codex/dev
mula.liu 2026-04-08 19:19:33 +08:00
parent ad16567e82
commit 41f71e649d
44 changed files with 5382 additions and 4700 deletions

View File

@ -1,391 +1,52 @@
from fastapi import APIRouter, Depends, Query
from app.core.auth import get_current_admin_user, get_current_user
from app.core.response import create_api_response
from app.core.database import get_db_connection
from app.models.models import (
MenuInfo,
MenuListResponse,
RolePermissionInfo,
UpdateRolePermissionsRequest,
RoleInfo,
CreateMenuRequest,
UpdateMenuRequest,
CreateRoleRequest,
UpdateMenuRequest,
UpdateRolePermissionsRequest,
UpdateRoleRequest,
)
from typing import List
import time
import app.services.admin_service as admin_service
router = APIRouter()
_USER_MENU_CACHE_TTL_SECONDS = 120
_USER_MENU_CACHE_VERSION = "menu-rules-v4"
_user_menu_cache_by_role = {}
def _get_cached_user_menus(role_id: int):
cached = _user_menu_cache_by_role.get(role_id)
if not cached:
return None
if cached.get("version") != _USER_MENU_CACHE_VERSION:
_user_menu_cache_by_role.pop(role_id, None)
return None
if time.time() > cached["expires_at"]:
_user_menu_cache_by_role.pop(role_id, None)
return None
return cached["menus"]
def _set_cached_user_menus(role_id: int, menus):
_user_menu_cache_by_role[role_id] = {
"version": _USER_MENU_CACHE_VERSION,
"menus": menus,
"expires_at": time.time() + _USER_MENU_CACHE_TTL_SECONDS,
}
def _invalidate_user_menu_cache(role_id: int | None = None):
if role_id is None:
_user_menu_cache_by_role.clear()
return
_user_menu_cache_by_role.pop(role_id, None)
def _build_menu_index(menus):
menu_by_id = {}
children_by_parent = {}
for menu in menus:
menu_id = menu["menu_id"]
menu_by_id[menu_id] = menu
parent_id = menu.get("parent_id")
if parent_id is not None:
children_by_parent.setdefault(parent_id, []).append(menu_id)
return menu_by_id, children_by_parent
def _get_descendants(menu_id, children_by_parent):
result = set()
stack = [menu_id]
while stack:
current = stack.pop()
for child_id in children_by_parent.get(current, []):
if child_id in result:
continue
result.add(child_id)
stack.append(child_id)
return result
def _normalize_permission_menu_ids(raw_menu_ids, all_menus):
"""
对权限菜单ID做归一化
1. 选中父节点 => 自动包含全部子孙节点
2. 选中子节点 => 自动包含全部祖先节点
"""
menu_by_id, children_by_parent = _build_menu_index(all_menus)
selected = {menu_id for menu_id in raw_menu_ids if menu_id in menu_by_id}
expanded = set(selected)
# 父 -> 子孙
for menu_id in list(expanded):
expanded.update(_get_descendants(menu_id, children_by_parent))
# 子 -> 祖先
for menu_id in list(expanded):
cursor = menu_by_id[menu_id].get("parent_id")
while cursor is not None and cursor in menu_by_id:
if cursor in expanded:
break
expanded.add(cursor)
cursor = menu_by_id[cursor].get("parent_id")
return sorted(expanded)
# ========== 菜单权限管理接口 ==========
@router.get("/admin/menus")
async def get_all_menus(current_user=Depends(get_current_admin_user)):
"""
获取所有菜单列表
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = """
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM sys_menus
ORDER BY
COALESCE(parent_id, menu_id) ASC,
CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC,
sort_order ASC,
menu_id ASC
"""
cursor.execute(query)
menus = cursor.fetchall()
menu_list = [MenuInfo(**menu) for menu in menus]
return create_api_response(
code="200",
message="获取菜单列表成功",
data=MenuListResponse(menus=menu_list, total=len(menu_list))
)
except Exception as e:
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
return admin_service.get_all_menus()
@router.post("/admin/menus")
async def create_menu(request: CreateMenuRequest, current_user=Depends(get_current_admin_user)):
"""
创建菜单
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_code = %s", (request.menu_code,))
if cursor.fetchone():
return create_api_response(code="400", message="菜单编码已存在")
if request.parent_id is not None:
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,))
if not cursor.fetchone():
return create_api_response(code="400", message="父菜单不存在")
cursor.execute(
"""
INSERT INTO sys_menus (menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
request.menu_code,
request.menu_name,
request.menu_icon,
request.menu_url,
request.menu_type,
request.parent_id,
request.sort_order,
1 if request.is_active else 0,
request.description,
),
)
menu_id = cursor.lastrowid
connection.commit()
_invalidate_user_menu_cache()
cursor.execute(
"""
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM sys_menus
WHERE menu_id = %s
""",
(menu_id,),
)
created = cursor.fetchone()
return create_api_response(code="200", message="创建菜单成功", data=created)
except Exception as e:
return create_api_response(code="500", message=f"创建菜单失败: {str(e)}")
return admin_service.create_menu(request)
@router.put("/admin/menus/{menu_id}")
async def update_menu(menu_id: int, request: UpdateMenuRequest, current_user=Depends(get_current_admin_user)):
"""
更新菜单
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM sys_menus WHERE menu_id = %s", (menu_id,))
current = cursor.fetchone()
if not current:
return create_api_response(code="404", message="菜单不存在")
updates = {}
for field in [
"menu_code",
"menu_name",
"menu_icon",
"menu_url",
"menu_type",
"sort_order",
"description",
]:
value = getattr(request, field)
if value is not None:
updates[field] = value
if request.is_active is not None:
updates["is_active"] = 1 if request.is_active else 0
fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set()))
# parent_id 允许设为 null且不允许设为自己
if request.parent_id == menu_id:
return create_api_response(code="400", message="父菜单不能为自身")
if request.parent_id is not None:
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,))
if not cursor.fetchone():
return create_api_response(code="400", message="父菜单不存在")
# 防止形成环:父菜单不能是当前菜单的子孙
cursor.execute("SELECT menu_id, parent_id FROM sys_menus")
all_menus = cursor.fetchall()
_, children_by_parent = _build_menu_index(all_menus)
descendants = _get_descendants(menu_id, children_by_parent)
if request.parent_id in descendants:
return create_api_response(code="400", message="父菜单不能设置为当前菜单的子孙菜单")
if request.parent_id is not None or (request.parent_id is None and "parent_id" in fields_set):
updates["parent_id"] = request.parent_id
if "menu_code" in updates:
cursor.execute(
"SELECT menu_id FROM sys_menus WHERE menu_code = %s AND menu_id != %s",
(updates["menu_code"], menu_id),
)
if cursor.fetchone():
return create_api_response(code="400", message="菜单编码已存在")
if not updates:
return create_api_response(code="200", message="没有变更内容", data=current)
set_sql = ", ".join([f"{k} = %s" for k in updates.keys()])
values = list(updates.values()) + [menu_id]
cursor.execute(f"UPDATE sys_menus SET {set_sql} WHERE menu_id = %s", tuple(values))
connection.commit()
_invalidate_user_menu_cache()
cursor.execute(
"""
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM sys_menus
WHERE menu_id = %s
""",
(menu_id,),
)
updated = cursor.fetchone()
return create_api_response(code="200", message="更新菜单成功", data=updated)
except Exception as e:
return create_api_response(code="500", message=f"更新菜单失败: {str(e)}")
return admin_service.update_menu(menu_id, request)
@router.delete("/admin/menus/{menu_id}")
async def delete_menu(menu_id: int, current_user=Depends(get_current_admin_user)):
"""
删除菜单有子菜单时不允许删除
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return admin_service.delete_menu(menu_id)
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (menu_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="菜单不存在")
cursor.execute("SELECT COUNT(*) AS cnt FROM sys_menus WHERE parent_id = %s", (menu_id,))
child_count = cursor.fetchone()["cnt"]
if child_count > 0:
return create_api_response(code="400", message="请先删除子菜单")
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE menu_id = %s", (menu_id,))
cursor.execute("DELETE FROM sys_menus WHERE menu_id = %s", (menu_id,))
connection.commit()
_invalidate_user_menu_cache()
return create_api_response(code="200", message="删除菜单成功")
except Exception as e:
return create_api_response(code="500", message=f"删除菜单失败: {str(e)}")
@router.get("/admin/roles")
async def get_all_roles(current_user=Depends(get_current_admin_user)):
"""
获取所有角色列表及其权限统计
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 查询所有角色及其权限数量
query = """
SELECT r.role_id, r.role_name, r.created_at,
COUNT(rmp.menu_id) as menu_count
FROM sys_roles r
LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id
GROUP BY r.role_id
ORDER BY r.role_id ASC
"""
cursor.execute(query)
roles = cursor.fetchall()
return create_api_response(
code="200",
message="获取角色列表成功",
data={"roles": roles, "total": len(roles)}
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
return admin_service.get_all_roles()
@router.post("/admin/roles")
async def create_role(request: CreateRoleRequest, current_user=Depends(get_current_admin_user)):
"""
创建角色
"""
try:
role_name = request.role_name.strip()
if not role_name:
return create_api_response(code="400", message="角色名称不能为空")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s", (role_name,))
if cursor.fetchone():
return create_api_response(code="400", message="角色名称已存在")
cursor.execute("INSERT INTO sys_roles (role_name) VALUES (%s)", (role_name,))
role_id = cursor.lastrowid
connection.commit()
cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
return create_api_response(code="200", message="创建角色成功", data=role)
except Exception as e:
return create_api_response(code="500", message=f"创建角色失败: {str(e)}")
return admin_service.create_role(request)
@router.put("/admin/roles/{role_id}")
async def update_role(role_id: int, request: UpdateRoleRequest, current_user=Depends(get_current_admin_user)):
"""
更新角色
"""
try:
role_name = request.role_name.strip()
if not role_name:
return create_api_response(code="400", message="角色名称不能为空")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="角色不存在")
cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s AND role_id != %s", (role_name, role_id))
if cursor.fetchone():
return create_api_response(code="400", message="角色名称已存在")
cursor.execute("UPDATE sys_roles SET role_name = %s WHERE role_id = %s", (role_name, role_id))
connection.commit()
cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
return create_api_response(code="200", message="更新角色成功", data=role)
except Exception as e:
return create_api_response(code="500", message=f"更新角色失败: {str(e)}")
return admin_service.update_role(role_id, request)
@router.get("/admin/roles/{role_id}/users")
@ -395,253 +56,28 @@ async def get_role_users(
size: int = Query(10, ge=1, le=100),
current_user=Depends(get_current_admin_user),
):
"""
获取角色下用户列表
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
if not role:
return create_api_response(code="404", message="角色不存在")
cursor.execute(
"""
SELECT COUNT(*) AS total
FROM sys_users
WHERE role_id = %s
""",
(role_id,),
)
total = cursor.fetchone()["total"]
offset = (page - 1) * size
cursor.execute(
"""
SELECT user_id, username, caption, email, avatar_url, role_id, created_at
FROM sys_users
WHERE role_id = %s
ORDER BY user_id ASC
LIMIT %s OFFSET %s
""",
(role_id, size, offset),
)
users = cursor.fetchall()
return create_api_response(
code="200",
message="获取角色用户成功",
data={
"role_id": role_id,
"role_name": role["role_name"],
"users": users,
"total": total,
"page": page,
"size": size,
},
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色用户失败: {str(e)}")
return admin_service.get_role_users(role_id, page, size)
@router.get("/admin/roles/permissions/all")
async def get_all_role_permissions(current_user=Depends(get_current_admin_user)):
"""
批量获取所有角色权限用于减少N次请求
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT rmp.role_id, rmp.menu_id
FROM sys_role_menu_permissions rmp
JOIN sys_menus m ON m.menu_id = rmp.menu_id
WHERE m.is_active = 1
ORDER BY rmp.role_id ASC, rmp.menu_id ASC
"""
)
rows = cursor.fetchall()
return admin_service.get_all_role_permissions()
result = {}
for row in rows:
result.setdefault(row["role_id"], []).append(row["menu_id"])
return create_api_response(code="200", message="获取角色权限成功", data={"permissions": result})
except Exception as e:
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
@router.get("/admin/roles/{role_id}/permissions")
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
"""
获取指定角色的菜单权限
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return admin_service.get_role_permissions(role_id)
# 检查角色是否存在
cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
if not role:
return create_api_response(code="404", message="角色不存在")
# 查询该角色的所有菜单权限
query = """
SELECT rmp.menu_id
FROM sys_role_menu_permissions rmp
JOIN sys_menus m ON m.menu_id = rmp.menu_id
WHERE rmp.role_id = %s
AND m.is_active = 1
"""
cursor.execute(query, (role_id,))
permissions = cursor.fetchall()
menu_ids = [p['menu_id'] for p in permissions]
return create_api_response(
code="200",
message="获取角色权限成功",
data=RolePermissionInfo(
role_id=role['role_id'],
role_name=role['role_name'],
menu_ids=menu_ids
)
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
@router.put("/admin/roles/{role_id}/permissions")
async def update_role_permissions(
role_id: int,
request: UpdateRolePermissionsRequest,
current_user=Depends(get_current_admin_user)
current_user=Depends(get_current_admin_user),
):
"""
更新指定角色的菜单权限
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return admin_service.update_role_permissions(role_id, request)
# 检查角色是否存在
cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="角色不存在")
cursor.execute(
"""
SELECT menu_id, parent_id
FROM sys_menus
WHERE is_active = 1
"""
)
all_menus = cursor.fetchall()
menu_id_set = {menu["menu_id"] for menu in all_menus}
# 验证所有menu_id是否有效
invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set]
if invalid_menu_ids:
return create_api_response(code="400", message="包含无效的菜单ID")
normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus)
# 删除该角色的所有现有权限
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,))
# 插入新的权限
if normalized_menu_ids:
insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids]
cursor.executemany(
"INSERT INTO sys_role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
insert_values
)
connection.commit()
_invalidate_user_menu_cache(role_id)
return create_api_response(
code="200",
message="更新角色权限成功",
data={"role_id": role_id, "menu_count": len(normalized_menu_ids)}
)
except Exception as e:
return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}")
@router.get("/menus/user")
async def get_user_menus(current_user=Depends(get_current_user)):
"""
获取当前用户可访问的菜单列表用于渲染下拉菜单
所有登录用户都可以访问
"""
try:
role_id = current_user["role_id"]
cached_menus = _get_cached_user_menus(role_id)
if cached_menus is not None:
return create_api_response(
code="200",
message="获取用户菜单成功",
data={"menus": cached_menus}
)
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 根据用户的role_id查询可访问的菜单
query = """
SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
m.menu_url, m.menu_type, m.parent_id, m.sort_order
FROM sys_menus m
JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id
WHERE rmp.role_id = %s
AND m.is_active = 1
AND (m.is_visible = 1 OR m.is_visible IS NULL OR m.menu_code IN ('dashboard', 'desktop'))
ORDER BY
COALESCE(m.parent_id, m.menu_id) ASC,
CASE WHEN m.parent_id IS NULL THEN 0 ELSE 1 END ASC,
m.sort_order ASC,
m.menu_id ASC
"""
cursor.execute(query, (role_id,))
menus = cursor.fetchall()
# 仅在缺失父菜单时补查减少不必要的SQL
current_menu_ids = {menu["menu_id"] for menu in menus}
missing_parent_ids = {
menu["parent_id"] for menu in menus
if menu.get("parent_id") is not None and menu["parent_id"] not in current_menu_ids
}
if missing_parent_ids:
format_strings = ",".join(["%s"] * len(missing_parent_ids))
cursor.execute(
f"""
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order
FROM sys_menus
WHERE is_active = 1 AND menu_id IN ({format_strings})
""",
tuple(missing_parent_ids),
)
parent_rows = cursor.fetchall()
menus.extend(parent_rows)
current_menu_ids.update(row["menu_id"] for row in parent_rows)
menus = sorted(
{menu["menu_id"]: menu for menu in menus}.values(),
key=lambda m: (
m["parent_id"] if m["parent_id"] is not None else m["menu_id"],
0 if m["parent_id"] is None else 1,
m["sort_order"],
m["menu_id"],
),
)
_set_cached_user_menus(role_id, menus)
return create_api_response(
code="200",
message="获取用户菜单成功",
data={"menus": menus}
)
except Exception as e:
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")
return admin_service.get_user_menus(current_user)

View File

@ -1,281 +1,25 @@
from fastapi import APIRouter, Depends, Query
from app.core.auth import get_current_admin_user
from app.core.response import create_api_response
from app.core.database import get_db_connection
from app.services.jwt_service import jwt_service
from app.core.config import AUDIO_DIR, REDIS_CONFIG
from datetime import datetime
from typing import Dict, List
import os
import redis
import app.services.admin_dashboard_service as admin_dashboard_service
router = APIRouter()
# Redis 客户端
redis_client = redis.Redis(**REDIS_CONFIG)
# 常量定义
AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.mpeg', '.mp4', '.webm')
BYTES_TO_GB = 1024 ** 3
def _build_status_condition(status: str) -> str:
"""构建任务状态查询条件"""
if status == 'running':
return "AND (t.status = 'pending' OR t.status = 'processing')"
elif status == 'completed':
return "AND t.status = 'completed'"
elif status == 'failed':
return "AND t.status = 'failed'"
return ""
def _get_task_stats_query() -> str:
"""获取任务统计的 SQL 查询"""
return """
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
"""
def _get_online_user_count(redis_client) -> int:
"""从 Redis 获取在线用户数"""
try:
token_keys = redis_client.keys("token:*")
user_ids = set()
for key in token_keys:
if isinstance(key, bytes):
key = key.decode("utf-8", errors="ignore")
parts = key.split(':')
if len(parts) >= 2:
user_ids.add(parts[1])
return len(user_ids)
except Exception as e:
print(f"获取在线用户数失败: {e}")
return 0
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute(
"""
SELECT COUNT(*) AS cnt
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = %s
""",
(table_name,),
)
return (cursor.fetchone() or {}).get("cnt", 0) > 0
def _calculate_audio_storage() -> Dict[str, float]:
"""计算音频文件存储统计"""
audio_files_count = 0
audio_total_size = 0
try:
if os.path.exists(AUDIO_DIR):
for root, _, files in os.walk(AUDIO_DIR):
for file in files:
file_extension = os.path.splitext(file)[1].lower()
if file_extension in AUDIO_FILE_EXTENSIONS:
audio_files_count += 1
file_path = os.path.join(root, file)
try:
audio_total_size += os.path.getsize(file_path)
except OSError:
continue
except Exception as e:
print(f"统计音频文件失败: {e}")
return {
"audio_file_count": audio_files_count,
"audio_files_count": audio_files_count,
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
}
@router.get("/admin/dashboard/stats")
async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
"""获取管理员 Dashboard 统计数据"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 1. 用户统计
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_users = 0
today_new_users = 0
if _table_exists(cursor, "sys_users"):
cursor.execute("SELECT COUNT(*) as total FROM sys_users")
total_users = (cursor.fetchone() or {}).get("total", 0)
cursor.execute(
"SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s",
(today_start,),
)
today_new_users = (cursor.fetchone() or {}).get("count", 0)
online_users = _get_online_user_count(redis_client)
# 2. 会议统计
total_meetings = 0
today_new_meetings = 0
if _table_exists(cursor, "meetings"):
cursor.execute("SELECT COUNT(*) as total FROM meetings")
total_meetings = (cursor.fetchone() or {}).get("total", 0)
cursor.execute(
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
(today_start,),
)
today_new_meetings = (cursor.fetchone() or {}).get("count", 0)
# 3. 任务统计
task_stats_query = _get_task_stats_query()
# 转录任务
if _table_exists(cursor, "transcript_tasks"):
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
else:
transcription_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 总结任务
if _table_exists(cursor, "llm_tasks"):
cursor.execute(f"{task_stats_query} FROM llm_tasks")
summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
else:
summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 知识库任务
if _table_exists(cursor, "knowledge_base_tasks"):
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
else:
kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 4. 音频存储统计
storage_stats = _calculate_audio_storage()
# 组装返回数据
stats = {
"users": {
"total": total_users,
"today_new": today_new_users,
"online": online_users
},
"meetings": {
"total": total_meetings,
"today_new": today_new_meetings
},
"tasks": {
"transcription": {
"total": transcription_stats['total'] or 0,
"running": transcription_stats['running'] or 0,
"completed": transcription_stats['completed'] or 0,
"failed": transcription_stats['failed'] or 0
},
"summary": {
"total": summary_stats['total'] or 0,
"running": summary_stats['running'] or 0,
"completed": summary_stats['completed'] or 0,
"failed": summary_stats['failed'] or 0
},
"knowledge_base": {
"total": kb_stats['total'] or 0,
"running": kb_stats['running'] or 0,
"completed": kb_stats['completed'] or 0,
"failed": kb_stats['failed'] or 0
}
},
"storage": storage_stats
}
return create_api_response(code="200", message="获取统计数据成功", data=stats)
except Exception as e:
print(f"获取Dashboard统计数据失败: {e}")
return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}")
return await admin_dashboard_service.get_dashboard_stats(current_user)
@router.get("/admin/online-users")
async def get_online_users(current_user=Depends(get_current_admin_user)):
"""获取在线用户列表"""
try:
token_keys = redis_client.keys("token:*")
# 提取用户ID并去重
user_tokens = {}
for key in token_keys:
if isinstance(key, bytes):
key = key.decode("utf-8", errors="ignore")
parts = key.split(':')
if len(parts) >= 3:
user_id = int(parts[1])
token = parts[2]
if user_id not in user_tokens:
user_tokens[user_id] = []
user_tokens[user_id].append({'token': token, 'key': key})
# 查询用户信息
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
online_users_list = []
for user_id, tokens in user_tokens.items():
cursor.execute(
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
(user_id,)
)
user = cursor.fetchone()
if user:
ttl_seconds = redis_client.ttl(tokens[0]['key'])
online_users_list.append({
**user,
'token_count': len(tokens),
'ttl_seconds': ttl_seconds,
'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0
})
# 按用户ID排序
online_users_list.sort(key=lambda x: x['user_id'])
return create_api_response(
code="200",
message="获取在线用户列表成功",
data={"users": online_users_list, "total": len(online_users_list)}
)
except Exception as e:
print(f"获取在线用户列表失败: {e}")
return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}")
return await admin_dashboard_service.get_online_users(current_user)
@router.post("/admin/kick-user/{user_id}")
async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)):
"""踢出用户(撤销该用户的所有 token"""
try:
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
if revoked_count > 0:
return create_api_response(
code="200",
message=f"已踢出用户,撤销了 {revoked_count} 个 token",
data={"user_id": user_id, "revoked_count": revoked_count}
)
else:
return create_api_response(
code="404",
message="该用户当前不在线或未找到 token"
)
except Exception as e:
print(f"踢出用户失败: {e}")
return create_api_response(code="500", message=f"踢出用户失败: {str(e)}")
return await admin_dashboard_service.kick_user(user_id, current_user)
@router.get("/admin/tasks/monitor")
@ -285,207 +29,14 @@ async def monitor_tasks(
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
current_user=Depends(get_current_admin_user)
):
"""监控任务进度"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
tasks = []
status_condition = _build_status_condition(status)
# 转录任务
if task_type in ['all', 'transcription']:
query = f"""
SELECT
t.task_id,
'transcription' as task_type,
t.meeting_id,
m.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.completed_at,
u.username as creator_name
FROM transcript_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN sys_users u ON m.user_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 总结任务
if task_type in ['all', 'summary']:
query = f"""
SELECT
t.task_id,
'summary' as task_type,
t.meeting_id,
m.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.completed_at,
u.username as creator_name
FROM llm_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN sys_users u ON m.user_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 知识库任务
if task_type in ['all', 'knowledge_base']:
query = f"""
SELECT
t.task_id,
'knowledge_base' as task_type,
t.kb_id as meeting_id,
k.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.updated_at,
u.username as creator_name
FROM knowledge_base_tasks t
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
LEFT JOIN sys_users u ON k.creator_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 按创建时间排序并限制返回数量
tasks.sort(key=lambda x: x['created_at'], reverse=True)
tasks = tasks[:limit]
return create_api_response(
code="200",
message="获取任务监控数据成功",
data={"tasks": tasks, "total": len(tasks)}
)
except Exception as e:
print(f"获取任务监控数据失败: {e}")
import traceback
traceback.print_exc()
return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}")
return await admin_dashboard_service.monitor_tasks(task_type, status, limit, current_user)
@router.get("/admin/system/resources")
async def get_system_resources(current_user=Depends(get_current_admin_user)):
"""获取服务器资源使用情况"""
try:
import psutil
# CPU 使用率
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# 内存使用情况
memory = psutil.virtual_memory()
memory_total_gb = round(memory.total / BYTES_TO_GB, 2)
memory_used_gb = round(memory.used / BYTES_TO_GB, 2)
# 磁盘使用情况
disk = psutil.disk_usage('/')
disk_total_gb = round(disk.total / BYTES_TO_GB, 2)
disk_used_gb = round(disk.used / BYTES_TO_GB, 2)
resources = {
"cpu": {
"percent": cpu_percent,
"count": cpu_count
},
"memory": {
"total_gb": memory_total_gb,
"used_gb": memory_used_gb,
"percent": memory.percent
},
"disk": {
"total_gb": disk_total_gb,
"used_gb": disk_used_gb,
"percent": disk.percent
},
"timestamp": datetime.now().isoformat()
}
return create_api_response(code="200", message="获取系统资源成功", data=resources)
except ImportError:
return create_api_response(
code="500",
message="psutil 库未安装,请运行: pip install psutil"
)
except Exception as e:
print(f"获取系统资源失败: {e}")
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
return await admin_dashboard_service.get_system_resources(current_user)
@router.get("/admin/user-stats")
async def get_user_stats(current_user=Depends(get_current_admin_user)):
"""获取用户统计列表"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
query = """
SELECT
u.user_id,
u.username,
u.caption,
u.created_at,
(SELECT MAX(created_at) FROM user_logs
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
COUNT(DISTINCT m.meeting_id) as meeting_count,
COALESCE(SUM(af.duration), 0) as total_duration_seconds
FROM sys_users u
INNER JOIN meetings m ON u.user_id = m.user_id
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
GROUP BY u.user_id, u.username, u.caption, u.created_at
HAVING meeting_count > 0
ORDER BY u.user_id ASC
"""
cursor.execute(query)
users = cursor.fetchall()
# 格式化返回数据
users_list = []
for user in users:
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
users_list.append({
'user_id': user['user_id'],
'username': user['username'],
'caption': user['caption'],
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
'meeting_count': user['meeting_count'],
'total_duration_seconds': total_seconds,
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
})
return create_api_response(
code="200",
message="获取用户统计成功",
data={"users": users_list, "total": len(users_list)}
)
except Exception as e:
print(f"获取用户统计失败: {e}")
import traceback
traceback.print_exc()
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")
return await admin_dashboard_service.get_user_stats(current_user)

View File

@ -1,215 +1,13 @@
import json
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from app.core.auth import get_current_admin_user
from app.core.database import get_db_connection
from app.core.response import create_api_response
from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.llm_service import LLMService
from app.services.system_config_service import SystemConfigService
import app.services.admin_settings_service as admin_settings_service
router = APIRouter()
llm_service = LLMService()
transcription_service = AsyncTranscriptionService()
def _parse_json_object(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
value = value.strip()
if not value:
return {}
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _normalize_string_list(value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, list):
values = [str(item).strip() for item in value if str(item).strip()]
return values or None
if isinstance(value, str):
values = [item.strip() for item in value.split(",") if item.strip()]
return values or None
return None
def _normalize_int_list(value: Any) -> list[int] | None:
if value is None:
return None
if isinstance(value, list):
items = value
elif isinstance(value, str):
items = [item.strip() for item in value.split(",") if item.strip()]
else:
return None
normalized = []
for item in items:
try:
normalized.append(int(item))
except (TypeError, ValueError):
continue
return normalized or None
def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]:
cleaned: dict[str, Any] = {}
for key, value in (config or {}).items():
if value is None:
continue
if isinstance(value, str):
stripped = value.strip()
if stripped:
cleaned[key] = stripped
continue
if isinstance(value, list):
normalized_list = []
for item in value:
if item is None:
continue
if isinstance(item, str):
stripped = item.strip()
if stripped:
normalized_list.append(stripped)
else:
normalized_list.append(item)
if normalized_list:
cleaned[key] = normalized_list
continue
cleaned[key] = value
return cleaned
def _merge_audio_extra_config(
request: "AudioModelUpsertRequest",
vocabulary_id: str | None = None,
) -> dict[str, Any]:
extra_config = _parse_json_object(request.extra_config)
if request.audio_scene == "asr":
legacy_config = {
"model": request.asr_model_name,
"speaker_count": request.asr_speaker_count,
"language_hints": request.asr_language_hints,
"disfluency_removal_enabled": request.asr_disfluency_removal_enabled,
"diarization_enabled": request.asr_diarization_enabled,
}
else:
legacy_config = {
"model": request.model_name,
"template_text": request.vp_template_text,
"duration_seconds": request.vp_duration_seconds,
"sample_rate": request.vp_sample_rate,
"channels": request.vp_channels,
"max_size_bytes": request.vp_max_size_bytes,
}
merged = {**legacy_config, **extra_config}
language_hints = _normalize_string_list(merged.get("language_hints"))
if language_hints is not None:
merged["language_hints"] = language_hints
channel_id = _normalize_int_list(merged.get("channel_id"))
if channel_id is not None:
merged["channel_id"] = channel_id
resolved_vocabulary_id = vocabulary_id or merged.get("vocabulary_id") or request.asr_vocabulary_id
if request.audio_scene == "asr" and resolved_vocabulary_id:
merged["vocabulary_id"] = resolved_vocabulary_id
return _clean_extra_config(merged)
def _extract_legacy_audio_columns(audio_scene: str, extra_config: dict[str, Any]) -> dict[str, Any]:
extra_config = _parse_json_object(extra_config)
columns = {
"asr_model_name": None,
"asr_vocabulary_id": None,
"asr_speaker_count": None,
"asr_language_hints": None,
"asr_disfluency_removal_enabled": None,
"asr_diarization_enabled": None,
"vp_template_text": None,
"vp_duration_seconds": None,
"vp_sample_rate": None,
"vp_channels": None,
"vp_max_size_bytes": None,
}
if audio_scene == "asr":
language_hints = extra_config.get("language_hints")
if isinstance(language_hints, list):
language_hints = ",".join(str(item).strip() for item in language_hints if str(item).strip())
columns.update(
{
"asr_model_name": extra_config.get("model"),
"asr_vocabulary_id": extra_config.get("vocabulary_id"),
"asr_speaker_count": extra_config.get("speaker_count"),
"asr_language_hints": language_hints,
"asr_disfluency_removal_enabled": 1 if extra_config.get("disfluency_removal_enabled") is True else 0 if extra_config.get("disfluency_removal_enabled") is False else None,
"asr_diarization_enabled": 1 if extra_config.get("diarization_enabled") is True else 0 if extra_config.get("diarization_enabled") is False else None,
}
)
else:
columns.update(
{
"vp_template_text": extra_config.get("template_text"),
"vp_duration_seconds": extra_config.get("duration_seconds"),
"vp_sample_rate": extra_config.get("sample_rate"),
"vp_channels": extra_config.get("channels"),
"vp_max_size_bytes": extra_config.get("max_size_bytes"),
}
)
return columns
def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]:
extra_config = _parse_json_object(row.get("extra_config"))
if row.get("audio_scene") == "asr":
if extra_config.get("model") is None and row.get("asr_model_name") is not None:
extra_config["model"] = row["asr_model_name"]
if extra_config.get("vocabulary_id") is None and row.get("asr_vocabulary_id") is not None:
extra_config["vocabulary_id"] = row["asr_vocabulary_id"]
if extra_config.get("speaker_count") is None and row.get("asr_speaker_count") is not None:
extra_config["speaker_count"] = row["asr_speaker_count"]
if extra_config.get("language_hints") is None and row.get("asr_language_hints"):
extra_config["language_hints"] = _normalize_string_list(row["asr_language_hints"])
if extra_config.get("disfluency_removal_enabled") is None and row.get("asr_disfluency_removal_enabled") is not None:
extra_config["disfluency_removal_enabled"] = bool(row["asr_disfluency_removal_enabled"])
if extra_config.get("diarization_enabled") is None and row.get("asr_diarization_enabled") is not None:
extra_config["diarization_enabled"] = bool(row["asr_diarization_enabled"])
else:
if extra_config.get("model") is None and row.get("model_name"):
extra_config["model"] = row["model_name"]
if extra_config.get("template_text") is None and row.get("vp_template_text") is not None:
extra_config["template_text"] = row["vp_template_text"]
if extra_config.get("duration_seconds") is None and row.get("vp_duration_seconds") is not None:
extra_config["duration_seconds"] = row["vp_duration_seconds"]
if extra_config.get("sample_rate") is None and row.get("vp_sample_rate") is not None:
extra_config["sample_rate"] = row["vp_sample_rate"]
if extra_config.get("channels") is None and row.get("vp_channels") is not None:
extra_config["channels"] = row["vp_channels"]
if extra_config.get("max_size_bytes") is None and row.get("vp_max_size_bytes") is not None:
extra_config["max_size_bytes"] = row["vp_max_size_bytes"]
row["extra_config"] = extra_config
row["service_model_name"] = extra_config.get("model")
return row
class ParameterUpsertRequest(BaseModel):
@ -242,7 +40,7 @@ class LLMModelUpsertRequest(BaseModel):
class AudioModelUpsertRequest(BaseModel):
model_code: str
model_name: str
audio_scene: str # asr / voiceprint
audio_scene: str
provider: str | None = None
endpoint_url: str | None = None
api_key: str | None = None
@ -278,89 +76,17 @@ async def list_parameters(
keyword: str | None = Query(None),
current_user=Depends(get_current_admin_user),
):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT param_id, param_key, param_name, param_value, value_type, category,
description, is_active, created_at, updated_at
FROM sys_system_parameters
WHERE 1=1
"""
params = []
if category:
query += " AND category = %s"
params.append(category)
if keyword:
like_pattern = f"%{keyword}%"
query += " AND (param_key LIKE %s OR param_name LIKE %s)"
params.extend([like_pattern, like_pattern])
query += " ORDER BY category ASC, param_key ASC"
cursor.execute(query, tuple(params))
rows = cursor.fetchall()
return create_api_response(
code="200",
message="获取参数列表成功",
data={"items": rows, "total": len(rows)},
)
except Exception as e:
return create_api_response(code="500", message=f"获取参数列表失败: {str(e)}")
return admin_settings_service.list_parameters(category, keyword)
@router.get("/admin/parameters/{param_key}")
async def get_parameter(param_key: str, current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_id, param_key, param_name, param_value, value_type, category,
description, is_active, created_at, updated_at
FROM sys_system_parameters
WHERE param_key = %s
LIMIT 1
""",
(param_key,),
)
row = cursor.fetchone()
if not row:
return create_api_response(code="404", message="参数不存在")
return create_api_response(code="200", message="获取参数成功", data=row)
except Exception as e:
return create_api_response(code="500", message=f"获取参数失败: {str(e)}")
return admin_settings_service.get_parameter(param_key)
@router.post("/admin/parameters")
async def create_parameter(request: ParameterUpsertRequest, current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (request.param_key,))
if cursor.fetchone():
return create_api_response(code="400", message="参数键已存在")
cursor.execute(
"""
INSERT INTO sys_system_parameters
(param_key, param_name, param_value, value_type, category, description, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
request.param_key,
request.param_name,
request.param_value,
request.value_type,
request.category,
request.description,
1 if request.is_active else 0,
),
)
conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="创建参数成功")
except Exception as e:
return create_api_response(code="500", message=f"创建参数失败: {str(e)}")
return admin_settings_service.create_parameter(request)
@router.put("/admin/parameters/{param_key}")
@ -369,131 +95,22 @@ async def update_parameter(
request: ParameterUpsertRequest,
current_user=Depends(get_current_admin_user),
):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,))
existed = cursor.fetchone()
if not existed:
return create_api_response(code="404", message="参数不存在")
new_key = request.param_key or param_key
if new_key != param_key:
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (new_key,))
if cursor.fetchone():
return create_api_response(code="400", message="新的参数键已存在")
cursor.execute(
"""
UPDATE sys_system_parameters
SET param_key = %s, param_name = %s, param_value = %s, value_type = %s,
category = %s, description = %s, is_active = %s
WHERE param_key = %s
""",
(
new_key,
request.param_name,
request.param_value,
request.value_type,
request.category,
request.description,
1 if request.is_active else 0,
param_key,
),
)
conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="更新参数成功")
except Exception as e:
return create_api_response(code="500", message=f"更新参数失败: {str(e)}")
return admin_settings_service.update_parameter(param_key, request)
@router.delete("/admin/parameters/{param_key}")
async def delete_parameter(param_key: str, current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,))
existed = cursor.fetchone()
if not existed:
return create_api_response(code="404", message="参数不存在")
cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,))
conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="删除参数成功")
except Exception as e:
return create_api_response(code="500", message=f"删除参数失败: {str(e)}")
return admin_settings_service.delete_parameter(param_key)
@router.get("/admin/model-configs/llm")
async def list_llm_model_configs(current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT config_id, model_code, model_name, provider, endpoint_url, api_key,
llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens,
llm_system_prompt, description, is_active, is_default, created_at, updated_at
FROM llm_model_config
ORDER BY model_code ASC
"""
)
rows = cursor.fetchall()
return create_api_response(
code="200",
message="获取LLM模型配置成功",
data={"items": rows, "total": len(rows)},
)
except Exception as e:
return create_api_response(code="500", message=f"获取LLM模型配置失败: {str(e)}")
return admin_settings_service.list_llm_model_configs()
@router.post("/admin/model-configs/llm")
async def create_llm_model_config(request: LLMModelUpsertRequest, current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,))
if cursor.fetchone():
return create_api_response(code="400", message="模型编码已存在")
cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config")
total_row = cursor.fetchone() or {"total": 0}
is_default = bool(request.is_default) or total_row["total"] == 0
if is_default:
cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1")
cursor.execute(
"""
INSERT INTO llm_model_config
(model_code, model_name, provider, endpoint_url, api_key, llm_model_name,
llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt,
description, is_active, is_default)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
request.model_code,
request.model_name,
request.provider,
request.endpoint_url,
request.api_key,
request.llm_model_name,
request.llm_timeout,
request.llm_temperature,
request.llm_top_p,
request.llm_max_tokens,
request.llm_system_prompt,
request.description,
1 if request.is_active else 0,
1 if is_default else 0,
),
)
conn.commit()
return create_api_response(code="200", message="创建LLM模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"创建LLM模型配置失败: {str(e)}")
return admin_settings_service.create_llm_model_config(request)
@router.put("/admin/model-configs/llm/{model_code}")
@ -502,54 +119,7 @@ async def update_llm_model_config(
request: LLMModelUpsertRequest,
current_user=Depends(get_current_admin_user),
):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,))
existed = cursor.fetchone()
if not existed:
return create_api_response(code="404", message="模型配置不存在")
new_model_code = request.model_code or model_code
if new_model_code != model_code:
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (new_model_code,))
duplicate_row = cursor.fetchone()
if duplicate_row and duplicate_row["config_id"] != existed["config_id"]:
return create_api_response(code="400", message="新的模型编码已存在")
if request.is_default:
cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1", (model_code,))
cursor.execute(
"""
UPDATE llm_model_config
SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s,
llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s,
llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s
WHERE model_code = %s
""",
(
new_model_code,
request.model_name,
request.provider,
request.endpoint_url,
request.api_key,
request.llm_model_name,
request.llm_timeout,
request.llm_temperature,
request.llm_top_p,
request.llm_max_tokens,
request.llm_system_prompt,
request.description,
1 if request.is_active else 0,
1 if request.is_default else 0,
model_code,
),
)
conn.commit()
return create_api_response(code="200", message="更新LLM模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"更新LLM模型配置失败: {str(e)}")
return admin_settings_service.update_llm_model_config(model_code, request)
@router.get("/admin/model-configs/audio")
@ -557,97 +127,12 @@ async def list_audio_model_configs(
scene: str = Query("all"),
current_user=Depends(get_current_admin_user),
):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
sql = """
SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key,
a.asr_model_name, a.asr_vocabulary_id, a.hot_word_group_id, a.asr_speaker_count, a.asr_language_hints,
a.asr_disfluency_removal_enabled, a.asr_diarization_enabled,
a.vp_template_text, a.vp_duration_seconds, a.vp_sample_rate, a.vp_channels, a.vp_max_size_bytes,
a.extra_config, a.description, a.is_active, a.is_default, a.created_at, a.updated_at,
g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id
FROM audio_model_config a
LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id
"""
params = []
if scene in ("asr", "voiceprint"):
sql += " WHERE a.audio_scene = %s"
params.append(scene)
sql += " ORDER BY a.audio_scene ASC, a.model_code ASC"
cursor.execute(sql, tuple(params))
rows = [_normalize_audio_row(row) for row in cursor.fetchall()]
return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)})
except Exception as e:
return create_api_response(code="500", message=f"获取音频模型配置失败: {str(e)}")
return admin_settings_service.list_audio_model_configs(scene)
@router.post("/admin/model-configs/audio")
async def create_audio_model_config(request: AudioModelUpsertRequest, current_user=Depends(get_current_admin_user)):
try:
if request.audio_scene not in ("asr", "voiceprint"):
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint")
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (request.model_code,))
if cursor.fetchone():
return create_api_response(code="400", message="模型编码已存在")
cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,))
total_row = cursor.fetchone() or {"total": 0}
is_default = bool(request.is_default) or total_row["total"] == 0
if is_default:
cursor.execute("UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1", (request.audio_scene,))
# 如果指定了热词组,从组中获取 vocabulary_id
asr_vocabulary_id = request.asr_vocabulary_id
if request.hot_word_group_id:
cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,))
group_row = cursor.fetchone()
if group_row and group_row.get("vocabulary_id"):
asr_vocabulary_id = group_row["vocabulary_id"]
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config)
cursor.execute(
"""
INSERT INTO audio_model_config
(model_code, model_name, audio_scene, provider, endpoint_url, api_key,
asr_model_name, asr_vocabulary_id, hot_word_group_id, asr_speaker_count, asr_language_hints,
asr_disfluency_removal_enabled, asr_diarization_enabled,
vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes,
extra_config, description, is_active, is_default)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
request.model_code,
request.model_name,
request.audio_scene,
request.provider,
request.endpoint_url,
request.api_key,
legacy_columns["asr_model_name"],
legacy_columns["asr_vocabulary_id"],
request.hot_word_group_id,
legacy_columns["asr_speaker_count"],
legacy_columns["asr_language_hints"],
legacy_columns["asr_disfluency_removal_enabled"],
legacy_columns["asr_diarization_enabled"],
legacy_columns["vp_template_text"],
legacy_columns["vp_duration_seconds"],
legacy_columns["vp_sample_rate"],
legacy_columns["vp_channels"],
legacy_columns["vp_max_size_bytes"],
json.dumps(extra_config, ensure_ascii=False),
request.description,
1 if request.is_active else 0,
1 if is_default else 0,
),
)
conn.commit()
return create_api_response(code="200", message="创建音频模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"创建音频模型配置失败: {str(e)}")
return admin_settings_service.create_audio_model_config(request)
@router.put("/admin/model-configs/audio/{model_code}")
@ -656,196 +141,34 @@ async def update_audio_model_config(
request: AudioModelUpsertRequest,
current_user=Depends(get_current_admin_user),
):
try:
if request.audio_scene not in ("asr", "voiceprint"):
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint")
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,))
existed = cursor.fetchone()
if not existed:
return create_api_response(code="404", message="模型配置不存在")
new_model_code = request.model_code or model_code
if new_model_code != model_code:
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (new_model_code,))
duplicate_row = cursor.fetchone()
if duplicate_row and duplicate_row["config_id"] != existed["config_id"]:
return create_api_response(code="400", message="新的模型编码已存在")
if request.is_default:
cursor.execute(
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1",
(request.audio_scene, model_code),
)
# 如果指定了热词组,从组中获取 vocabulary_id
asr_vocabulary_id = request.asr_vocabulary_id
if request.hot_word_group_id:
cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,))
group_row = cursor.fetchone()
if group_row and group_row.get("vocabulary_id"):
asr_vocabulary_id = group_row["vocabulary_id"]
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config)
cursor.execute(
"""
UPDATE audio_model_config
SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s,
asr_model_name = %s, asr_vocabulary_id = %s, hot_word_group_id = %s, asr_speaker_count = %s, asr_language_hints = %s,
asr_disfluency_removal_enabled = %s, asr_diarization_enabled = %s,
vp_template_text = %s, vp_duration_seconds = %s, vp_sample_rate = %s, vp_channels = %s, vp_max_size_bytes = %s,
extra_config = %s, description = %s, is_active = %s, is_default = %s
WHERE model_code = %s
""",
(
new_model_code,
request.model_name,
request.audio_scene,
request.provider,
request.endpoint_url,
request.api_key,
legacy_columns["asr_model_name"],
legacy_columns["asr_vocabulary_id"],
request.hot_word_group_id,
legacy_columns["asr_speaker_count"],
legacy_columns["asr_language_hints"],
legacy_columns["asr_disfluency_removal_enabled"],
legacy_columns["asr_diarization_enabled"],
legacy_columns["vp_template_text"],
legacy_columns["vp_duration_seconds"],
legacy_columns["vp_sample_rate"],
legacy_columns["vp_channels"],
legacy_columns["vp_max_size_bytes"],
json.dumps(extra_config, ensure_ascii=False),
request.description,
1 if request.is_active else 0,
1 if request.is_default else 0,
model_code,
),
)
conn.commit()
return create_api_response(code="200", message="更新音频模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"更新音频模型配置失败: {str(e)}")
return admin_settings_service.update_audio_model_config(model_code, request)
@router.delete("/admin/model-configs/llm/{model_code}")
async def delete_llm_model_config(model_code: str, current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,))
if not cursor.fetchone():
return create_api_response(code="404", message="模型配置不存在")
cursor.execute("DELETE FROM llm_model_config WHERE model_code = %s", (model_code,))
conn.commit()
return create_api_response(code="200", message="删除LLM模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"删除LLM模型配置失败: {str(e)}")
return admin_settings_service.delete_llm_model_config(model_code)
@router.delete("/admin/model-configs/audio/{model_code}")
async def delete_audio_model_config(model_code: str, current_user=Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,))
if not cursor.fetchone():
return create_api_response(code="404", message="模型配置不存在")
cursor.execute("DELETE FROM audio_model_config WHERE model_code = %s", (model_code,))
conn.commit()
return create_api_response(code="200", message="删除音频模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"删除音频模型配置失败: {str(e)}")
return admin_settings_service.delete_audio_model_config(model_code)
@router.post("/admin/model-configs/llm/test")
async def test_llm_model_config(request: LLMModelTestRequest, current_user=Depends(get_current_admin_user)):
try:
payload = request.model_dump() if hasattr(request, "model_dump") else request.dict()
result = llm_service.test_model(payload, prompt=request.test_prompt)
return create_api_response(code="200", message="LLM模型测试成功", data=result)
except Exception as e:
return create_api_response(code="500", message=f"LLM模型测试失败: {str(e)}")
return admin_settings_service.test_llm_model_config(request)
@router.post("/admin/model-configs/audio/test")
async def test_audio_model_config(request: AudioModelTestRequest, current_user=Depends(get_current_admin_user)):
try:
if request.audio_scene != "asr":
return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试")
vocabulary_id = request.asr_vocabulary_id
if request.hot_word_group_id:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,))
group_row = cursor.fetchone()
cursor.close()
if group_row and group_row.get("vocabulary_id"):
vocabulary_id = group_row["vocabulary_id"]
extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id)
runtime_config = {
"provider": request.provider,
"endpoint_url": request.endpoint_url,
"api_key": request.api_key,
"audio_scene": request.audio_scene,
"hot_word_group_id": request.hot_word_group_id,
**extra_config,
}
result = transcription_service.test_asr_model(runtime_config, test_file_url=request.test_file_url)
return create_api_response(code="200", message="音频模型测试任务已提交", data=result)
except Exception as e:
return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}")
return admin_settings_service.test_audio_model_config(request)
@router.get("/system-config/public")
async def get_public_system_config():
try:
return create_api_response(
code="200",
message="获取公开配置成功",
data=SystemConfigService.get_public_configs()
)
except Exception as e:
return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}")
return admin_settings_service.get_public_system_config()
@router.get("/admin/system-config")
async def get_system_config_compat(current_user=Depends(get_current_admin_user)):
"""兼容旧前端的系统配置接口,数据来源为 sys_system_parameters。"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_key, param_value
FROM sys_system_parameters
WHERE is_active = 1
"""
)
rows = cursor.fetchall()
data = {row["param_key"]: row["param_value"] for row in rows}
# 兼容旧字段
if "max_audio_size" in data:
try:
data["MAX_FILE_SIZE"] = int(data["max_audio_size"]) * 1024 * 1024
except Exception:
data["MAX_FILE_SIZE"] = 100 * 1024 * 1024
if "max_image_size" in data:
try:
data["MAX_IMAGE_SIZE"] = int(data["max_image_size"]) * 1024 * 1024
except Exception:
data["MAX_IMAGE_SIZE"] = 10 * 1024 * 1024
else:
data.setdefault("MAX_IMAGE_SIZE", 10 * 1024 * 1024)
return create_api_response(code="200", message="获取系统配置成功", data=data)
except Exception as e:
return create_api_response(code="500", message=f"获取系统配置失败: {str(e)}")
return admin_settings_service.get_system_config_compat()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from app.api.endpoints import (
admin,
admin_dashboard,
admin_settings,
audio,
auth,
client_downloads,
dict_data,
external_apps,
hot_words,
knowledge_base,
meetings,
prompts,
tags,
tasks,
terminals,
users,
voiceprint,
)
from app.core.config import UPLOAD_DIR
from app.core.middleware import TerminalCheckMiddleware
def create_app() -> FastAPI:
app = FastAPI(
title="iMeeting API",
description="iMeeting API说明",
version="1.1.0",
docs_url=None,
redoc_url=None,
)
app.add_middleware(TerminalCheckMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if UPLOAD_DIR.exists():
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
app.include_router(users.router, prefix="/api", tags=["Users"])
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
app.include_router(tags.router, prefix="/api", tags=["Tags"])
app.include_router(admin.router, prefix="/api", tags=["Admin"])
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
app.include_router(admin_settings.router, prefix="/api", tags=["AdminSettings"])
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"])
app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"])
app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
app.include_router(audio.router, prefix="/api", tags=["Audio"])
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
app.include_router(terminals.router, prefix="/api", tags=["Terminals"])
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js",
swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css",
)
@app.get("/")
def read_root():
return {"message": "Welcome to iMeeting API"}
@app.get("/health")
def health_check():
return {
"status": "healthy",
"service": "iMeeting API",
"version": "1.1.0",
}
return app

View File

@ -1,110 +1,22 @@
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
# 无论从哪里运行,都能正确找到 app 模块
current_file = Path(__file__).resolve()
project_root = current_file.parent.parent # backend/
project_root = current_file.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
import uvicorn
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html
from app.core.middleware import TerminalCheckMiddleware
from app.api.endpoints import (
auth,
users,
meetings,
tags,
admin,
admin_dashboard,
admin_settings,
tasks,
prompts,
knowledge_base,
client_downloads,
voiceprint,
audio,
dict_data,
hot_words,
external_apps,
terminals,
)
from app.core.config import UPLOAD_DIR, API_CONFIG
app = FastAPI(
title="iMeeting API",
description="iMeeting API说明",
version="1.1.0",
docs_url=None, # 禁用默认docs使用自定义CDN
redoc_url=None
)
from app.app_factory import create_app
from app.core.config import API_CONFIG
# 添加终端检查中间件 (在CORS之前添加以便位于CORS内部)
app.add_middleware(TerminalCheckMiddleware)
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app = create_app()
# 静态文件服务 - 提供音频文件下载
if UPLOAD_DIR.exists():
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
# 包含API路由
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
app.include_router(users.router, prefix="/api", tags=["Users"])
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
app.include_router(tags.router, prefix="/api", tags=["Tags"])
app.include_router(admin.router, prefix="/api", tags=["Admin"])
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
app.include_router(admin_settings.router, prefix="/api", tags=["AdminSettings"])
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"])
app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"])
app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
app.include_router(audio.router, prefix="/api", tags=["Audio"])
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
app.include_router(terminals.router, prefix="/api", tags=["Terminals"])
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
"""自定义Swagger UI使用国内可访问的CDN"""
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js",
swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css",
)
@app.get("/")
def read_root():
return {"message": "Welcome to iMeeting API"}
@app.get("/health")
def health_check():
"""健康检查端点"""
return {
"status": "healthy",
"service": "iMeeting API",
"version": "1.1.0"
}
if __name__ == "__main__":
# 简单的uvicorn配置避免参数冲突
uvicorn.run(
"app.main:app",
host=API_CONFIG['host'],

View File

@ -0,0 +1,482 @@
from app.core.response import create_api_response
from app.core.database import get_db_connection
from app.services.jwt_service import jwt_service
from app.core.config import AUDIO_DIR, REDIS_CONFIG
from datetime import datetime
from typing import Dict, List
import os
import redis
# Redis 客户端
redis_client = redis.Redis(**REDIS_CONFIG)
# 常量定义
AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.mpeg', '.mp4', '.webm')
BYTES_TO_GB = 1024 ** 3
def _build_status_condition(status: str) -> str:
"""构建任务状态查询条件"""
if status == 'running':
return "AND (t.status = 'pending' OR t.status = 'processing')"
elif status == 'completed':
return "AND t.status = 'completed'"
elif status == 'failed':
return "AND t.status = 'failed'"
return ""
def _get_task_stats_query() -> str:
"""获取任务统计的 SQL 查询"""
return """
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
"""
def _get_online_user_count(redis_client) -> int:
"""从 Redis 获取在线用户数"""
try:
token_keys = redis_client.keys("token:*")
user_ids = set()
for key in token_keys:
if isinstance(key, bytes):
key = key.decode("utf-8", errors="ignore")
parts = key.split(':')
if len(parts) >= 2:
user_ids.add(parts[1])
return len(user_ids)
except Exception as e:
print(f"获取在线用户数失败: {e}")
return 0
def _table_exists(cursor, table_name: str) -> bool:
cursor.execute(
"""
SELECT COUNT(*) AS cnt
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = %s
""",
(table_name,),
)
return (cursor.fetchone() or {}).get("cnt", 0) > 0
def _calculate_audio_storage() -> Dict[str, float]:
"""计算音频文件存储统计"""
audio_files_count = 0
audio_total_size = 0
try:
if os.path.exists(AUDIO_DIR):
for root, _, files in os.walk(AUDIO_DIR):
for file in files:
file_extension = os.path.splitext(file)[1].lower()
if file_extension in AUDIO_FILE_EXTENSIONS:
audio_files_count += 1
file_path = os.path.join(root, file)
try:
audio_total_size += os.path.getsize(file_path)
except OSError:
continue
except Exception as e:
print(f"统计音频文件失败: {e}")
return {
"audio_file_count": audio_files_count,
"audio_files_count": audio_files_count,
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
}
async def get_dashboard_stats(current_user=None):
"""获取管理员 Dashboard 统计数据"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 1. 用户统计
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_users = 0
today_new_users = 0
if _table_exists(cursor, "sys_users"):
cursor.execute("SELECT COUNT(*) as total FROM sys_users")
total_users = (cursor.fetchone() or {}).get("total", 0)
cursor.execute(
"SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s",
(today_start,),
)
today_new_users = (cursor.fetchone() or {}).get("count", 0)
online_users = _get_online_user_count(redis_client)
# 2. 会议统计
total_meetings = 0
today_new_meetings = 0
if _table_exists(cursor, "meetings"):
cursor.execute("SELECT COUNT(*) as total FROM meetings")
total_meetings = (cursor.fetchone() or {}).get("total", 0)
cursor.execute(
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
(today_start,),
)
today_new_meetings = (cursor.fetchone() or {}).get("count", 0)
# 3. 任务统计
task_stats_query = _get_task_stats_query()
# 转录任务
if _table_exists(cursor, "transcript_tasks"):
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
else:
transcription_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 总结任务
if _table_exists(cursor, "llm_tasks"):
cursor.execute(f"{task_stats_query} FROM llm_tasks")
summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
else:
summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 知识库任务
if _table_exists(cursor, "knowledge_base_tasks"):
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
else:
kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 4. 音频存储统计
storage_stats = _calculate_audio_storage()
# 组装返回数据
stats = {
"users": {
"total": total_users,
"today_new": today_new_users,
"online": online_users
},
"meetings": {
"total": total_meetings,
"today_new": today_new_meetings
},
"tasks": {
"transcription": {
"total": transcription_stats['total'] or 0,
"running": transcription_stats['running'] or 0,
"completed": transcription_stats['completed'] or 0,
"failed": transcription_stats['failed'] or 0
},
"summary": {
"total": summary_stats['total'] or 0,
"running": summary_stats['running'] or 0,
"completed": summary_stats['completed'] or 0,
"failed": summary_stats['failed'] or 0
},
"knowledge_base": {
"total": kb_stats['total'] or 0,
"running": kb_stats['running'] or 0,
"completed": kb_stats['completed'] or 0,
"failed": kb_stats['failed'] or 0
}
},
"storage": storage_stats
}
return create_api_response(code="200", message="获取统计数据成功", data=stats)
except Exception as e:
print(f"获取Dashboard统计数据失败: {e}")
return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}")
async def get_online_users(current_user=None):
"""获取在线用户列表"""
try:
token_keys = redis_client.keys("token:*")
# 提取用户ID并去重
user_tokens = {}
for key in token_keys:
if isinstance(key, bytes):
key = key.decode("utf-8", errors="ignore")
parts = key.split(':')
if len(parts) >= 3:
user_id = int(parts[1])
token = parts[2]
if user_id not in user_tokens:
user_tokens[user_id] = []
user_tokens[user_id].append({'token': token, 'key': key})
# 查询用户信息
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
online_users_list = []
for user_id, tokens in user_tokens.items():
cursor.execute(
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
(user_id,)
)
user = cursor.fetchone()
if user:
ttl_seconds = redis_client.ttl(tokens[0]['key'])
online_users_list.append({
**user,
'token_count': len(tokens),
'ttl_seconds': ttl_seconds,
'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0
})
# 按用户ID排序
online_users_list.sort(key=lambda x: x['user_id'])
return create_api_response(
code="200",
message="获取在线用户列表成功",
data={"users": online_users_list, "total": len(online_users_list)}
)
except Exception as e:
print(f"获取在线用户列表失败: {e}")
return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}")
async def kick_user(user_id: int, current_user=None):
"""踢出用户(撤销该用户的所有 token"""
try:
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
if revoked_count > 0:
return create_api_response(
code="200",
message=f"已踢出用户,撤销了 {revoked_count} 个 token",
data={"user_id": user_id, "revoked_count": revoked_count}
)
else:
return create_api_response(
code="404",
message="该用户当前不在线或未找到 token"
)
except Exception as e:
print(f"踢出用户失败: {e}")
return create_api_response(code="500", message=f"踢出用户失败: {str(e)}")
async def monitor_tasks(
task_type: str = 'all',
status: str = 'all',
limit: int = 20,
current_user=None
):
"""监控任务进度"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
tasks = []
status_condition = _build_status_condition(status)
# 转录任务
if task_type in ['all', 'transcription']:
query = f"""
SELECT
t.task_id,
'transcription' as task_type,
t.meeting_id,
m.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.completed_at,
u.username as creator_name
FROM transcript_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN sys_users u ON m.user_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 总结任务
if task_type in ['all', 'summary']:
query = f"""
SELECT
t.task_id,
'summary' as task_type,
t.meeting_id,
m.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.completed_at,
u.username as creator_name
FROM llm_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN sys_users u ON m.user_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 知识库任务
if task_type in ['all', 'knowledge_base']:
query = f"""
SELECT
t.task_id,
'knowledge_base' as task_type,
t.kb_id as meeting_id,
k.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.updated_at,
u.username as creator_name
FROM knowledge_base_tasks t
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
LEFT JOIN sys_users u ON k.creator_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 按创建时间排序并限制返回数量
tasks.sort(key=lambda x: x['created_at'], reverse=True)
tasks = tasks[:limit]
return create_api_response(
code="200",
message="获取任务监控数据成功",
data={"tasks": tasks, "total": len(tasks)}
)
except Exception as e:
print(f"获取任务监控数据失败: {e}")
import traceback
traceback.print_exc()
return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}")
async def get_system_resources(current_user=None):
"""获取服务器资源使用情况"""
try:
import psutil
# CPU 使用率
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# 内存使用情况
memory = psutil.virtual_memory()
memory_total_gb = round(memory.total / BYTES_TO_GB, 2)
memory_used_gb = round(memory.used / BYTES_TO_GB, 2)
# 磁盘使用情况
disk = psutil.disk_usage('/')
disk_total_gb = round(disk.total / BYTES_TO_GB, 2)
disk_used_gb = round(disk.used / BYTES_TO_GB, 2)
resources = {
"cpu": {
"percent": cpu_percent,
"count": cpu_count
},
"memory": {
"total_gb": memory_total_gb,
"used_gb": memory_used_gb,
"percent": memory.percent
},
"disk": {
"total_gb": disk_total_gb,
"used_gb": disk_used_gb,
"percent": disk.percent
},
"timestamp": datetime.now().isoformat()
}
return create_api_response(code="200", message="获取系统资源成功", data=resources)
except ImportError:
return create_api_response(
code="500",
message="psutil 库未安装,请运行: pip install psutil"
)
except Exception as e:
print(f"获取系统资源失败: {e}")
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
async def get_user_stats(current_user=None):
"""获取用户统计列表"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
query = """
SELECT
u.user_id,
u.username,
u.caption,
u.created_at,
(SELECT MAX(created_at) FROM user_logs
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
COUNT(DISTINCT m.meeting_id) as meeting_count,
COALESCE(SUM(af.duration), 0) as total_duration_seconds
FROM sys_users u
INNER JOIN meetings m ON u.user_id = m.user_id
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
GROUP BY u.user_id, u.username, u.caption, u.created_at
HAVING meeting_count > 0
ORDER BY u.user_id ASC
"""
cursor.execute(query)
users = cursor.fetchall()
# 格式化返回数据
users_list = []
for user in users:
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
users_list.append({
'user_id': user['user_id'],
'username': user['username'],
'caption': user['caption'],
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
'meeting_count': user['meeting_count'],
'total_duration_seconds': total_seconds,
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
})
return create_api_response(
code="200",
message="获取用户统计成功",
data={"users": users_list, "total": len(users_list)}
)
except Exception as e:
print(f"获取用户统计失败: {e}")
import traceback
traceback.print_exc()
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")

View File

@ -0,0 +1,550 @@
import time
from typing import Any
from app.core.database import get_db_connection
from app.core.response import create_api_response
from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo
_USER_MENU_CACHE_TTL_SECONDS = 120
_USER_MENU_CACHE_VERSION = "menu-rules-v4"
_user_menu_cache_by_role: dict[int, dict[str, Any]] = {}
def _get_cached_user_menus(role_id: int):
cached = _user_menu_cache_by_role.get(role_id)
if not cached:
return None
if cached.get("version") != _USER_MENU_CACHE_VERSION:
_user_menu_cache_by_role.pop(role_id, None)
return None
if time.time() > cached["expires_at"]:
_user_menu_cache_by_role.pop(role_id, None)
return None
return cached["menus"]
def _set_cached_user_menus(role_id: int, menus):
_user_menu_cache_by_role[role_id] = {
"version": _USER_MENU_CACHE_VERSION,
"menus": menus,
"expires_at": time.time() + _USER_MENU_CACHE_TTL_SECONDS,
}
def _invalidate_user_menu_cache(role_id: int | None = None):
if role_id is None:
_user_menu_cache_by_role.clear()
return
_user_menu_cache_by_role.pop(role_id, None)
def _build_menu_index(menus):
menu_by_id = {}
children_by_parent = {}
for menu in menus:
menu_id = menu["menu_id"]
menu_by_id[menu_id] = menu
parent_id = menu.get("parent_id")
if parent_id is not None:
children_by_parent.setdefault(parent_id, []).append(menu_id)
return menu_by_id, children_by_parent
def _get_descendants(menu_id, children_by_parent):
result = set()
stack = [menu_id]
while stack:
current = stack.pop()
for child_id in children_by_parent.get(current, []):
if child_id in result:
continue
result.add(child_id)
stack.append(child_id)
return result
def _normalize_permission_menu_ids(raw_menu_ids, all_menus):
menu_by_id, children_by_parent = _build_menu_index(all_menus)
selected = {menu_id for menu_id in raw_menu_ids if menu_id in menu_by_id}
expanded = set(selected)
for menu_id in list(expanded):
expanded.update(_get_descendants(menu_id, children_by_parent))
for menu_id in list(expanded):
cursor = menu_by_id[menu_id].get("parent_id")
while cursor is not None and cursor in menu_by_id:
if cursor in expanded:
break
expanded.add(cursor)
cursor = menu_by_id[cursor].get("parent_id")
return sorted(expanded)
def get_all_menus():
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = """
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM sys_menus
ORDER BY
COALESCE(parent_id, menu_id) ASC,
CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC,
sort_order ASC,
menu_id ASC
"""
cursor.execute(query)
menus = cursor.fetchall()
menu_list = [MenuInfo(**menu) for menu in menus]
return create_api_response(
code="200",
message="获取菜单列表成功",
data=MenuListResponse(menus=menu_list, total=len(menu_list)),
)
except Exception as e:
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
def create_menu(request):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_code = %s", (request.menu_code,))
if cursor.fetchone():
return create_api_response(code="400", message="菜单编码已存在")
if request.parent_id is not None:
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,))
if not cursor.fetchone():
return create_api_response(code="400", message="父菜单不存在")
cursor.execute(
"""
INSERT INTO sys_menus (menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
request.menu_code,
request.menu_name,
request.menu_icon,
request.menu_url,
request.menu_type,
request.parent_id,
request.sort_order,
1 if request.is_active else 0,
request.description,
),
)
menu_id = cursor.lastrowid
connection.commit()
_invalidate_user_menu_cache()
cursor.execute(
"""
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM sys_menus
WHERE menu_id = %s
""",
(menu_id,),
)
created = cursor.fetchone()
return create_api_response(code="200", message="创建菜单成功", data=created)
except Exception as e:
return create_api_response(code="500", message=f"创建菜单失败: {str(e)}")
def update_menu(menu_id: int, request):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM sys_menus WHERE menu_id = %s", (menu_id,))
current = cursor.fetchone()
if not current:
return create_api_response(code="404", message="菜单不存在")
updates = {}
for field in [
"menu_code",
"menu_name",
"menu_icon",
"menu_url",
"menu_type",
"sort_order",
"description",
]:
value = getattr(request, field)
if value is not None:
updates[field] = value
if request.is_active is not None:
updates["is_active"] = 1 if request.is_active else 0
fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set()))
if request.parent_id == menu_id:
return create_api_response(code="400", message="父菜单不能为自身")
if request.parent_id is not None:
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,))
if not cursor.fetchone():
return create_api_response(code="400", message="父菜单不存在")
cursor.execute("SELECT menu_id, parent_id FROM sys_menus")
all_menus = cursor.fetchall()
_, children_by_parent = _build_menu_index(all_menus)
descendants = _get_descendants(menu_id, children_by_parent)
if request.parent_id in descendants:
return create_api_response(code="400", message="父菜单不能设置为当前菜单的子孙菜单")
if request.parent_id is not None or (request.parent_id is None and "parent_id" in fields_set):
updates["parent_id"] = request.parent_id
if "menu_code" in updates:
cursor.execute(
"SELECT menu_id FROM sys_menus WHERE menu_code = %s AND menu_id != %s",
(updates["menu_code"], menu_id),
)
if cursor.fetchone():
return create_api_response(code="400", message="菜单编码已存在")
if not updates:
return create_api_response(code="200", message="没有变更内容", data=current)
set_sql = ", ".join([f"{key} = %s" for key in updates.keys()])
values = list(updates.values()) + [menu_id]
cursor.execute(f"UPDATE sys_menus SET {set_sql} WHERE menu_id = %s", tuple(values))
connection.commit()
_invalidate_user_menu_cache()
cursor.execute(
"""
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM sys_menus
WHERE menu_id = %s
""",
(menu_id,),
)
updated = cursor.fetchone()
return create_api_response(code="200", message="更新菜单成功", data=updated)
except Exception as e:
return create_api_response(code="500", message=f"更新菜单失败: {str(e)}")
def delete_menu(menu_id: int):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (menu_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="菜单不存在")
cursor.execute("SELECT COUNT(*) AS cnt FROM sys_menus WHERE parent_id = %s", (menu_id,))
child_count = cursor.fetchone()["cnt"]
if child_count > 0:
return create_api_response(code="400", message="请先删除子菜单")
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE menu_id = %s", (menu_id,))
cursor.execute("DELETE FROM sys_menus WHERE menu_id = %s", (menu_id,))
connection.commit()
_invalidate_user_menu_cache()
return create_api_response(code="200", message="删除菜单成功")
except Exception as e:
return create_api_response(code="500", message=f"删除菜单失败: {str(e)}")
def get_all_roles():
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = """
SELECT r.role_id, r.role_name, r.created_at,
COUNT(rmp.menu_id) as menu_count
FROM sys_roles r
LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id
GROUP BY r.role_id
ORDER BY r.role_id ASC
"""
cursor.execute(query)
roles = cursor.fetchall()
return create_api_response(
code="200",
message="获取角色列表成功",
data={"roles": roles, "total": len(roles)},
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
def create_role(request):
try:
role_name = request.role_name.strip()
if not role_name:
return create_api_response(code="400", message="角色名称不能为空")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s", (role_name,))
if cursor.fetchone():
return create_api_response(code="400", message="角色名称已存在")
cursor.execute("INSERT INTO sys_roles (role_name) VALUES (%s)", (role_name,))
role_id = cursor.lastrowid
connection.commit()
cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
return create_api_response(code="200", message="创建角色成功", data=role)
except Exception as e:
return create_api_response(code="500", message=f"创建角色失败: {str(e)}")
def update_role(role_id: int, request):
try:
role_name = request.role_name.strip()
if not role_name:
return create_api_response(code="400", message="角色名称不能为空")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="角色不存在")
cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s AND role_id != %s", (role_name, role_id))
if cursor.fetchone():
return create_api_response(code="400", message="角色名称已存在")
cursor.execute("UPDATE sys_roles SET role_name = %s WHERE role_id = %s", (role_name, role_id))
connection.commit()
cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
return create_api_response(code="200", message="更新角色成功", data=role)
except Exception as e:
return create_api_response(code="500", message=f"更新角色失败: {str(e)}")
def get_role_users(role_id: int, page: int, size: int):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
if not role:
return create_api_response(code="404", message="角色不存在")
cursor.execute(
"""
SELECT COUNT(*) AS total
FROM sys_users
WHERE role_id = %s
""",
(role_id,),
)
total = cursor.fetchone()["total"]
offset = (page - 1) * size
cursor.execute(
"""
SELECT user_id, username, caption, email, avatar_url, role_id, created_at
FROM sys_users
WHERE role_id = %s
ORDER BY user_id ASC
LIMIT %s OFFSET %s
""",
(role_id, size, offset),
)
users = cursor.fetchall()
return create_api_response(
code="200",
message="获取角色用户成功",
data={
"role_id": role_id,
"role_name": role["role_name"],
"users": users,
"total": total,
"page": page,
"size": size,
},
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色用户失败: {str(e)}")
def get_all_role_permissions():
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT rmp.role_id, rmp.menu_id
FROM sys_role_menu_permissions rmp
JOIN sys_menus m ON m.menu_id = rmp.menu_id
WHERE m.is_active = 1
ORDER BY rmp.role_id ASC, rmp.menu_id ASC
"""
)
rows = cursor.fetchall()
result = {}
for row in rows:
result.setdefault(row["role_id"], []).append(row["menu_id"])
return create_api_response(code="200", message="获取角色权限成功", data={"permissions": result})
except Exception as e:
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
def get_role_permissions(role_id: int):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
if not role:
return create_api_response(code="404", message="角色不存在")
query = """
SELECT rmp.menu_id
FROM sys_role_menu_permissions rmp
JOIN sys_menus m ON m.menu_id = rmp.menu_id
WHERE rmp.role_id = %s
AND m.is_active = 1
"""
cursor.execute(query, (role_id,))
permissions = cursor.fetchall()
menu_ids = [permission["menu_id"] for permission in permissions]
return create_api_response(
code="200",
message="获取角色权限成功",
data=RolePermissionInfo(
role_id=role["role_id"],
role_name=role["role_name"],
menu_ids=menu_ids,
),
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
def update_role_permissions(role_id: int, request):
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="角色不存在")
cursor.execute(
"""
SELECT menu_id, parent_id
FROM sys_menus
WHERE is_active = 1
"""
)
all_menus = cursor.fetchall()
menu_id_set = {menu["menu_id"] for menu in all_menus}
invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set]
if invalid_menu_ids:
return create_api_response(code="400", message="包含无效的菜单ID")
normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus)
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,))
if normalized_menu_ids:
insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids]
cursor.executemany(
"INSERT INTO sys_role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
insert_values,
)
connection.commit()
_invalidate_user_menu_cache(role_id)
return create_api_response(
code="200",
message="更新角色权限成功",
data={"role_id": role_id, "menu_count": len(normalized_menu_ids)},
)
except Exception as e:
return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}")
def get_user_menus(current_user: dict):
try:
role_id = current_user["role_id"]
cached_menus = _get_cached_user_menus(role_id)
if cached_menus is not None:
return create_api_response(
code="200",
message="获取用户菜单成功",
data={"menus": cached_menus},
)
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = """
SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
m.menu_url, m.menu_type, m.parent_id, m.sort_order
FROM sys_menus m
JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id
WHERE rmp.role_id = %s
AND m.is_active = 1
AND (m.is_visible = 1 OR m.is_visible IS NULL OR m.menu_code IN ('dashboard', 'desktop'))
ORDER BY
COALESCE(m.parent_id, m.menu_id) ASC,
CASE WHEN m.parent_id IS NULL THEN 0 ELSE 1 END ASC,
m.sort_order ASC,
m.menu_id ASC
"""
cursor.execute(query, (role_id,))
menus = cursor.fetchall()
current_menu_ids = {menu["menu_id"] for menu in menus}
missing_parent_ids = {
menu["parent_id"]
for menu in menus
if menu.get("parent_id") is not None and menu["parent_id"] not in current_menu_ids
}
if missing_parent_ids:
format_strings = ",".join(["%s"] * len(missing_parent_ids))
cursor.execute(
f"""
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order
FROM sys_menus
WHERE is_active = 1 AND menu_id IN ({format_strings})
""",
tuple(missing_parent_ids),
)
parent_rows = cursor.fetchall()
menus.extend(parent_rows)
menus = sorted(
{menu["menu_id"]: menu for menu in menus}.values(),
key=lambda menu: (
menu["parent_id"] if menu["parent_id"] is not None else menu["menu_id"],
0 if menu["parent_id"] is None else 1,
menu["sort_order"],
menu["menu_id"],
),
)
_set_cached_user_menus(role_id, menus)
return create_api_response(
code="200",
message="获取用户菜单成功",
data={"menus": menus},
)
except Exception as e:
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")

View File

@ -0,0 +1,744 @@
import json
from typing import Any
from app.core.database import get_db_connection
from app.core.response import create_api_response
from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.llm_service import LLMService
from app.services.system_config_service import SystemConfigService
llm_service = LLMService()
transcription_service = AsyncTranscriptionService()
def _parse_json_object(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
value = value.strip()
if not value:
return {}
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _normalize_string_list(value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, list):
values = [str(item).strip() for item in value if str(item).strip()]
return values or None
if isinstance(value, str):
values = [item.strip() for item in value.split(",") if item.strip()]
return values or None
return None
def _normalize_int_list(value: Any) -> list[int] | None:
if value is None:
return None
if isinstance(value, list):
items = value
elif isinstance(value, str):
items = [item.strip() for item in value.split(",") if item.strip()]
else:
return None
normalized = []
for item in items:
try:
normalized.append(int(item))
except (TypeError, ValueError):
continue
return normalized or None
def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]:
cleaned: dict[str, Any] = {}
for key, value in (config or {}).items():
if value is None:
continue
if isinstance(value, str):
stripped = value.strip()
if stripped:
cleaned[key] = stripped
continue
if isinstance(value, list):
normalized_list = []
for item in value:
if item is None:
continue
if isinstance(item, str):
stripped = item.strip()
if stripped:
normalized_list.append(stripped)
else:
normalized_list.append(item)
if normalized_list:
cleaned[key] = normalized_list
continue
cleaned[key] = value
return cleaned
def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict[str, Any]:
extra_config = _parse_json_object(request.extra_config)
if request.audio_scene == "asr":
legacy_config = {
"model": request.asr_model_name,
"speaker_count": request.asr_speaker_count,
"language_hints": request.asr_language_hints,
"disfluency_removal_enabled": request.asr_disfluency_removal_enabled,
"diarization_enabled": request.asr_diarization_enabled,
}
else:
legacy_config = {
"model": request.model_name,
"template_text": request.vp_template_text,
"duration_seconds": request.vp_duration_seconds,
"sample_rate": request.vp_sample_rate,
"channels": request.vp_channels,
"max_size_bytes": request.vp_max_size_bytes,
}
merged = {**legacy_config, **extra_config}
language_hints = _normalize_string_list(merged.get("language_hints"))
if language_hints is not None:
merged["language_hints"] = language_hints
channel_id = _normalize_int_list(merged.get("channel_id"))
if channel_id is not None:
merged["channel_id"] = channel_id
resolved_vocabulary_id = vocabulary_id or merged.get("vocabulary_id") or request.asr_vocabulary_id
if request.audio_scene == "asr" and resolved_vocabulary_id:
merged["vocabulary_id"] = resolved_vocabulary_id
return _clean_extra_config(merged)
def _extract_legacy_audio_columns(audio_scene: str, extra_config: dict[str, Any]) -> dict[str, Any]:
extra_config = _parse_json_object(extra_config)
columns = {
"asr_model_name": None,
"asr_vocabulary_id": None,
"asr_speaker_count": None,
"asr_language_hints": None,
"asr_disfluency_removal_enabled": None,
"asr_diarization_enabled": None,
"vp_template_text": None,
"vp_duration_seconds": None,
"vp_sample_rate": None,
"vp_channels": None,
"vp_max_size_bytes": None,
}
if audio_scene == "asr":
language_hints = extra_config.get("language_hints")
if isinstance(language_hints, list):
language_hints = ",".join(str(item).strip() for item in language_hints if str(item).strip())
columns.update(
{
"asr_model_name": extra_config.get("model"),
"asr_vocabulary_id": extra_config.get("vocabulary_id"),
"asr_speaker_count": extra_config.get("speaker_count"),
"asr_language_hints": language_hints,
"asr_disfluency_removal_enabled": 1 if extra_config.get("disfluency_removal_enabled") is True else 0 if extra_config.get("disfluency_removal_enabled") is False else None,
"asr_diarization_enabled": 1 if extra_config.get("diarization_enabled") is True else 0 if extra_config.get("diarization_enabled") is False else None,
}
)
else:
columns.update(
{
"vp_template_text": extra_config.get("template_text"),
"vp_duration_seconds": extra_config.get("duration_seconds"),
"vp_sample_rate": extra_config.get("sample_rate"),
"vp_channels": extra_config.get("channels"),
"vp_max_size_bytes": extra_config.get("max_size_bytes"),
}
)
return columns
def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]:
extra_config = _parse_json_object(row.get("extra_config"))
if row.get("audio_scene") == "asr":
if extra_config.get("model") is None and row.get("asr_model_name") is not None:
extra_config["model"] = row["asr_model_name"]
if extra_config.get("vocabulary_id") is None and row.get("asr_vocabulary_id") is not None:
extra_config["vocabulary_id"] = row["asr_vocabulary_id"]
if extra_config.get("speaker_count") is None and row.get("asr_speaker_count") is not None:
extra_config["speaker_count"] = row["asr_speaker_count"]
if extra_config.get("language_hints") is None and row.get("asr_language_hints"):
extra_config["language_hints"] = _normalize_string_list(row["asr_language_hints"])
if extra_config.get("disfluency_removal_enabled") is None and row.get("asr_disfluency_removal_enabled") is not None:
extra_config["disfluency_removal_enabled"] = bool(row["asr_disfluency_removal_enabled"])
if extra_config.get("diarization_enabled") is None and row.get("asr_diarization_enabled") is not None:
extra_config["diarization_enabled"] = bool(row["asr_diarization_enabled"])
else:
if extra_config.get("model") is None and row.get("model_name"):
extra_config["model"] = row["model_name"]
if extra_config.get("template_text") is None and row.get("vp_template_text") is not None:
extra_config["template_text"] = row["vp_template_text"]
if extra_config.get("duration_seconds") is None and row.get("vp_duration_seconds") is not None:
extra_config["duration_seconds"] = row["vp_duration_seconds"]
if extra_config.get("sample_rate") is None and row.get("vp_sample_rate") is not None:
extra_config["sample_rate"] = row["vp_sample_rate"]
if extra_config.get("channels") is None and row.get("vp_channels") is not None:
extra_config["channels"] = row["vp_channels"]
if extra_config.get("max_size_bytes") is None and row.get("vp_max_size_bytes") is not None:
extra_config["max_size_bytes"] = row["vp_max_size_bytes"]
row["extra_config"] = extra_config
row["service_model_name"] = extra_config.get("model")
return row
def _resolve_hot_word_vocabulary_id(cursor, request) -> str | None:
vocabulary_id = request.asr_vocabulary_id
if request.hot_word_group_id:
cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,))
group_row = cursor.fetchone()
if group_row and group_row.get("vocabulary_id"):
vocabulary_id = group_row["vocabulary_id"]
return vocabulary_id
def list_parameters(category: str | None = None, keyword: str | None = None):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT param_id, param_key, param_name, param_value, value_type, category,
description, is_active, created_at, updated_at
FROM sys_system_parameters
WHERE 1=1
"""
params = []
if category:
query += " AND category = %s"
params.append(category)
if keyword:
like_pattern = f"%{keyword}%"
query += " AND (param_key LIKE %s OR param_name LIKE %s)"
params.extend([like_pattern, like_pattern])
query += " ORDER BY category ASC, param_key ASC"
cursor.execute(query, tuple(params))
rows = cursor.fetchall()
return create_api_response(
code="200",
message="获取参数列表成功",
data={"items": rows, "total": len(rows)},
)
except Exception as e:
return create_api_response(code="500", message=f"获取参数列表失败: {str(e)}")
def get_parameter(param_key: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_id, param_key, param_name, param_value, value_type, category,
description, is_active, created_at, updated_at
FROM sys_system_parameters
WHERE param_key = %s
LIMIT 1
""",
(param_key,),
)
row = cursor.fetchone()
if not row:
return create_api_response(code="404", message="参数不存在")
return create_api_response(code="200", message="获取参数成功", data=row)
except Exception as e:
return create_api_response(code="500", message=f"获取参数失败: {str(e)}")
def create_parameter(request):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (request.param_key,))
if cursor.fetchone():
return create_api_response(code="400", message="参数键已存在")
cursor.execute(
"""
INSERT INTO sys_system_parameters
(param_key, param_name, param_value, value_type, category, description, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
request.param_key,
request.param_name,
request.param_value,
request.value_type,
request.category,
request.description,
1 if request.is_active else 0,
),
)
conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="创建参数成功")
except Exception as e:
return create_api_response(code="500", message=f"创建参数失败: {str(e)}")
def update_parameter(param_key: str, request):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,))
if not cursor.fetchone():
return create_api_response(code="404", message="参数不存在")
new_key = request.param_key or param_key
if new_key != param_key:
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (new_key,))
if cursor.fetchone():
return create_api_response(code="400", message="新的参数键已存在")
cursor.execute(
"""
UPDATE sys_system_parameters
SET param_key = %s, param_name = %s, param_value = %s, value_type = %s,
category = %s, description = %s, is_active = %s
WHERE param_key = %s
""",
(
new_key,
request.param_name,
request.param_value,
request.value_type,
request.category,
request.description,
1 if request.is_active else 0,
param_key,
),
)
conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="更新参数成功")
except Exception as e:
return create_api_response(code="500", message=f"更新参数失败: {str(e)}")
def delete_parameter(param_key: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,))
if not cursor.fetchone():
return create_api_response(code="404", message="参数不存在")
cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,))
conn.commit()
SystemConfigService.invalidate_cache()
return create_api_response(code="200", message="删除参数成功")
except Exception as e:
return create_api_response(code="500", message=f"删除参数失败: {str(e)}")
def list_llm_model_configs():
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT config_id, model_code, model_name, provider, endpoint_url, api_key,
llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens,
llm_system_prompt, description, is_active, is_default, created_at, updated_at
FROM llm_model_config
ORDER BY model_code ASC
"""
)
rows = cursor.fetchall()
return create_api_response(
code="200",
message="获取LLM模型配置成功",
data={"items": rows, "total": len(rows)},
)
except Exception as e:
return create_api_response(code="500", message=f"获取LLM模型配置失败: {str(e)}")
def create_llm_model_config(request):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,))
if cursor.fetchone():
return create_api_response(code="400", message="模型编码已存在")
cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config")
total_row = cursor.fetchone() or {"total": 0}
is_default = bool(request.is_default) or total_row["total"] == 0
if is_default:
cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1")
cursor.execute(
"""
INSERT INTO llm_model_config
(model_code, model_name, provider, endpoint_url, api_key, llm_model_name,
llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt,
description, is_active, is_default)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
request.model_code,
request.model_name,
request.provider,
request.endpoint_url,
request.api_key,
request.llm_model_name,
request.llm_timeout,
request.llm_temperature,
request.llm_top_p,
request.llm_max_tokens,
request.llm_system_prompt,
request.description,
1 if request.is_active else 0,
1 if is_default else 0,
),
)
conn.commit()
return create_api_response(code="200", message="创建LLM模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"创建LLM模型配置失败: {str(e)}")
def update_llm_model_config(model_code: str, request):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,))
existed = cursor.fetchone()
if not existed:
return create_api_response(code="404", message="模型配置不存在")
new_model_code = request.model_code or model_code
if new_model_code != model_code:
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (new_model_code,))
duplicate_row = cursor.fetchone()
if duplicate_row and duplicate_row["config_id"] != existed["config_id"]:
return create_api_response(code="400", message="新的模型编码已存在")
if request.is_default:
cursor.execute(
"UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1",
(model_code,),
)
cursor.execute(
"""
UPDATE llm_model_config
SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s,
llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s,
llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s
WHERE model_code = %s
""",
(
new_model_code,
request.model_name,
request.provider,
request.endpoint_url,
request.api_key,
request.llm_model_name,
request.llm_timeout,
request.llm_temperature,
request.llm_top_p,
request.llm_max_tokens,
request.llm_system_prompt,
request.description,
1 if request.is_active else 0,
1 if request.is_default else 0,
model_code,
),
)
conn.commit()
return create_api_response(code="200", message="更新LLM模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"更新LLM模型配置失败: {str(e)}")
def list_audio_model_configs(scene: str = "all"):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
sql = """
SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key,
a.asr_model_name, a.asr_vocabulary_id, a.hot_word_group_id, a.asr_speaker_count, a.asr_language_hints,
a.asr_disfluency_removal_enabled, a.asr_diarization_enabled,
a.vp_template_text, a.vp_duration_seconds, a.vp_sample_rate, a.vp_channels, a.vp_max_size_bytes,
a.extra_config, a.description, a.is_active, a.is_default, a.created_at, a.updated_at,
g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id
FROM audio_model_config a
LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id
"""
params = []
if scene in ("asr", "voiceprint"):
sql += " WHERE a.audio_scene = %s"
params.append(scene)
sql += " ORDER BY a.audio_scene ASC, a.model_code ASC"
cursor.execute(sql, tuple(params))
rows = [_normalize_audio_row(row) for row in cursor.fetchall()]
return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)})
except Exception as e:
return create_api_response(code="500", message=f"获取音频模型配置失败: {str(e)}")
def create_audio_model_config(request):
try:
if request.audio_scene not in ("asr", "voiceprint"):
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint")
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (request.model_code,))
if cursor.fetchone():
return create_api_response(code="400", message="模型编码已存在")
cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,))
total_row = cursor.fetchone() or {"total": 0}
is_default = bool(request.is_default) or total_row["total"] == 0
if is_default:
cursor.execute(
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1",
(request.audio_scene,),
)
asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config)
cursor.execute(
"""
INSERT INTO audio_model_config
(model_code, model_name, audio_scene, provider, endpoint_url, api_key,
asr_model_name, asr_vocabulary_id, hot_word_group_id, asr_speaker_count, asr_language_hints,
asr_disfluency_removal_enabled, asr_diarization_enabled,
vp_template_text, vp_duration_seconds, vp_sample_rate, vp_channels, vp_max_size_bytes,
extra_config, description, is_active, is_default)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
request.model_code,
request.model_name,
request.audio_scene,
request.provider,
request.endpoint_url,
request.api_key,
legacy_columns["asr_model_name"],
legacy_columns["asr_vocabulary_id"],
request.hot_word_group_id,
legacy_columns["asr_speaker_count"],
legacy_columns["asr_language_hints"],
legacy_columns["asr_disfluency_removal_enabled"],
legacy_columns["asr_diarization_enabled"],
legacy_columns["vp_template_text"],
legacy_columns["vp_duration_seconds"],
legacy_columns["vp_sample_rate"],
legacy_columns["vp_channels"],
legacy_columns["vp_max_size_bytes"],
json.dumps(extra_config, ensure_ascii=False),
request.description,
1 if request.is_active else 0,
1 if is_default else 0,
),
)
conn.commit()
return create_api_response(code="200", message="创建音频模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"创建音频模型配置失败: {str(e)}")
def update_audio_model_config(model_code: str, request):
try:
if request.audio_scene not in ("asr", "voiceprint"):
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint")
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,))
existed = cursor.fetchone()
if not existed:
return create_api_response(code="404", message="模型配置不存在")
new_model_code = request.model_code or model_code
if new_model_code != model_code:
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (new_model_code,))
duplicate_row = cursor.fetchone()
if duplicate_row and duplicate_row["config_id"] != existed["config_id"]:
return create_api_response(code="400", message="新的模型编码已存在")
if request.is_default:
cursor.execute(
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1",
(request.audio_scene, model_code),
)
asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
legacy_columns = _extract_legacy_audio_columns(request.audio_scene, extra_config)
cursor.execute(
"""
UPDATE audio_model_config
SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s,
asr_model_name = %s, asr_vocabulary_id = %s, hot_word_group_id = %s, asr_speaker_count = %s, asr_language_hints = %s,
asr_disfluency_removal_enabled = %s, asr_diarization_enabled = %s,
vp_template_text = %s, vp_duration_seconds = %s, vp_sample_rate = %s, vp_channels = %s, vp_max_size_bytes = %s,
extra_config = %s, description = %s, is_active = %s, is_default = %s
WHERE model_code = %s
""",
(
new_model_code,
request.model_name,
request.audio_scene,
request.provider,
request.endpoint_url,
request.api_key,
legacy_columns["asr_model_name"],
legacy_columns["asr_vocabulary_id"],
request.hot_word_group_id,
legacy_columns["asr_speaker_count"],
legacy_columns["asr_language_hints"],
legacy_columns["asr_disfluency_removal_enabled"],
legacy_columns["asr_diarization_enabled"],
legacy_columns["vp_template_text"],
legacy_columns["vp_duration_seconds"],
legacy_columns["vp_sample_rate"],
legacy_columns["vp_channels"],
legacy_columns["vp_max_size_bytes"],
json.dumps(extra_config, ensure_ascii=False),
request.description,
1 if request.is_active else 0,
1 if request.is_default else 0,
model_code,
),
)
conn.commit()
return create_api_response(code="200", message="更新音频模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"更新音频模型配置失败: {str(e)}")
def delete_llm_model_config(model_code: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,))
if not cursor.fetchone():
return create_api_response(code="404", message="模型配置不存在")
cursor.execute("DELETE FROM llm_model_config WHERE model_code = %s", (model_code,))
conn.commit()
return create_api_response(code="200", message="删除LLM模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"删除LLM模型配置失败: {str(e)}")
def delete_audio_model_config(model_code: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,))
if not cursor.fetchone():
return create_api_response(code="404", message="模型配置不存在")
cursor.execute("DELETE FROM audio_model_config WHERE model_code = %s", (model_code,))
conn.commit()
return create_api_response(code="200", message="删除音频模型配置成功")
except Exception as e:
return create_api_response(code="500", message=f"删除音频模型配置失败: {str(e)}")
def test_llm_model_config(request):
try:
payload = request.model_dump() if hasattr(request, "model_dump") else request.dict()
result = llm_service.test_model(payload, prompt=request.test_prompt)
return create_api_response(code="200", message="LLM模型测试成功", data=result)
except Exception as e:
return create_api_response(code="500", message=f"LLM模型测试失败: {str(e)}")
def test_audio_model_config(request):
try:
if request.audio_scene != "asr":
return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试")
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id)
runtime_config = {
"provider": request.provider,
"endpoint_url": request.endpoint_url,
"api_key": request.api_key,
"audio_scene": request.audio_scene,
"hot_word_group_id": request.hot_word_group_id,
**extra_config,
}
result = transcription_service.test_asr_model(runtime_config, test_file_url=request.test_file_url)
return create_api_response(code="200", message="音频模型测试任务已提交", data=result)
except Exception as e:
return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}")
def get_public_system_config():
try:
return create_api_response(
code="200",
message="获取公开配置成功",
data=SystemConfigService.get_public_configs(),
)
except Exception as e:
return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}")
def get_system_config_compat():
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_key, param_value
FROM sys_system_parameters
WHERE is_active = 1
"""
)
rows = cursor.fetchall()
data = {row["param_key"]: row["param_value"] for row in rows}
if "max_audio_size" in data:
try:
data["MAX_FILE_SIZE"] = int(data["max_audio_size"]) * 1024 * 1024
except Exception:
data["MAX_FILE_SIZE"] = 100 * 1024 * 1024
if "max_image_size" in data:
try:
data["MAX_IMAGE_SIZE"] = int(data["max_image_size"]) * 1024 * 1024
except Exception:
data["MAX_IMAGE_SIZE"] = 10 * 1024 * 1024
else:
data.setdefault("MAX_IMAGE_SIZE", 10 * 1024 * 1024)
return create_api_response(code="200", message="获取系统配置成功", data=data)
except Exception as e:
return create_api_response(code="500", message=f"获取系统配置失败: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,11 @@ import AccountSettings from './pages/AccountSettings';
import MeetingCenterPage from './pages/MeetingCenterPage';
import MainLayout from './components/MainLayout';
import menuService from './services/menuService';
import {
clearAuthSession,
getStoredUser,
setStoredAuthPayload,
} from './services/authSessionService';
import configService from './utils/configService';
import './App.css';
import './styles/console-theme.css';
@ -84,26 +89,8 @@ function App() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Load user from localStorage on app start
useEffect(() => {
const savedAuth = localStorage.getItem('iMeetingUser');
if (savedAuth && savedAuth !== "undefined" && savedAuth !== "null") {
try {
const authData = JSON.parse(savedAuth);
// user user
// 使
const userData = authData.user || authData;
if (userData && typeof userData === 'object' && (userData.user_id || userData.id)) {
setUser(userData);
} else {
localStorage.removeItem('iMeetingUser');
}
} catch (error) {
console.error('Error parsing saved user:', error);
localStorage.removeItem('iMeetingUser');
}
}
setUser(getStoredUser());
setIsLoading(false);
}, []);
@ -124,11 +111,7 @@ function App() {
const handleLogin = (authData) => {
if (authData) {
menuService.clearCache();
// UI
const userData = authData.user || authData;
setUser(userData);
// auth token使
localStorage.setItem('iMeetingUser', JSON.stringify(authData));
setUser(setStoredAuthPayload(authData));
}
};
@ -139,9 +122,8 @@ function App() {
console.error('Logout API error:', error);
} finally {
setUser(null);
localStorage.removeItem('iMeetingUser');
menuService.clearCache();
window.location.href = '/';
clearAuthSession();
}
};

View File

@ -9,7 +9,7 @@ import {
WindowsOutlined,
RightOutlined
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { Title, Text } = Typography;
@ -24,7 +24,7 @@ const ClientDownloads = () => {
const fetchClients = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST));
setClients(response.data.clients || []);
} catch (error) {
console.error('获取下载列表失败:', error);

View File

@ -1,160 +1,28 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Drawer, Form, Input, Button, DatePicker, Select, Space, App, Upload, Card, Progress, Typography } from 'antd';
import React from 'react';
import { Drawer, Form, Input, Button, DatePicker, Select, Space, Upload, Card, Progress, Typography } from 'antd';
import { SaveOutlined, UploadOutlined, DeleteOutlined, AudioOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import configService from '../utils/configService';
import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
import { AUDIO_UPLOAD_ACCEPT } from '../services/meetingAudioService';
import useMeetingFormDrawer from '../hooks/useMeetingFormDrawer';
const { Text } = Typography;
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
const { message } = App.useApp();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]);
const [selectedAudioFile, setSelectedAudioFile] = useState(null);
const [audioUploading, setAudioUploading] = useState(false);
const [audioUploadProgress, setAudioUploadProgress] = useState(0);
const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const isEdit = Boolean(meetingId);
const fetchOptions = useCallback(async () => {
try {
const [uRes, pRes] = await Promise.all([
apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)),
apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))),
]);
setUsers(uRes.data.users || []);
setPrompts(pRes.data.prompts || []);
} catch {
message.error('加载会议表单选项失败');
}
}, [message]);
const loadAudioUploadConfig = useCallback(async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
}
}, []);
const fetchMeeting = useCallback(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
const meeting = res.data;
form.setFieldsValue({
title: meeting.title,
meeting_time: dayjs(meeting.meeting_time),
attendee_ids: meeting.attendee_ids || meeting.attendees?.map((a) => a.user_id).filter(Boolean) || [],
prompt_id: meeting.prompt_id,
tags: meeting.tags?.map((t) => t.name) || [],
});
} catch {
message.error('加载会议数据失败');
}
}, [form, meetingId, message]);
useEffect(() => {
if (!open) return;
fetchOptions();
loadAudioUploadConfig();
if (isEdit) {
fetchMeeting();
} else {
form.resetFields();
form.setFieldsValue({ meeting_time: dayjs() });
setSelectedAudioFile(null);
setAudioUploading(false);
setAudioUploadProgress(0);
setAudioUploadMessage('');
}
}, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]);
const handleAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return Upload.LIST_IGNORE;
}
setSelectedAudioFile(file);
return false;
};
const clearSelectedAudio = () => {
setSelectedAudioFile(null);
setAudioUploadProgress(0);
setAudioUploadMessage('');
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const payload = {
...values,
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
attendee_ids: values.attendee_ids || [],
tags: values.tags?.join(',') || '',
};
if (isEdit) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload);
message.success('会议更新成功');
} else {
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') {
const newMeetingId = res.data.meeting_id;
if (selectedAudioFile) {
setAudioUploading(true);
setAudioUploadProgress(0);
setAudioUploadMessage('正在上传音频文件...');
try {
await uploadMeetingAudio({
meetingId: newMeetingId,
file: selectedAudioFile,
promptId: values.prompt_id,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setAudioUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setAudioUploadMessage('正在上传音频文件...');
},
});
setAudioUploadProgress(100);
setAudioUploadMessage('上传完成,正在启动转录任务...');
message.success('会议创建成功,音频已开始上传处理');
} catch (uploadError) {
message.warning(uploadError?.response?.data?.message || uploadError?.response?.data?.detail || '会议已创建,但音频上传失败,请在详情页重试');
} finally {
setAudioUploading(false);
}
} else {
message.success('会议创建成功');
}
onSuccess?.(res.data.meeting_id);
onClose();
return;
}
}
onSuccess?.();
onClose();
} catch (error) {
if (!error?.errorFields) {
message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败');
}
} finally {
setLoading(false);
}
};
const {
form,
isEdit,
loading,
users,
prompts,
selectedAudioFile,
audioUploading,
audioUploadProgress,
audioUploadMessage,
maxAudioSize,
handleAudioBeforeUpload,
clearSelectedAudio,
handleSubmit,
} = useMeetingFormDrawer({ open, onClose, onSuccess, meetingId });
return (
<Drawer
@ -172,7 +40,7 @@ const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null }) => {
</Space>
}
>
<Form form={form} layout="vertical" initialValues={{ meeting_time: dayjs() }}>
<Form form={form} layout="vertical">
<Form.Item label="会议主题" name="title" rules={[{ required: true, message: '请输入会议主题' }]}>
<Input placeholder="请输入会议主题..." />
</Form.Item>

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Tag, Space, Typography, Skeleton, Empty } from 'antd';
import { TagsOutlined } from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { CheckableTag } = Tag;
@ -19,7 +19,7 @@ const TagCloud = ({
const fetchAllTags = useCallback(async () => {
try {
setLoading(true);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
const tags = response.data || [];
setAllTags(limitTags ? tags.slice(0, 15) : tags);
} catch (err) {

View File

@ -0,0 +1,231 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { App } from 'antd';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const AUTO_REFRESH_INTERVAL = 30;
export default function useAdminDashboardPage() {
const { message, modal } = App.useApp();
const inFlightRef = useRef(false);
const mountedRef = useRef(true);
const [stats, setStats] = useState(null);
const [onlineUsers, setOnlineUsers] = useState([]);
const [usersList, setUsersList] = useState([]);
const [tasks, setTasks] = useState([]);
const [resources, setResources] = useState(null);
const [loading, setLoading] = useState(true);
const [taskLoading, setTaskLoading] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const [taskType, setTaskType] = useState('all');
const [taskStatus, setTaskStatus] = useState('all');
const [autoRefresh, setAutoRefresh] = useState(true);
const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL);
const [showMeetingModal, setShowMeetingModal] = useState(false);
const [meetingDetails, setMeetingDetails] = useState(null);
const [meetingLoading, setMeetingLoading] = useState(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const fetchStats = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS));
if (response.code === '200') {
setStats(response.data);
}
}, []);
const fetchOnlineUsers = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS));
if (response.code === '200') {
setOnlineUsers(response.data.users || []);
}
}, []);
const fetchUsersList = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS));
if (response.code === '200') {
setUsersList(response.data.users || []);
}
}, []);
const fetchTasks = useCallback(async () => {
try {
setTaskLoading(true);
const params = new URLSearchParams();
if (taskType !== 'all') params.append('task_type', taskType);
if (taskStatus !== 'all') params.append('status', taskStatus);
params.append('limit', '20');
const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`));
if (response.code === '200') {
setTasks(response.data.tasks || []);
}
} finally {
setTaskLoading(false);
}
}, [taskStatus, taskType]);
const fetchResources = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES));
if (response.code === '200') {
setResources(response.data);
}
}, []);
const fetchAllData = useCallback(async ({ silent = false } = {}) => {
if (inFlightRef.current) {
return;
}
inFlightRef.current = true;
try {
if (!silent && mountedRef.current) {
setLoading(true);
}
await Promise.all([fetchStats(), fetchOnlineUsers(), fetchUsersList(), fetchTasks(), fetchResources()]);
if (mountedRef.current) {
setLastUpdatedAt(new Date());
setCountdown(AUTO_REFRESH_INTERVAL);
}
} catch (error) {
console.error('获取数据失败:', error);
if (mountedRef.current && !silent) {
message.error('加载数据失败,请稍后重试');
}
} finally {
inFlightRef.current = false;
if (mountedRef.current && !silent) {
setLoading(false);
}
}
}, [fetchOnlineUsers, fetchResources, fetchStats, fetchTasks, fetchUsersList, message]);
useEffect(() => {
fetchAllData();
}, [fetchAllData]);
useEffect(() => {
if (!autoRefresh || showMeetingModal) {
return undefined;
}
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
fetchAllData({ silent: true });
return AUTO_REFRESH_INTERVAL;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchAllData, showMeetingModal]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleKickUser = (user) => {
modal.confirm({
title: '踢出用户',
content: `确定要踢出用户"${user.caption}"吗?该用户将被强制下线。`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(user.user_id)));
if (response.code === '200') {
message.success('用户已被踢出');
fetchOnlineUsers();
}
} catch {
message.error('踢出用户失败');
}
},
});
};
const handleViewMeeting = async (meetingId) => {
setMeetingLoading(true);
setShowMeetingModal(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
if (response.code === '200') {
setMeetingDetails(response.data);
}
} catch {
message.error('获取会议详情失败');
} finally {
setMeetingLoading(false);
}
};
const handleDownloadTranscript = async (meetingId) => {
try {
const response = await apiClient.get(buildApiUrl(`/api/meetings/${meetingId}/transcript`));
if (response.code === '200') {
const dataStr = JSON.stringify(response.data, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `transcript_${meetingId}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
} catch {
message.error('下载失败');
}
};
const closeMeetingModal = () => {
setShowMeetingModal(false);
setMeetingDetails(null);
};
const taskCompletionRate = useMemo(() => {
const all = tasks.length || 1;
const completed = tasks.filter((item) => item.status === 'completed').length;
return Math.round((completed / all) * 100);
}, [tasks]);
return {
stats,
onlineUsers,
usersList,
tasks,
resources,
loading,
taskLoading,
lastUpdatedAt,
taskType,
setTaskType,
taskStatus,
setTaskStatus,
autoRefresh,
setAutoRefresh,
countdown,
showMeetingModal,
meetingDetails,
meetingLoading,
fetchAllData,
fetchOnlineUsers,
handleKickUser,
handleViewMeeting,
handleDownloadTranscript,
closeMeetingModal,
taskCompletionRate,
};
}

View File

@ -0,0 +1,979 @@
import { useEffect, useEffectEvent, useRef, useState } from 'react';
import { App } from 'antd';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import configService from '../utils/configService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import { uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
const TRANSCRIPT_INITIAL_RENDER_COUNT = 80;
const TRANSCRIPT_RENDER_STEP = 120;
const findTranscriptIndexByTime = (segments, timeMs) => {
let left = 0;
let right = segments.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const segment = segments[mid];
if (timeMs < segment.start_time_ms) {
right = mid - 1;
} else if (timeMs > segment.end_time_ms) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
};
const generateRandomPassword = (length = 4) => {
const charset = '0123456789';
return Array.from({ length }, () => charset[Math.floor(Math.random() * charset.length)]).join('');
};
const buildSpeakerState = (segments) => {
const speakerMap = new Map();
segments.forEach((segment) => {
if (segment?.speaker_id == null || speakerMap.has(segment.speaker_id)) {
return;
}
speakerMap.set(segment.speaker_id, {
speaker_id: segment.speaker_id,
speaker_tag: segment.speaker_tag || `发言人 ${segment.speaker_id}`,
});
});
const speakerList = Array.from(speakerMap.values()).sort((a, b) => a.speaker_id - b.speaker_id);
const editingSpeakers = {};
speakerList.forEach((speaker) => {
editingSpeakers[speaker.speaker_id] = speaker.speaker_tag;
});
return { speakerList, editingSpeakers };
};
export default function useMeetingDetailsPage({ user }) {
const { meeting_id: meetingId } = useParams();
const navigate = useNavigate();
const { message, modal } = App.useApp();
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [transcript, setTranscript] = useState([]);
const [transcriptLoading, setTranscriptLoading] = useState(false);
const [audioUrl, setAudioUrl] = useState(null);
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
const [showSummaryDrawer, setShowSummaryDrawer] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryResourcesLoading, setSummaryResourcesLoading] = useState(false);
const [userPrompt, setUserPrompt] = useState('');
const [promptList, setPromptList] = useState([]);
const [selectedPromptId, setSelectedPromptId] = useState(null);
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
const [llmModels, setLlmModels] = useState([]);
const [selectedModelCode, setSelectedModelCode] = useState(null);
const [showSpeakerDrawer, setShowSpeakerDrawer] = useState(false);
const [viewingPrompt, setViewingPrompt] = useState(null);
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [showQRModal, setShowQRModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatusMessage, setUploadStatusMessage] = useState('');
const [playbackRate, setPlaybackRate] = useState(1);
const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false);
const [accessPasswordDraft, setAccessPasswordDraft] = useState('');
const [savingAccessPassword, setSavingAccessPassword] = useState(false);
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const [showTranscriptEditDrawer, setShowTranscriptEditDrawer] = useState(false);
const [editingSegments, setEditingSegments] = useState({});
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [editingSummaryContent, setEditingSummaryContent] = useState('');
const [inlineSpeakerEdit, setInlineSpeakerEdit] = useState(null);
const [inlineSpeakerEditSegmentId, setInlineSpeakerEditSegmentId] = useState(null);
const [inlineSpeakerValue, setInlineSpeakerValue] = useState('');
const [inlineSegmentEditId, setInlineSegmentEditId] = useState(null);
const [inlineSegmentValue, setInlineSegmentValue] = useState('');
const [savingInlineEdit, setSavingInlineEdit] = useState(false);
const [transcriptVisibleCount, setTranscriptVisibleCount] = useState(TRANSCRIPT_INITIAL_RENDER_COUNT);
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
const statusCheckIntervalRef = useRef(null);
const summaryPollIntervalRef = useRef(null);
const summaryBootstrapTimeoutRef = useRef(null);
const activeSummaryTaskIdRef = useRef(null);
const isMeetingOwner = user?.user_id === meeting?.creator_id;
const creatorName = meeting?.creator_username || '未知创建人';
const hasUploadedAudio = Boolean(audioUrl);
const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status);
const isSummaryRunning = summaryLoading;
const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100));
const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100));
const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100));
const summaryDisabledReason = isUploading
? '音频上传中,暂不允许重新总结'
: !hasUploadedAudio
? '请先上传音频后再总结'
: isTranscriptionRunning
? '转录进行中,完成后会自动总结'
: '';
const isSummaryActionDisabled = isUploading || !hasUploadedAudio || isTranscriptionRunning || summaryLoading;
const clearSummaryBootstrapPolling = () => {
if (summaryBootstrapTimeoutRef.current) {
clearTimeout(summaryBootstrapTimeoutRef.current);
summaryBootstrapTimeoutRef.current = null;
}
};
const loadAudioUploadConfig = async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
}
};
const fetchPromptList = async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK')));
setPromptList(res.data.prompts || []);
const defaultPrompt = res.data.prompts?.find((prompt) => prompt.is_default) || res.data.prompts?.[0];
if (defaultPrompt) {
setSelectedPromptId(defaultPrompt.id);
}
} catch (error) {
console.debug('加载提示词列表失败:', error);
}
};
const fetchLlmModels = async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LLM_MODELS));
const models = Array.isArray(res.data) ? res.data : (res.data?.models || []);
setLlmModels(models);
const defaultModel = models.find((model) => model.is_default);
if (defaultModel) {
setSelectedModelCode(defaultModel.model_code);
}
} catch (error) {
console.debug('加载模型列表失败:', error);
}
};
const fetchSummaryResources = async () => {
setSummaryResourcesLoading(true);
try {
await Promise.allSettled([
promptList.length > 0 ? Promise.resolve() : fetchPromptList(),
llmModels.length > 0 ? Promise.resolve() : fetchLlmModels(),
]);
} finally {
setSummaryResourcesLoading(false);
}
};
const fetchTranscript = async () => {
setTranscriptLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meetingId)));
const segments = Array.isArray(res.data) ? res.data : [];
const speakerState = buildSpeakerState(segments);
setTranscript(segments);
setSpeakerList(speakerState.speakerList);
setEditingSpeakers(speakerState.editingSpeakers);
} catch {
setTranscript([]);
setSpeakerList([]);
setEditingSpeakers({});
} finally {
setTranscriptLoading(false);
}
};
const fetchMeetingDetails = async (options = {}) => {
const { showPageLoading = true } = options;
try {
if (showPageLoading) {
setLoading(true);
}
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
const meetingData = response.data;
setMeeting(meetingData);
if (meetingData.prompt_id) {
setSelectedPromptId(meetingData.prompt_id);
}
setAccessPasswordEnabled(Boolean(meetingData.access_password));
setAccessPasswordDraft(meetingData.access_password || '');
if (meetingData.transcription_status) {
const nextStatus = meetingData.transcription_status;
setTranscriptionStatus(nextStatus);
setTranscriptionProgress(nextStatus.progress || 0);
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
}
if (meetingData.llm_status) {
const llmStatus = meetingData.llm_status;
clearSummaryBootstrapPolling();
setSummaryTaskProgress(llmStatus.progress || 0);
setSummaryTaskMessage(
llmStatus.message
|| (llmStatus.status === 'processing'
? 'AI 正在分析会议内容...'
: llmStatus.status === 'pending'
? 'AI 总结任务排队中...'
: '')
);
if (!['pending', 'processing'].includes(llmStatus.status)) {
setSummaryLoading(false);
}
} else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) {
if (!activeSummaryTaskIdRef.current) {
setSummaryLoading(true);
setSummaryTaskProgress(0);
setSummaryTaskMessage('转录完成,正在启动 AI 分析...');
}
} else {
clearSummaryBootstrapPolling();
if (!activeSummaryTaskIdRef.current) {
setSummaryLoading(false);
setSummaryTaskProgress(0);
setSummaryTaskMessage('');
}
}
const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5);
setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meetingId)}/stream`) : null);
return meetingData;
} catch {
message.error('加载会议详情失败');
return null;
} finally {
if (showPageLoading) {
setLoading(false);
}
}
};
const startStatusPolling = (taskId) => {
if (statusCheckIntervalRef.current) {
clearInterval(statusCheckIntervalRef.current);
}
const interval = setInterval(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId)));
const status = res.data;
setTranscriptionStatus(status);
setTranscriptionProgress(status.progress || 0);
setMeeting((prev) => (prev ? { ...prev, transcription_status: status } : prev));
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval);
statusCheckIntervalRef.current = null;
if (status.status === 'completed') {
await fetchTranscript();
await fetchMeetingDetails({ showPageLoading: false });
}
}
} catch {
clearInterval(interval);
statusCheckIntervalRef.current = null;
}
}, 3000);
statusCheckIntervalRef.current = interval;
};
const startSummaryPolling = (taskId, options = {}) => {
const { closeDrawerOnComplete = false } = options;
if (!taskId) {
return;
}
if (summaryPollIntervalRef.current && activeSummaryTaskIdRef.current === taskId) {
return;
}
if (summaryPollIntervalRef.current) {
clearInterval(summaryPollIntervalRef.current);
}
activeSummaryTaskIdRef.current = taskId;
setSummaryLoading(true);
const poll = async () => {
try {
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
const status = statusRes.data;
setSummaryTaskProgress(status.progress || 0);
setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...');
setMeeting((prev) => (prev ? { ...prev, llm_status: status } : prev));
if (status.status === 'completed') {
clearInterval(interval);
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
if (closeDrawerOnComplete) {
setShowSummaryDrawer(false);
}
await fetchMeetingDetails({ showPageLoading: false });
} else if (status.status === 'failed') {
clearInterval(interval);
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
message.error(status.error_message || '生成总结失败');
}
} catch (error) {
clearInterval(interval);
summaryPollIntervalRef.current = null;
activeSummaryTaskIdRef.current = null;
setSummaryLoading(false);
message.error(error?.response?.data?.message || '获取总结状态失败');
}
};
const interval = setInterval(poll, 3000);
summaryPollIntervalRef.current = interval;
poll();
};
const scheduleSummaryBootstrapPolling = (attempt = 0) => {
if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) {
return;
}
clearSummaryBootstrapPolling();
if (attempt >= 10) {
setSummaryLoading(false);
setSummaryTaskMessage('');
return;
}
summaryBootstrapTimeoutRef.current = setTimeout(async () => {
summaryBootstrapTimeoutRef.current = null;
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
const meetingData = response.data;
if (meetingData.llm_status?.task_id) {
startSummaryPolling(meetingData.llm_status.task_id);
return;
}
if (meetingData.llm_status || meetingData.summary) {
await fetchMeetingDetails({ showPageLoading: false });
return;
}
} catch {
if (attempt >= 9) {
setSummaryLoading(false);
setSummaryTaskMessage('');
return;
}
}
scheduleSummaryBootstrapPolling(attempt + 1);
}, attempt === 0 ? 1200 : 2000);
};
const bootstrapMeetingPage = useEffectEvent(async () => {
const meetingData = await fetchMeetingDetails();
await fetchTranscript();
await loadAudioUploadConfig();
if (meetingData?.transcription_status?.task_id && ['pending', 'processing'].includes(meetingData.transcription_status.status)) {
startStatusPolling(meetingData.transcription_status.task_id);
}
if (meetingData?.llm_status?.task_id && ['pending', 'processing'].includes(meetingData.llm_status.status)) {
startSummaryPolling(meetingData.llm_status.task_id);
} else if (meetingData?.transcription_status?.status === 'completed' && !meetingData.summary) {
scheduleSummaryBootstrapPolling();
}
});
useEffect(() => {
bootstrapMeetingPage();
return () => {
if (statusCheckIntervalRef.current) {
clearInterval(statusCheckIntervalRef.current);
}
if (summaryPollIntervalRef.current) {
clearInterval(summaryPollIntervalRef.current);
}
if (summaryBootstrapTimeoutRef.current) {
clearTimeout(summaryBootstrapTimeoutRef.current);
}
};
}, [bootstrapMeetingPage, meetingId]);
const openSummaryResources = useEffectEvent(() => {
fetchSummaryResources();
});
useEffect(() => {
if (!showSummaryDrawer) {
return;
}
if (promptList.length > 0 && llmModels.length > 0) {
return;
}
openSummaryResources();
}, [llmModels.length, openSummaryResources, promptList.length, showSummaryDrawer]);
useEffect(() => {
transcriptRefs.current = [];
setTranscriptVisibleCount(Math.min(transcript.length, TRANSCRIPT_INITIAL_RENDER_COUNT));
}, [transcript]);
useEffect(() => {
if (currentHighlightIndex < 0 || currentHighlightIndex < transcriptVisibleCount || transcriptVisibleCount >= transcript.length) {
return;
}
setTranscriptVisibleCount((prev) => Math.min(
transcript.length,
Math.max(prev + TRANSCRIPT_RENDER_STEP, currentHighlightIndex + 20)
));
}, [currentHighlightIndex, transcript.length, transcriptVisibleCount]);
useEffect(() => {
if (currentHighlightIndex < 0) {
return;
}
transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [currentHighlightIndex, transcriptVisibleCount]);
const validateAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return validationMessage;
}
return null;
};
const handleUploadAudio = async (file) => {
const validationMessage = validateAudioBeforeUpload(file);
if (validationMessage) {
throw new Error(validationMessage);
}
setIsUploading(true);
setUploadProgress(0);
setUploadStatusMessage('正在上传音频文件...');
try {
await uploadMeetingAudio({
meetingId,
file,
promptId: meeting?.prompt_id,
modelCode: selectedModelCode,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setUploadStatusMessage('正在上传音频文件...');
},
});
setUploadProgress(100);
setUploadStatusMessage('上传完成,正在启动转录任务...');
message.success('音频上传成功');
setTranscript([]);
setSpeakerList([]);
setEditingSpeakers({});
await fetchMeetingDetails({ showPageLoading: false });
await fetchTranscript();
} catch (error) {
message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败');
throw error;
} finally {
setIsUploading(false);
setUploadProgress(0);
setUploadStatusMessage('');
}
};
const handleUploadAudioRequest = async ({ file, onSuccess, onError }) => {
try {
await handleUploadAudio(file);
onSuccess?.({}, file);
} catch (error) {
onError?.(error);
}
};
const handleTimeUpdate = () => {
if (!audioRef.current) {
return;
}
const timeMs = audioRef.current.currentTime * 1000;
const nextIndex = findTranscriptIndexByTime(transcript, timeMs);
if (nextIndex !== -1 && nextIndex !== currentHighlightIndex) {
setCurrentHighlightIndex(nextIndex);
transcriptRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
const handleTranscriptScroll = (event) => {
if (transcriptVisibleCount >= transcript.length) {
return;
}
const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
if (scrollHeight - scrollTop - clientHeight > 240) {
return;
}
setTranscriptVisibleCount((prev) => Math.min(transcript.length, prev + TRANSCRIPT_RENDER_STEP));
};
const jumpToTime = (ms) => {
if (audioRef.current) {
audioRef.current.currentTime = ms / 1000;
audioRef.current.play();
}
};
const saveAccessPassword = async () => {
const nextPassword = accessPasswordEnabled ? accessPasswordDraft.trim() : null;
if (accessPasswordEnabled && !nextPassword) {
message.warning('开启访问密码后,请先输入密码');
return;
}
setSavingAccessPassword(true);
try {
const res = await apiClient.put(
buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meetingId)),
{ password: nextPassword }
);
const savedPassword = res.data?.password || null;
setMeeting((prev) => (prev ? { ...prev, access_password: savedPassword } : prev));
setAccessPasswordEnabled(Boolean(savedPassword));
setAccessPasswordDraft(savedPassword || '');
message.success(res.message || '访问密码已更新');
} catch (error) {
message.error(error?.response?.data?.message || '访问密码更新失败');
} finally {
setSavingAccessPassword(false);
}
};
const handleAccessPasswordSwitchChange = async (checked) => {
setAccessPasswordEnabled(checked);
if (checked) {
const existingPassword = (meeting?.access_password || accessPasswordDraft || '').trim();
setAccessPasswordDraft(existingPassword || generateRandomPassword());
return;
}
setAccessPasswordDraft('');
setSavingAccessPassword(true);
try {
const res = await apiClient.put(
buildApiUrl(API_ENDPOINTS.MEETINGS.ACCESS_PASSWORD(meetingId)),
{ password: null }
);
setMeeting((prev) => (prev ? { ...prev, access_password: null } : prev));
message.success(res.message || '访问密码已关闭');
} catch (error) {
setAccessPasswordEnabled(true);
setAccessPasswordDraft(meeting?.access_password || '');
message.error(error?.response?.data?.message || '访问密码更新失败');
} finally {
setSavingAccessPassword(false);
}
};
const copyAccessPassword = async () => {
if (!accessPasswordDraft) {
message.warning('当前没有可复制的访问密码');
return;
}
await navigator.clipboard.writeText(accessPasswordDraft);
message.success('访问密码已复制');
};
const openAudioUploadPicker = () => {
document.getElementById('audio-upload-input')?.click();
};
const startInlineSpeakerEdit = (speakerId, currentTag, segmentId) => {
setInlineSpeakerEdit(speakerId);
setInlineSpeakerEditSegmentId(`speaker-${speakerId}-${segmentId}`);
setInlineSpeakerValue(currentTag || `发言人 ${speakerId}`);
};
const cancelInlineSpeakerEdit = () => {
setInlineSpeakerEdit(null);
setInlineSpeakerEditSegmentId(null);
setInlineSpeakerValue('');
};
const saveInlineSpeakerEdit = async () => {
if (inlineSpeakerEdit == null) {
return;
}
const nextTag = inlineSpeakerValue.trim();
if (!nextTag) {
message.warning('发言人名称不能为空');
return;
}
setSavingInlineEdit(true);
try {
await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/speaker-tags/batch`), {
updates: [{ speaker_id: inlineSpeakerEdit, new_tag: nextTag }],
});
setTranscript((prev) => prev.map((item) => (
item.speaker_id === inlineSpeakerEdit
? { ...item, speaker_tag: nextTag }
: item
)));
setSpeakerList((prev) => prev.map((item) => (
item.speaker_id === inlineSpeakerEdit
? { ...item, speaker_tag: nextTag }
: item
)));
setEditingSpeakers((prev) => ({ ...prev, [inlineSpeakerEdit]: nextTag }));
message.success('发言人名称已更新');
cancelInlineSpeakerEdit();
} catch (error) {
message.error(error?.response?.data?.message || '更新发言人名称失败');
} finally {
setSavingInlineEdit(false);
}
};
const startInlineSegmentEdit = (segment) => {
setInlineSegmentEditId(segment.segment_id);
setInlineSegmentValue(segment.text_content || '');
};
const cancelInlineSegmentEdit = () => {
setInlineSegmentEditId(null);
setInlineSegmentValue('');
};
const saveInlineSegmentEdit = async () => {
if (inlineSegmentEditId == null) {
return;
}
setSavingInlineEdit(true);
try {
await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/transcript/batch`), {
updates: [{ segment_id: inlineSegmentEditId, text_content: inlineSegmentValue }],
});
setTranscript((prev) => prev.map((item) => (
item.segment_id === inlineSegmentEditId
? { ...item, text_content: inlineSegmentValue }
: item
)));
message.success('转录内容已更新');
cancelInlineSegmentEdit();
} catch (error) {
message.error(error?.response?.data?.message || '更新转录内容失败');
} finally {
setSavingInlineEdit(false);
}
};
const changePlaybackRate = (nextRate) => {
setPlaybackRate(nextRate);
if (audioRef.current) {
audioRef.current.playbackRate = nextRate;
}
};
const handleStartTranscription = async () => {
try {
const res = await apiClient.post(buildApiUrl(`/api/meetings/${meetingId}/transcription/start`));
if (res.data?.task_id) {
message.success('转录任务已启动');
setTranscriptionStatus({ status: 'processing' });
startStatusPolling(res.data.task_id);
}
} catch (error) {
message.error(error?.response?.data?.detail || '启动转录失败');
}
};
const handleDeleteMeeting = () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可删除会议');
return;
}
modal.confirm({
title: '删除会议',
content: '确定要删除此会议吗?此操作无法撤销。',
okText: '删除',
okType: 'danger',
onOk: async () => {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId)));
navigate('/dashboard');
},
});
};
const generateSummary = async () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可重新总结');
return;
}
if (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,暂不允许重新总结');
return;
}
setSummaryLoading(true);
setSummaryTaskProgress(0);
try {
const res = await apiClient.post(buildApiUrl(`/api/meetings/${meetingId}/generate-summary-async`), {
user_prompt: userPrompt,
prompt_id: selectedPromptId,
model_code: selectedModelCode,
});
startSummaryPolling(res.data.task_id, { closeDrawerOnComplete: true });
} catch (error) {
message.error(error?.response?.data?.message || '生成总结失败');
setSummaryLoading(false);
}
};
const openSummaryDrawer = () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可重新总结');
return;
}
if (isUploading) {
message.warning('音频上传中,暂不允许重新总结');
return;
}
if (!hasUploadedAudio) {
message.warning('请先上传音频后再总结');
return;
}
if (isTranscriptionRunning) {
message.warning('转录进行中,完成后会自动总结');
return;
}
setShowSummaryDrawer(true);
};
const downloadSummaryMd = () => {
if (!meeting?.summary) {
message.warning('暂无总结内容');
return;
}
const blob = new Blob([meeting.summary], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${meeting.title || 'summary'}_总结.md`;
anchor.click();
URL.revokeObjectURL(url);
};
const saveTranscriptEdits = async () => {
try {
const updates = Object.values(editingSegments).map((segment) => ({
segment_id: segment.segment_id,
text_content: segment.text_content,
}));
await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/transcript/batch`), { updates });
message.success('转录内容已更新');
setShowTranscriptEditDrawer(false);
await fetchTranscript();
} catch (error) {
console.debug('批量更新转录失败:', error);
message.error('更新失败');
}
};
const openSummaryEditDrawer = () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可编辑总结');
return;
}
setEditingSummaryContent(meeting?.summary || '');
setIsEditingSummary(true);
};
const saveSummaryContent = async () => {
if (!isMeetingOwner) {
message.warning('仅会议创建人可编辑总结');
return;
}
try {
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), {
title: meeting.title,
meeting_time: meeting.meeting_time,
summary: editingSummaryContent,
tags: meeting.tags?.map((tag) => tag.name).join(',') || '',
});
message.success('总结已保存');
setMeeting((prev) => (prev ? { ...prev, summary: editingSummaryContent } : prev));
setIsEditingSummary(false);
} catch {
message.error('保存失败');
}
};
const saveSpeakerTags = async () => {
const updates = Object.entries(editingSpeakers).map(([id, tag]) => ({
speaker_id: parseInt(id, 10),
new_tag: tag,
}));
await apiClient.put(buildApiUrl(`/api/meetings/${meetingId}/speaker-tags/batch`), { updates });
setShowSpeakerDrawer(false);
await fetchTranscript();
message.success('更新成功');
};
return {
meetingId,
meeting,
loading,
transcript,
transcriptLoading,
audioUrl,
editingSpeakers,
setEditingSpeakers,
speakerList,
transcriptionStatus,
currentHighlightIndex,
showSummaryDrawer,
setShowSummaryDrawer,
summaryLoading,
summaryResourcesLoading,
userPrompt,
setUserPrompt,
promptList,
selectedPromptId,
setSelectedPromptId,
summaryTaskProgress,
summaryTaskMessage,
llmModels,
selectedModelCode,
setSelectedModelCode,
showSpeakerDrawer,
setShowSpeakerDrawer,
viewingPrompt,
setViewingPrompt,
editDrawerOpen,
setEditDrawerOpen,
showQRModal,
setShowQRModal,
isUploading,
displayUploadProgress,
uploadStatusMessage,
playbackRate,
accessPasswordEnabled,
accessPasswordDraft,
setAccessPasswordDraft,
savingAccessPassword,
showTranscriptEditDrawer,
setShowTranscriptEditDrawer,
editingSegments,
setEditingSegments,
isEditingSummary,
setIsEditingSummary,
editingSummaryContent,
setEditingSummaryContent,
inlineSpeakerEdit,
inlineSpeakerEditSegmentId,
inlineSpeakerValue,
setInlineSpeakerValue,
inlineSegmentEditId,
inlineSegmentValue,
setInlineSegmentValue,
savingInlineEdit,
transcriptVisibleCount,
audioRef,
transcriptRefs,
isMeetingOwner,
creatorName,
isTranscriptionRunning,
isSummaryRunning,
displayTranscriptionProgress,
displaySummaryProgress,
summaryDisabledReason,
isSummaryActionDisabled,
validateAudioBeforeUpload,
handleUploadAudioRequest,
fetchMeetingDetails,
handleTimeUpdate,
handleTranscriptScroll,
jumpToTime,
saveAccessPassword,
handleAccessPasswordSwitchChange,
copyAccessPassword,
openAudioUploadPicker,
startInlineSpeakerEdit,
saveInlineSpeakerEdit,
cancelInlineSpeakerEdit,
startInlineSegmentEdit,
saveInlineSegmentEdit,
cancelInlineSegmentEdit,
changePlaybackRate,
handleStartTranscription,
handleDeleteMeeting,
generateSummary,
openSummaryDrawer,
downloadSummaryMd,
saveTranscriptEdits,
openSummaryEditDrawer,
saveSummaryContent,
saveSpeakerTags,
};
}

View File

@ -0,0 +1,179 @@
import { useCallback, useEffect, useState } from 'react';
import { App, Form, Upload } from 'antd';
import dayjs from 'dayjs';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import configService from '../utils/configService';
import { uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
export default function useMeetingFormDrawer({ open, onClose, onSuccess, meetingId = null }) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]);
const [selectedAudioFile, setSelectedAudioFile] = useState(null);
const [audioUploading, setAudioUploading] = useState(false);
const [audioUploadProgress, setAudioUploadProgress] = useState(0);
const [audioUploadMessage, setAudioUploadMessage] = useState('');
const [maxAudioSize, setMaxAudioSize] = useState(100 * 1024 * 1024);
const isEdit = Boolean(meetingId);
const fetchOptions = useCallback(async () => {
try {
const [usersResponse, promptsResponse] = await Promise.all([
apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)),
apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))),
]);
setUsers(usersResponse.data.users || []);
setPrompts(promptsResponse.data.prompts || []);
} catch {
message.error('加载会议表单选项失败');
}
}, [message]);
const loadAudioUploadConfig = useCallback(async () => {
try {
const nextMaxAudioSize = await configService.getMaxAudioSize();
setMaxAudioSize(nextMaxAudioSize || 100 * 1024 * 1024);
} catch {
setMaxAudioSize(100 * 1024 * 1024);
}
}, []);
const fetchMeeting = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
const meeting = response.data;
form.setFieldsValue({
title: meeting.title,
meeting_time: dayjs(meeting.meeting_time),
attendee_ids: meeting.attendee_ids || meeting.attendees?.map((attendee) => attendee.user_id).filter(Boolean) || [],
prompt_id: meeting.prompt_id,
tags: meeting.tags?.map((tag) => tag.name) || [],
});
} catch {
message.error('加载会议数据失败');
}
}, [form, meetingId, message]);
useEffect(() => {
if (!open) {
return;
}
fetchOptions();
loadAudioUploadConfig();
if (isEdit) {
fetchMeeting();
return;
}
form.resetFields();
form.setFieldsValue({ meeting_time: dayjs() });
setSelectedAudioFile(null);
setAudioUploading(false);
setAudioUploadProgress(0);
setAudioUploadMessage('');
}, [fetchMeeting, fetchOptions, form, isEdit, loadAudioUploadConfig, open]);
const handleAudioBeforeUpload = (file) => {
const validationMessage = validateMeetingAudioFile(file, maxAudioSize, configService.formatFileSize.bind(configService));
if (validationMessage) {
message.warning(validationMessage);
return Upload.LIST_IGNORE;
}
setSelectedAudioFile(file);
return false;
};
const clearSelectedAudio = () => {
setSelectedAudioFile(null);
setAudioUploadProgress(0);
setAudioUploadMessage('');
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const payload = {
...values,
meeting_time: values.meeting_time.format('YYYY-MM-DD HH:mm:ss'),
attendee_ids: values.attendee_ids || [],
tags: values.tags?.join(',') || '',
};
if (isEdit) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload);
message.success('会议更新成功');
onSuccess?.();
onClose();
return;
}
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (response.code !== '200') {
return;
}
const newMeetingId = response.data.meeting_id;
if (selectedAudioFile) {
setAudioUploading(true);
setAudioUploadProgress(0);
setAudioUploadMessage('正在上传音频文件...');
try {
await uploadMeetingAudio({
meetingId: newMeetingId,
file: selectedAudioFile,
promptId: values.prompt_id,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setAudioUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total)));
}
setAudioUploadMessage('正在上传音频文件...');
},
});
setAudioUploadProgress(100);
setAudioUploadMessage('上传完成,正在启动转录任务...');
message.success('会议创建成功,音频已开始上传处理');
} catch (uploadError) {
message.warning(uploadError?.response?.data?.message || uploadError?.response?.data?.detail || '会议已创建,但音频上传失败,请在详情页重试');
} finally {
setAudioUploading(false);
}
} else {
message.success('会议创建成功');
}
onSuccess?.(newMeetingId);
onClose();
} catch (error) {
if (!error?.errorFields) {
message.error(error?.response?.data?.message || error?.response?.data?.detail || '操作失败');
}
} finally {
setLoading(false);
}
};
return {
form,
isEdit,
loading,
users,
prompts,
selectedAudioFile,
audioUploading,
audioUploadProgress,
audioUploadMessage,
maxAudioSize,
handleAudioBeforeUpload,
clearSelectedAudio,
handleSubmit,
};
}

View File

@ -25,10 +25,11 @@ import {
SaveOutlined,
UserOutlined,
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { API_ENDPOINTS, buildApiUrl } from '../config/api';
import StatusTag from '../components/StatusTag';
import ActionButton from '../components/ActionButton';
import { updateStoredUser } from '../services/authSessionService';
import './AccountSettings.css';
const { Title, Paragraph, Text } = Typography;
@ -43,30 +44,6 @@ const formatDateTime = (value) => {
return date.toLocaleString('zh-CN', { hour12: false });
};
const buildUpdatedAuthPayload = (nextUser) => {
const raw = localStorage.getItem('iMeetingUser');
if (!raw || raw === 'undefined' || raw === 'null') {
return nextUser;
}
try {
const savedAuth = JSON.parse(raw);
if (savedAuth && typeof savedAuth === 'object' && savedAuth.user) {
return {
...savedAuth,
user: {
...savedAuth.user,
...nextUser,
},
};
}
} catch (error) {
console.error('Failed to parse saved auth payload:', error);
}
return nextUser;
};
const AccountSettings = ({ user, onUpdateUser }) => {
const { message, modal } = App.useApp();
const [profileForm] = Form.useForm();
@ -84,7 +61,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
const fetchUserData = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
profileForm.setFieldsValue({
username: response.data.username,
caption: response.data.caption,
@ -103,7 +80,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
setMcpLoading(true);
setMcpError('');
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.MCP_CONFIG(user.user_id)));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.MCP_CONFIG(user.user_id)));
setMcpConfig(response.data || null);
} catch (error) {
setMcpConfig(null);
@ -146,21 +123,20 @@ const AccountSettings = ({ user, onUpdateUser }) => {
if (avatarFile) {
const formData = new FormData();
formData.append('file', avatarFile);
const uploadRes = await apiClient.post(
const uploadRes = await httpService.post(
buildApiUrl(API_ENDPOINTS.USERS.AVATAR(user.user_id)),
formData,
);
currentAvatarUrl = uploadRes.data.avatar_url;
}
const updateRes = await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE(user.user_id)), {
const updateRes = await httpService.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE(user.user_id)), {
caption: values.caption,
email: values.email,
avatar_url: currentAvatarUrl,
});
const nextAuthPayload = buildUpdatedAuthPayload(updateRes.data);
localStorage.setItem('iMeetingUser', JSON.stringify(nextAuthPayload));
const nextAuthPayload = updateStoredUser(updateRes.data);
onUpdateUser?.(nextAuthPayload);
setAvatarFile(null);
setPreviewAvatar(updateRes.data.avatar_url || null);
@ -175,7 +151,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
const handlePasswordUpdate = async (values) => {
setPasswordLoading(true);
try {
await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)), {
await httpService.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)), {
old_password: values.old_password,
new_password: values.new_password,
});
@ -207,7 +183,7 @@ const AccountSettings = ({ user, onUpdateUser }) => {
onOk: async () => {
setMcpRefreshing(true);
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.MCP_REGENERATE(user.user_id)));
const response = await httpService.post(buildApiUrl(API_ENDPOINTS.USERS.MCP_REGENERATE(user.user_id)));
setMcpConfig(response.data || null);
setMcpError('');
message.success('MCP Secret 已重新生成');

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useMemo } from 'react';
import {
Card,
Table,
@ -10,7 +10,6 @@ import {
Button,
Tag,
Select,
App,
Modal,
Descriptions,
Badge,
@ -35,16 +34,13 @@ import {
ClockCircleOutlined,
CloseOutlined,
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ActionButton from '../components/ActionButton';
import useSystemPageSize from '../hooks/useSystemPageSize';
import useAdminDashboardPage from '../hooks/useAdminDashboardPage';
import './AdminDashboard.css';
const { Text } = Typography;
const AUTO_REFRESH_INTERVAL = 30;
const TASK_TYPE_MAP = {
transcription: { text: '转录', color: 'blue' },
summary: { text: '总结', color: 'green' },
@ -61,181 +57,34 @@ const STATUS_MAP = {
const formatResourcePercent = (value) => `${Number(value || 0).toFixed(1)}%`;
const AdminDashboard = () => {
const { message, modal } = App.useApp();
const inFlightRef = useRef(false);
const mountedRef = useRef(true);
const [stats, setStats] = useState(null);
const [onlineUsers, setOnlineUsers] = useState([]);
const [usersList, setUsersList] = useState([]);
const [tasks, setTasks] = useState([]);
const [resources, setResources] = useState(null);
const [loading, setLoading] = useState(true);
const [taskLoading, setTaskLoading] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const [taskType, setTaskType] = useState('all');
const [taskStatus, setTaskStatus] = useState('all');
const {
stats,
onlineUsers,
usersList,
tasks,
resources,
loading,
taskLoading,
lastUpdatedAt,
taskType,
setTaskType,
taskStatus,
setTaskStatus,
autoRefresh,
setAutoRefresh,
countdown,
showMeetingModal,
meetingDetails,
meetingLoading,
fetchAllData,
handleKickUser,
handleViewMeeting,
handleDownloadTranscript,
closeMeetingModal,
taskCompletionRate,
} = useAdminDashboardPage();
const pageSize = useSystemPageSize(10);
const [autoRefresh, setAutoRefresh] = useState(true);
const [countdown, setCountdown] = useState(AUTO_REFRESH_INTERVAL);
const [showMeetingModal, setShowMeetingModal] = useState(false);
const [meetingDetails, setMeetingDetails] = useState(null);
const [meetingLoading, setMeetingLoading] = useState(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const fetchStats = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.DASHBOARD_STATS));
if (response.code === '200') setStats(response.data);
}, []);
const fetchOnlineUsers = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.ONLINE_USERS));
if (response.code === '200') setOnlineUsers(response.data.users || []);
}, []);
const fetchUsersList = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.USER_STATS));
if (response.code === '200') setUsersList(response.data.users || []);
}, []);
const fetchTasks = useCallback(async () => {
try {
setTaskLoading(true);
const params = new URLSearchParams();
if (taskType !== 'all') params.append('task_type', taskType);
if (taskStatus !== 'all') params.append('status', taskStatus);
params.append('limit', '20');
const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.TASKS_MONITOR}?${params.toString()}`));
if (response.code === '200') setTasks(response.data.tasks || []);
} finally {
setTaskLoading(false);
}
}, [taskStatus, taskType]);
const fetchResources = useCallback(async () => {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.SYSTEM_RESOURCES));
if (response.code === '200') setResources(response.data);
}, []);
const fetchAllData = useCallback(async ({ silent = false } = {}) => {
if (inFlightRef.current) return;
inFlightRef.current = true;
try {
if (!silent && mountedRef.current) {
setLoading(true);
}
await Promise.all([fetchStats(), fetchOnlineUsers(), fetchUsersList(), fetchTasks(), fetchResources()]);
if (mountedRef.current) {
setLastUpdatedAt(new Date());
setCountdown(AUTO_REFRESH_INTERVAL);
}
} catch (err) {
console.error('获取数据失败:', err);
if (mountedRef.current && !silent) {
message.error('加载数据失败,请稍后重试');
}
} finally {
inFlightRef.current = false;
if (mountedRef.current && !silent) {
setLoading(false);
}
}
}, [fetchOnlineUsers, fetchResources, fetchStats, fetchTasks, fetchUsersList, message]);
useEffect(() => {
fetchAllData();
}, [fetchAllData]);
useEffect(() => {
if (!autoRefresh || showMeetingModal) return;
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
fetchAllData({ silent: true });
return AUTO_REFRESH_INTERVAL;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [autoRefresh, fetchAllData, showMeetingModal]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleKickUser = (u) => {
modal.confirm({
title: '踢出用户',
content: `确定要踢出用户"${u.caption}"吗?该用户将被强制下线。`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.KICK_USER(u.user_id)));
if (response.code === '200') {
message.success('用户已被踢出');
fetchOnlineUsers();
}
} catch {
message.error('踢出用户失败');
}
},
});
};
const handleViewMeeting = async (meetingId) => {
setMeetingLoading(true);
setShowMeetingModal(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meetingId)));
if (response.code === '200') setMeetingDetails(response.data);
} catch {
message.error('获取会议详情失败');
} finally {
setMeetingLoading(false);
}
};
const handleDownloadTranscript = async (meetingId) => {
try {
const response = await apiClient.get(buildApiUrl(`/api/meetings/${meetingId}/transcript`));
if (response.code === '200') {
const dataStr = JSON.stringify(response.data, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `transcript_${meetingId}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
} catch {
message.error('下载失败');
}
};
const taskCompletionRate = useMemo(() => {
const all = tasks.length || 1;
const completed = tasks.filter((item) => item.status === 'completed').length;
return Math.round((completed / all) * 100);
}, [tasks]);
const resourceRows = useMemo(() => ([
{
key: 'cpu',
@ -524,11 +373,8 @@ const AdminDashboard = () => {
<Modal
title="会议详情"
open={showMeetingModal}
onCancel={() => {
setShowMeetingModal(false);
setMeetingDetails(null);
}}
footer={[<Button key="close" icon={<CloseOutlined />} onClick={() => setShowMeetingModal(false)}>关闭</Button>]}
onCancel={closeMeetingModal}
footer={[<Button key="close" icon={<CloseOutlined />} onClick={closeMeetingModal}>关闭</Button>]}
width={620}
>
{meetingLoading ? (

View File

@ -36,7 +36,7 @@ import {
RocketOutlined,
SaveOutlined,
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import AdminModuleShell from '../components/AdminModuleShell';
import ActionButton from '../components/ActionButton';
@ -82,7 +82,7 @@ const ClientManagement = () => {
const fetchPlatforms = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')));
if (response.code === '200') {
const { tree = [], items = [] } = response.data || {};
setPlatforms({ tree, items });
@ -101,7 +101,7 @@ const ClientManagement = () => {
const fetchClients = useCallback(async () => {
setLoading(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST));
if (response.code === '200') {
setClients(response.data.clients || []);
}
@ -165,10 +165,10 @@ const ClientManagement = () => {
};
if (isEditing) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(selectedClient.id)), payload);
message.success('版本更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.CREATE), payload);
message.success('版本创建成功');
}
@ -189,7 +189,7 @@ const ClientManagement = () => {
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(item.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(item.id)));
message.success('删除成功');
fetchClients();
} catch {
@ -202,7 +202,7 @@ const ClientManagement = () => {
const handleToggleActive = async (item, checked) => {
setUpdatingStatusId(item.id);
try {
await apiClient.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(item.id)), {
await httpService.put(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPDATE(item.id)), {
is_active: checked,
});
setClients((prev) => prev.map((client) => (
@ -232,7 +232,7 @@ const ClientManagement = () => {
uploadFormData.append('platform_code', platformCode);
try {
const response = await apiClient.post(
const response = await httpService.post(
buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.UPLOAD),
uploadFormData,
{ headers: { 'Content-Type': 'multipart/form-data' } },

View File

@ -9,7 +9,7 @@ import {
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import configService from '../utils/configService';
import { AUDIO_UPLOAD_ACCEPT, uploadMeetingAudio, validateMeetingAudioFile } from '../services/meetingAudioService';
@ -31,7 +31,7 @@ const CreateMeeting = () => {
const fetchUsers = useCallback(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.LIST));
setUsers(res.data.users || []);
} catch {
setUsers([]);
@ -40,7 +40,7 @@ const CreateMeeting = () => {
const fetchPrompts = useCallback(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK')));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK')));
setPrompts(res.data.prompts || []);
} catch {
setPrompts([]);
@ -88,7 +88,7 @@ const CreateMeeting = () => {
attendee_ids: values.attendee_ids,
tags: values.tags?.join(',') || ''
};
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') {
const meetingId = res.data.meeting_id;

View File

@ -29,7 +29,7 @@ import {
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { API_ENDPOINTS, buildApiUrl } from '../config/api';
import ActionButton from '../components/ActionButton';
import MeetingTimeline from '../components/MeetingTimeline';
@ -113,9 +113,9 @@ const Dashboard = ({ user }) => {
const fetchVoiceprintData = useCallback(async () => {
try {
setVoiceprintLoading(true);
const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id)));
const statusResponse = await httpService.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id)));
setVoiceprintStatus(statusResponse.data);
const templateResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.TEMPLATE));
const templateResponse = await httpService.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.TEMPLATE));
setVoiceprintTemplate(templateResponse.data);
} catch (error) {
console.error('获取声纹数据失败:', error);
@ -148,7 +148,7 @@ const Dashboard = ({ user }) => {
if (isLoadMore) setLoadingMore(true); else setLoading(true);
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), {
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), {
params: {
user_id: user.user_id,
page,
@ -182,7 +182,7 @@ const Dashboard = ({ user }) => {
const fetchMeetingsStats = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), {
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), {
params: { user_id: user.user_id },
});
setMeetingsStats(response.data);
@ -193,7 +193,7 @@ const Dashboard = ({ user }) => {
const fetchUserData = useCallback(async () => {
try {
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
const userResponse = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
setUserInfo(userResponse.data);
} catch {
message.error('获取用户信息失败');
@ -218,7 +218,7 @@ const Dashboard = ({ user }) => {
const handleDeleteMeeting = async (meetingId) => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId)));
meetingCacheService.clearAll();
fetchMeetings(1, false);
fetchMeetingsStats();
@ -236,7 +236,7 @@ const Dashboard = ({ user }) => {
const handleVoiceprintUpload = async (formData) => {
try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.VOICEPRINT.UPLOAD(user.user_id)), formData, {
await httpService.post(buildApiUrl(API_ENDPOINTS.VOICEPRINT.UPLOAD(user.user_id)), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
await fetchVoiceprintData();
@ -255,7 +255,7 @@ const Dashboard = ({ user }) => {
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.VOICEPRINT.DELETE(user.user_id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.VOICEPRINT.DELETE(user.user_id)));
await fetchVoiceprintData();
message.success('声纹已删除');
} catch (error) {

View File

@ -7,7 +7,7 @@ import {
ArrowLeftOutlined, SaveOutlined, EditOutlined
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownEditor from '../components/MarkdownEditor';
@ -23,7 +23,7 @@ const EditKnowledgeBase = () => {
const fetchKbDetail = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id)));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id)));
form.setFieldsValue(response.data);
} catch {
message.error('加载知识库详情失败');
@ -39,7 +39,7 @@ const EditKnowledgeBase = () => {
const onFinish = async (values) => {
setSaving(true);
try {
await apiClient.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values);
await httpService.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), values);
message.success('更新成功');
navigate('/knowledge-base');
} catch {

View File

@ -8,7 +8,7 @@ import {
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import dayjs from 'dayjs';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { Title } = Typography;
@ -26,9 +26,9 @@ const EditMeeting = () => {
const fetchData = useCallback(async () => {
try {
const [uRes, pRes, mRes] = await Promise.all([
apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)),
apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))),
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)))
httpService.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)),
httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK'))),
httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id)))
]);
setUsers(uRes.data.users || []);
@ -61,7 +61,7 @@ const EditMeeting = () => {
attendee_ids: values.attendee_ids,
tags: values.tags?.join(',') || ''
};
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), payload);
message.success('会议更新成功');
navigate(`/meetings/${meeting_id}`);
} catch (error) {

View File

@ -8,7 +8,7 @@ import {
LockOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import menuService from '../services/menuService';
import BrandLogo from '../components/BrandLogo';
@ -35,24 +35,16 @@ const HomePage = ({ onLogin }) => {
const handleLogin = async (values) => {
setLoading(true);
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), {
const response = await httpService.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), {
username: values.username,
password: values.password
});
if (response.code === '200') {
message.success('登录成功');
// data token user
const authData = response.data;
localStorage.setItem('iMeetingUser', JSON.stringify(authData));
menuService.clearCache();
// App
onLogin(authData);
//
window.location.href = '/';
onLogin(response.data);
} else {
message.error(response.message || '登录失败');
}

View File

@ -13,7 +13,7 @@ import {
CheckCircleOutlined, InfoCircleOutlined, ThunderboltOutlined
} from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ContentViewer from '../components/ContentViewer';
import ActionButton from '../components/ActionButton';
@ -49,7 +49,7 @@ const KnowledgeBasePage = ({ user }) => {
const loadKbDetail = useCallback(async (id) => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id)));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id)));
setSelectedKb(res.data);
} catch {
message.error('加载知识库详情失败');
@ -58,7 +58,7 @@ const KnowledgeBasePage = ({ user }) => {
const fetchAllKbs = useCallback(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST));
const sorted = (res.data.kbs || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setKbs(sorted);
if (sorted.length > 0 && !selectedKb) {
@ -78,7 +78,7 @@ const KnowledgeBasePage = ({ user }) => {
search: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined
};
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params });
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params });
setMeetings(res.data.meetings || []);
setMeetingsPagination({ page: res.data.page, total: res.data.total });
} catch {
@ -90,7 +90,7 @@ const KnowledgeBasePage = ({ user }) => {
const fetchAvailableTags = useCallback(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
setAvailableTags(res.data?.slice(0, 10) || []);
} catch {
setAvailableTags([]);
@ -99,7 +99,7 @@ const KnowledgeBasePage = ({ user }) => {
const fetchPrompts = useCallback(async () => {
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
setAvailablePrompts(res.data.prompts || []);
const def = res.data.prompts?.find(p => p.is_default) || res.data.prompts?.[0];
if (def) setSelectedPromptId(def.id);
@ -130,7 +130,7 @@ const KnowledgeBasePage = ({ user }) => {
setGenerating(true);
setTaskProgress(10);
try {
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
user_prompt: userPrompt,
source_meeting_ids: selectedMeetings.join(','),
prompt_id: selectedPromptId
@ -138,7 +138,7 @@ const KnowledgeBasePage = ({ user }) => {
const taskId = res.data.task_id;
const interval = setInterval(async () => {
const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId)));
const statusRes = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId)));
const s = statusRes.data;
setTaskProgress(s.progress || 20);
if (s.status === 'completed') {
@ -166,7 +166,7 @@ const KnowledgeBasePage = ({ user }) => {
okText: '删除',
okType: 'danger',
onOk: async () => {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(kb.kb_id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(kb.kb_id)));
if (selectedKb?.kb_id === kb.kb_id) setSelectedKb(null);
fetchAllKbs();
message.success('删除成功');

View File

@ -24,7 +24,7 @@ import {
UserOutlined,
} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { API_ENDPOINTS, buildApiUrl } from '../config/api';
import ActionButton from '../components/ActionButton';
import CenterPager from '../components/CenterPager';
@ -123,7 +123,7 @@ const MeetingCenterPage = ({ user }) => {
setLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), {
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), {
params: {
user_id: user.user_id,
page: nextPage,
@ -176,7 +176,7 @@ const MeetingCenterPage = ({ user }) => {
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting.meeting_id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting.meeting_id)));
message.success('会议已删除');
meetingCacheService.clearAll();
const nextPage = page > 1 && meetings.length === 1 ? page - 1 : page;

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd';
import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MindMap from '../components/MindMap';
@ -38,8 +38,8 @@ const MeetingPreview = () => {
const fetchTranscriptAndAudio = useCallback(async () => {
const [transcriptRes, audioRes] = await Promise.allSettled([
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))),
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))),
httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))),
httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))),
]);
if (transcriptRes.status === 'fulfilled') {
@ -60,7 +60,7 @@ const MeetingPreview = () => {
try {
const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id);
const url = buildApiUrl(`${endpoint}${pwd ? `?password=${encodeURIComponent(pwd)}` : ''}`);
const res = await apiClient.get(url);
const res = await httpService.get(url);
setMeeting(res.data);
setIsAuthorized(true);
setPasswordRequired(false);

View File

@ -22,7 +22,7 @@ import {
SaveOutlined,
StarFilled,
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { API_ENDPOINTS, buildApiUrl } from '../config/api';
import PromptManagementPage from './PromptManagementPage';
import MarkdownRenderer from '../components/MarkdownRenderer';
@ -49,7 +49,7 @@ const PromptConfigPage = ({ user }) => {
const loadConfig = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType)));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType)));
setAvailablePrompts(res.data.available_prompts || []);
setSelectedPromptIds(res.data.selected_prompt_ids || []);
} catch {
@ -98,7 +98,7 @@ const PromptConfigPage = ({ user }) => {
is_enabled: true,
sort_order: index + 1,
}));
await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType)), { items });
await httpService.put(buildApiUrl(API_ENDPOINTS.PROMPTS.CONFIG(taskType)), { items });
message.success('提示词配置已保存');
loadConfig();
} catch (error) {
@ -126,7 +126,7 @@ const PromptConfigPage = ({ user }) => {
items={[
{
key: 'config',
label: '系统提示词配置',
label: '客户端提示词配置',
children: (
<div className="console-tab-panel">
<div className="console-tab-toolbar">

View File

@ -26,7 +26,7 @@ import {
SaveOutlined,
StarFilled,
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownEditor from '../components/MarkdownEditor';
import CenterPager from '../components/CenterPager';
@ -72,7 +72,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
setLoading(true);
try {
const is_active = statusFilter === 'all' ? undefined : Number(statusFilter);
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST), {
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST), {
params: {
page,
size,
@ -135,10 +135,10 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
}
setDrawerSubmitting(true);
if (editingPrompt) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)), payload);
message.success('提示词已更新');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), payload);
message.success('提示词已创建');
}
setDrawerOpen(false);
@ -160,7 +160,7 @@ const PromptManagementPage = ({ user, mode = 'default', embedded = false }) => {
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(prompt.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(prompt.id)));
message.success('提示词已删除');
loadPrompts();
} catch (error) {

View File

@ -9,7 +9,7 @@ import {
FileOutlined,
CloseOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
const { Option } = Select;
const { TextArea } = Input;
@ -28,7 +28,7 @@ const DictManagement = () => {
//
const fetchDictTypes = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.TYPES));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.TYPES));
if (response.code === '200') {
const types = (response.data.types || []).filter((type) => type !== 'system_config');
setDictTypes(types);
@ -60,7 +60,7 @@ const DictManagement = () => {
const fetchDictData = useCallback(async (dictType) => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE(dictType)));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE(dictType)));
if (response.code === '200') {
setDictData(response.data.items);
@ -144,7 +144,7 @@ const DictManagement = () => {
if (selectedNode) {
//
const response = await apiClient.put(
const response = await httpService.put(
buildApiUrl(API_ENDPOINTS.DICT_DATA.UPDATE(selectedNode.id)),
values
);
@ -154,7 +154,7 @@ const DictManagement = () => {
}
} else {
//
const response = await apiClient.post(
const response = await httpService.post(
buildApiUrl(API_ENDPOINTS.DICT_DATA.CREATE),
values
);
@ -174,7 +174,7 @@ const DictManagement = () => {
const handleDelete = async () => {
if (!selectedNode) return;
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.DICT_DATA.DELETE(selectedNode.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.DICT_DATA.DELETE(selectedNode.id)));
message.success('删除成功');
setSelectedNode(null);
setIsEditing(false);

View File

@ -35,7 +35,7 @@ import {
CheckCircleOutlined,
BlockOutlined,
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
@ -90,7 +90,7 @@ const ExternalAppManagement = () => {
const fetchApps = useCallback(async () => {
setLoading(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST));
if (response.code === '200') {
setApps(response.data || []);
}
@ -138,10 +138,10 @@ const ExternalAppManagement = () => {
};
if (isEditing) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(selectedApp.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(selectedApp.id)), payload);
message.success('应用更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.CREATE), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.CREATE), payload);
message.success('应用创建成功');
}
@ -162,7 +162,7 @@ const ExternalAppManagement = () => {
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.DELETE(item.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.DELETE(item.id)));
message.success('删除成功');
fetchApps();
} catch {
@ -182,7 +182,7 @@ const ExternalAppManagement = () => {
uploadFormData.append(fieldName, file);
try {
const response = await apiClient.post(buildApiUrl(endpoint), uploadFormData, {
const response = await httpService.post(buildApiUrl(endpoint), uploadFormData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@ -217,7 +217,7 @@ const ExternalAppManagement = () => {
const handleToggleStatus = async (item, checked) => {
try {
const newActive = checked ? 1 : 0;
await apiClient.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(item.id)), { is_active: newActive });
await httpService.put(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(item.id)), { is_active: newActive });
setApps((prev) => prev.map((app) => (
app.id === item.id ? { ...app, is_active: newActive } : app
)));

View File

@ -8,7 +8,7 @@ import {
FontSizeOutlined, SearchOutlined, ReloadOutlined,
SaveOutlined,
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
@ -54,7 +54,7 @@ const HotWordManagement = () => {
const fetchGroups = useCallback(async () => {
setGroupsLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUPS));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUPS));
if (res.code === '200') {
setGroups(res.data || []);
}
@ -70,7 +70,7 @@ const HotWordManagement = () => {
if (!groupId) { setItems([]); return; }
setItemsLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEMS(groupId)));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEMS(groupId)));
if (res.code === '200') {
setItems(res.data || []);
}
@ -129,10 +129,10 @@ const HotWordManagement = () => {
const values = await groupForm.validateFields();
const payload = { ...values, status: values.status ? 1 : 0 };
if (editingGroup) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUP_DETAIL(editingGroup.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUP_DETAIL(editingGroup.id)), payload);
message.success('更新成功');
} else {
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUPS), payload);
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUPS), payload);
if (res.code === '200') {
setSelectedGroupId(res.data?.id);
}
@ -155,7 +155,7 @@ const HotWordManagement = () => {
cancelText: '取消',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUP_DETAIL(group.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUP_DETAIL(group.id)));
message.success('删除成功');
if (selectedGroupId === group.id) setSelectedGroupId(null);
fetchGroups();
@ -170,7 +170,7 @@ const HotWordManagement = () => {
e?.stopPropagation();
setSyncLoadingId(group.id);
try {
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.SYNC_GROUP(group.id)));
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.SYNC_GROUP(group.id)));
if (res.code === '200') {
message.success(`同步成功 — 词表ID: ${res.data?.vocabulary_id}`);
fetchGroups();
@ -208,10 +208,10 @@ const HotWordManagement = () => {
const values = await itemForm.validateFields();
const payload = { ...values, status: values.status ? 1 : 0 };
if (editingItem) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEM_DETAIL(editingItem.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEM_DETAIL(editingItem.id)), payload);
message.success('更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEMS(selectedGroupId)), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEMS(selectedGroupId)), payload);
message.success('创建成功');
}
setItemDrawerOpen(false);
@ -231,7 +231,7 @@ const HotWordManagement = () => {
cancelText: '取消',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEM_DETAIL(item.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEM_DETAIL(item.id)));
message.success('删除成功');
fetchItems(selectedGroupId);
fetchGroups();
@ -244,7 +244,7 @@ const HotWordManagement = () => {
const toggleItemStatus = async (item, checked) => {
try {
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEM_DETAIL(item.id)), {
await httpService.put(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.ITEM_DETAIL(item.id)), {
status: checked ? 1 : 0,
});
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, status: checked ? 1 : 0 } : it)));

View File

@ -20,7 +20,7 @@ import {
Tooltip,
} from 'antd';
import { DeleteOutlined, EditOutlined, ExperimentOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined } from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { API_ENDPOINTS, buildApiUrl } from '../../config/api';
import {
buildManagedModelCode,
@ -179,7 +179,7 @@ const ModelManagement = () => {
kind === 'llm'
? API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIG_DETAIL(modelCode)
: API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIG_DETAIL(modelCode);
await apiClient.delete(buildApiUrl(endpoint));
await httpService.delete(buildApiUrl(endpoint));
message.success(kind === 'llm' ? 'LLM模型删除成功' : '音频模型删除成功');
fetchAll();
} catch (error) {
@ -191,9 +191,9 @@ const ModelManagement = () => {
setLoading(true);
try {
const [llmRes, audioRes, groupsRes] = await Promise.all([
apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIGS)),
apiClient.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIGS}?scene=all`)),
apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUPS)),
httpService.get(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIGS)),
httpService.get(buildApiUrl(`${API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIGS}?scene=all`)),
httpService.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.GROUPS)),
]);
setLlmItems(llmRes.data.items || []);
setAudioItems(audioRes.data.items || []);
@ -298,20 +298,20 @@ const ModelManagement = () => {
const payload = buildLlmPayload(values);
if (editingRow) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIG_DETAIL(editingRow.model_code)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIG_DETAIL(editingRow.model_code)), payload);
message.success('LLM模型更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIGS), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIGS), payload);
message.success('LLM模型创建成功');
}
} else {
const payload = buildAudioPayload(values);
if (editingRow) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIG_DETAIL(editingRow.model_code)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIG_DETAIL(editingRow.model_code)), payload);
message.success('音频模型更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIGS), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIGS), payload);
message.success('音频模型创建成功');
}
}
@ -334,7 +334,7 @@ const ModelManagement = () => {
...buildLlmPayload(values),
test_prompt: '请用一句中文回复LLM测试成功。',
};
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIG_TEST), payload);
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.LLM_MODEL_CONFIG_TEST), payload);
modal.success({
title: 'LLM测试成功',
width: 640,
@ -351,7 +351,7 @@ const ModelManagement = () => {
...buildAudioPayload(values),
test_file_url: 'https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav',
};
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIG_TEST), payload);
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.AUDIO_MODEL_CONFIG_TEST), payload);
modal.success({
title: 'ASR测试任务已提交',
width: 680,

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, App, Button, Drawer, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SettingOutlined } from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { API_ENDPOINTS, buildApiUrl } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
@ -57,7 +57,7 @@ const ParameterManagement = () => {
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS));
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS));
setItems(res.data.items || []);
} catch {
message.error('获取参数列表失败');
@ -98,10 +98,10 @@ const ParameterManagement = () => {
setSubmitting(true);
try {
if (editing) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DETAIL(editing.param_key)), values);
await httpService.put(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DETAIL(editing.param_key)), values);
message.success('参数更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS), values);
await httpService.post(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETERS), values);
message.success('参数创建成功');
}
configService.clearCache();
@ -152,7 +152,7 @@ const ParameterManagement = () => {
okButtonProps={{ danger: true }}
onConfirm={async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DELETE(row.param_key)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.ADMIN.PARAMETER_DELETE(row.param_key)));
configService.clearCache();
message.success('参数删除成功');
fetchItems();

View File

@ -39,7 +39,7 @@ import {
CompressOutlined,
} from '@ant-design/icons';
import ActionButton from '../../components/ActionButton';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import useSystemPageSize from '../../hooks/useSystemPageSize';
@ -214,9 +214,9 @@ const PermissionManagement = () => {
setLoading(true);
try {
const [rolesRes, menusRes, rolePermsRes] = await Promise.all([
apiClient.get(buildApiUrl('/api/admin/roles')),
apiClient.get(buildApiUrl('/api/admin/menus')),
apiClient.get(buildApiUrl('/api/admin/roles/permissions/all')),
httpService.get(buildApiUrl('/api/admin/roles')),
httpService.get(buildApiUrl('/api/admin/menus')),
httpService.get(buildApiUrl('/api/admin/roles/permissions/all')),
]);
const rolesList = rolesRes.data.roles || [];
@ -255,7 +255,7 @@ const PermissionManagement = () => {
}
setRoleUsersLoading(true);
try {
const res = await apiClient.get(
const res = await httpService.get(
buildApiUrl(`/api/admin/roles/${selectedRoleId}/users?page=${roleUsersPage}&size=${roleUsersPageSize}`),
);
setRoleUsers(res.data.users || []);
@ -280,7 +280,7 @@ const PermissionManagement = () => {
setSavingPermission(true);
try {
await apiClient.put(buildApiUrl(`/api/admin/roles/${selectedRoleId}/permissions`), {
await httpService.put(buildApiUrl(`/api/admin/roles/${selectedRoleId}/permissions`), {
menu_ids: sanitizedMenuIds,
});
@ -317,10 +317,10 @@ const PermissionManagement = () => {
setRoleSubmitting(true);
try {
if (editingRole) {
await apiClient.put(buildApiUrl(`/api/admin/roles/${editingRole.role_id}`), values);
await httpService.put(buildApiUrl(`/api/admin/roles/${editingRole.role_id}`), values);
message.success('角色更新成功');
} else {
await apiClient.post(buildApiUrl('/api/admin/roles'), values);
await httpService.post(buildApiUrl('/api/admin/roles'), values);
message.success('角色创建成功');
}
setRoleDrawerOpen(false);
@ -397,10 +397,10 @@ const PermissionManagement = () => {
setMenuSubmitting(true);
try {
if (editingMenu) {
await apiClient.put(buildApiUrl(`/api/admin/menus/${editingMenu.menu_id}`), values);
await httpService.put(buildApiUrl(`/api/admin/menus/${editingMenu.menu_id}`), values);
message.success('菜单更新成功');
} else {
await apiClient.post(buildApiUrl('/api/admin/menus'), values);
await httpService.post(buildApiUrl('/api/admin/menus'), values);
message.success('菜单创建成功');
}
setMenuIconPickerOpen(false);
@ -415,7 +415,7 @@ const PermissionManagement = () => {
const deleteMenu = async (menuId) => {
try {
await apiClient.delete(buildApiUrl(`/api/admin/menus/${menuId}`));
await httpService.delete(buildApiUrl(`/api/admin/menus/${menuId}`));
message.success('菜单删除成功');
if (selectedManageMenuId === menuId) {
setSelectedManageMenuId(null);
@ -631,7 +631,7 @@ const PermissionManagement = () => {
try {
await Promise.all(
updates.map((item) =>
apiClient.put(buildApiUrl(`/api/admin/menus/${item.menu_id}`), {
httpService.put(buildApiUrl(`/api/admin/menus/${item.menu_id}`), {
parent_id: item.parent_id,
sort_order: item.sort_order,
}),

View File

@ -19,7 +19,7 @@ import {
MessageOutlined,
DatabaseOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import ActionButton from '../../components/ActionButton';
import MarkdownEditor from '../../components/MarkdownEditor';
@ -47,7 +47,7 @@ const PromptManagement = () => {
const fetchPrompts = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.LIST));
const list = response.data.prompts || [];
setPrompts(list);
if (list.length > 0 && !selectedPrompt) {
@ -81,7 +81,7 @@ const PromptManagement = () => {
is_default: editingPrompt.is_default ? 1 : 0,
is_active: editingPrompt.is_active ? 1 : 0
};
await apiClient.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.PROMPTS.UPDATE(editingPrompt.id)), payload);
message.success('保存成功');
fetchPrompts();
} catch {
@ -102,7 +102,7 @@ const PromptManagement = () => {
okText: '删除',
okType: 'danger',
onOk: async () => {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(item.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(item.id)));
message.success('删除成功');
setSelectedPrompt(null);
fetchPrompts();
@ -216,7 +216,7 @@ const PromptManagement = () => {
onOk={() => form.submit()}
>
<Form form={form} layout="vertical" onFinish={async (v) => {
await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), v);
await httpService.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), v);
message.success('创建成功');
setShowCreateModal(false);
fetchPrompts();

View File

@ -21,7 +21,7 @@ import {
ClusterOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import {
@ -79,9 +79,9 @@ const SystemManagementOverview = () => {
setLoading(true);
try {
const [clientRes, appRes, terminalRes] = await Promise.all([
apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST)),
apiClient.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST)),
apiClient.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST, { page: 1, size: 10000 })),
httpService.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LIST)),
httpService.get(buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.LIST)),
httpService.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST, { page: 1, size: 10000 })),
]);
setClients(clientRes?.data?.clients || []);

View File

@ -30,7 +30,7 @@ import {
CheckCircleOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
@ -61,7 +61,7 @@ const TerminalManagement = () => {
const fetchTerminalTypes = useCallback(async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')), {
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')), {
params: { parent_code: 'TERMINAL' },
});
if (response.code === '200') {
@ -75,7 +75,7 @@ const TerminalManagement = () => {
const fetchTerminals = useCallback(async () => {
setLoading(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), {
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), {
params: { page: 1, size: 10000 },
});
if (response.code === '200') {
@ -122,10 +122,10 @@ const TerminalManagement = () => {
};
if (isEditing) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)), payload);
await httpService.put(buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)), payload);
message.success('终端更新成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.TERMINALS.CREATE), payload);
await httpService.post(buildApiUrl(API_ENDPOINTS.TERMINALS.CREATE), payload);
message.success('终端创建成功');
}
@ -146,7 +146,7 @@ const TerminalManagement = () => {
okType: 'danger',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.TERMINALS.DELETE(item.id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.TERMINALS.DELETE(item.id)));
message.success('删除成功');
fetchTerminals();
} catch {
@ -159,7 +159,7 @@ const TerminalManagement = () => {
const handleToggleStatus = async (item, checked) => {
try {
const newStatus = checked ? 1 : 0;
await apiClient.post(buildApiUrl(API_ENDPOINTS.TERMINALS.STATUS(item.id)), null, {
await httpService.post(buildApiUrl(API_ENDPOINTS.TERMINALS.STATUS(item.id)), null, {
params: { status: newStatus },
});
setTerminals((prev) => prev.map((terminal) => (

View File

@ -12,7 +12,7 @@ import {
SafetyCertificateOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import apiClient from '../../utils/apiClient';
import httpService from '../../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import AdminModuleShell from '../../components/AdminModuleShell';
import ActionButton from '../../components/ActionButton';
@ -54,7 +54,7 @@ const UserManagement = () => {
if (debouncedSearchText) {
url += `&search=${encodeURIComponent(debouncedSearchText)}`;
}
const response = await apiClient.get(buildApiUrl(url));
const response = await httpService.get(buildApiUrl(url));
setUsers(response.data.users || []);
setTotal(response.data.total || 0);
} catch {
@ -70,7 +70,7 @@ const UserManagement = () => {
const fetchRoles = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.ROLES));
const response = await httpService.get(buildApiUrl(API_ENDPOINTS.USERS.ROLES));
setRoles(response.data || []);
} catch {
setRoles([
@ -98,10 +98,10 @@ const UserManagement = () => {
try {
const values = await form.validateFields();
if (isEditing) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE(currentUser.user_id)), values);
await httpService.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE(currentUser.user_id)), values);
message.success('用户修改成功');
} else {
await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.CREATE), values);
await httpService.post(buildApiUrl(API_ENDPOINTS.USERS.CREATE), values);
message.success('用户添加成功');
}
setShowUserDrawer(false);
@ -122,7 +122,7 @@ const UserManagement = () => {
cancelText: '取消',
onOk: async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.USERS.DELETE(user.user_id)));
await httpService.delete(buildApiUrl(API_ENDPOINTS.USERS.DELETE(user.user_id)));
message.success('用户删除成功');
fetchUsers();
} catch {
@ -140,7 +140,7 @@ const UserManagement = () => {
cancelText: '取消',
onOk: async () => {
try {
await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.RESET_PASSWORD(user.user_id)));
await httpService.post(buildApiUrl(API_ENDPOINTS.USERS.RESET_PASSWORD(user.user_id)));
message.success('密码重置成功');
} catch {
message.error('重置失败');

View File

@ -0,0 +1,82 @@
const AUTH_STORAGE_KEY = 'iMeetingUser';
const isValidStoredValue = (value) => value && value !== 'undefined' && value !== 'null';
export const getStoredAuthPayload = () => {
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
if (!isValidStoredValue(raw)) {
return null;
}
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (error) {
console.error('Failed to parse stored auth payload:', error);
localStorage.removeItem(AUTH_STORAGE_KEY);
return null;
}
};
export const getStoredUser = () => {
const authPayload = getStoredAuthPayload();
const user = authPayload?.user || authPayload;
if (user && typeof user === 'object' && (user.user_id || user.id)) {
return user;
}
if (authPayload) {
localStorage.removeItem(AUTH_STORAGE_KEY);
}
return null;
};
export const getStoredToken = () => {
const authPayload = getStoredAuthPayload();
return authPayload?.token || null;
};
export const setStoredAuthPayload = (authPayload) => {
if (!authPayload || typeof authPayload !== 'object') {
return null;
}
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authPayload));
return getStoredUser();
};
export const clearStoredAuthPayload = () => {
localStorage.removeItem(AUTH_STORAGE_KEY);
};
export const updateStoredUser = (nextUser) => {
if (!nextUser || typeof nextUser !== 'object') {
return null;
}
const currentPayload = getStoredAuthPayload();
if (currentPayload?.user) {
const nextPayload = {
...currentPayload,
user: {
...currentPayload.user,
...nextUser,
},
};
setStoredAuthPayload(nextPayload);
return nextPayload;
}
setStoredAuthPayload(nextUser);
return nextUser;
};
export const clearAuthSession = ({ redirectToRoot = false } = {}) => {
clearStoredAuthPayload();
if (redirectToRoot && window.location.pathname !== '/') {
window.location.href = '/';
}
};

View File

@ -0,0 +1,18 @@
import apiClient from '../utils/apiClient';
const httpService = {
get(url, config) {
return apiClient.get(url, config);
},
post(url, data, config) {
return apiClient.post(url, data, config);
},
put(url, data, config) {
return apiClient.put(url, data, config);
},
delete(url, config) {
return apiClient.delete(url, config);
},
};
export default httpService;

View File

@ -1,4 +1,5 @@
import axios from 'axios';
import { clearAuthSession, getStoredToken } from '../services/authSessionService';
// 创建axios实例
const apiClient = axios.create();
@ -6,20 +7,9 @@ const apiClient = axios.create();
// 请求拦截器 - 自动添加Authorization头
apiClient.interceptors.request.use(
(config) => {
const savedUser = localStorage.getItem('iMeetingUser');
// 增加严格校验:排除 null, undefined 字符串以及空值
if (savedUser && savedUser !== "undefined" && savedUser !== "null") {
try {
const user = JSON.parse(savedUser);
// 确保解析出来的是对象且包含 token
if (user && typeof user === 'object' && user.token) {
config.headers.Authorization = `Bearer ${user.token}`;
}
} catch (error) {
console.error('Failed to parse user from localStorage:', error);
localStorage.removeItem('iMeetingUser');
}
const token = getStoredToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
@ -45,11 +35,7 @@ apiClient.interceptors.response.use(
},
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('iMeetingUser');
// 避免重复跳转
if (window.location.pathname !== '/') {
window.location.href = '/';
}
clearAuthSession({ redirectToRoot: true });
}
return Promise.reject(error);
}