Compare commits

...

2 Commits

Author SHA1 Message Date
mula.liu 4715cd4a86 chore: release 1.1.0 2026-03-26 14:55:12 +08:00
mula.liu bbcc5466f0 修复ListTable行高对齐问题
- 修改.list-table-scroll为overflow-y: scroll,确保显示滚动条轨道
- 修改.list-table-actions为overflow-y: scroll,与数据列保持一致
- 两个表格现在会同时显示滚动条,保持行高完全对齐

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-12 15:34:12 +08:00
161 changed files with 17426 additions and 23230 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@ -0,0 +1,27 @@
# UI Modernization & Standardization Plan
## Stage 1: Foundation (Global Theme & Layout)
**Goal**: Establish a consistent visual base and layout structure.
**Success Criteria**:
- Global `ConfigProvider` with a modern theme (v5 tokens).
- A reusable `MainLayout` component replacing duplicated header/sidebar logic.
- Unified navigation experience across Admin and User dashboards.
**Status**: Complete
## Stage 2: Component Standardization
**Goal**: Replace custom, inconsistent components with Ant Design standards.
**Success Criteria**:
- `ListTable` and `DataTable` replaced by `antd.Table`. (Complete)
- `FormModal` and `ConfirmDialog` replaced by `antd.Modal`. (Complete)
- `Toast` and custom notifications replaced by `antd.message` and `antd.notification`. (Complete)
- Custom `Dropdown`, `Breadcrumb`, and `PageLoading` replaced by `antd` equivalents. (Complete)
**Status**: Complete
## Stage 3: Visual Polish & UX
**Goal**: Enhance design details and interactive experience.
**Success Criteria**:
- Modernized dashboard cards with subtle shadows and transitions. (Complete)
- Standardized `Empty` states and `Skeleton` loaders. (Complete)
- Responsive design improvements for various screen sizes. (Complete)
- Clean up redundant CSS files and components. (In Progress)
**Status**: In Progress

View File

@ -1,11 +1,105 @@
from fastapi import APIRouter, Depends
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
from app.models.models import (
MenuInfo,
MenuListResponse,
RolePermissionInfo,
UpdateRolePermissionsRequest,
RoleInfo,
CreateMenuRequest,
UpdateMenuRequest,
CreateRoleRequest,
UpdateRoleRequest,
)
from typing import List
import time
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)
# ========== 菜单权限管理接口 ==========
@ -21,8 +115,12 @@ async def get_all_menus(current_user=Depends(get_current_admin_user)):
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 menus
ORDER BY sort_order ASC, menu_id ASC
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()
@ -37,6 +135,171 @@ async def get_all_menus(current_user=Depends(get_current_admin_user)):
except Exception as e:
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
@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)}")
@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)}")
@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)
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)):
"""
@ -51,8 +314,8 @@ async def get_all_roles(current_user=Depends(get_current_admin_user)):
query = """
SELECT r.role_id, r.role_name, r.created_at,
COUNT(rmp.menu_id) as menu_count
FROM roles r
LEFT JOIN role_menu_permissions rmp ON r.role_id = rmp.role_id
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
"""
@ -67,6 +330,146 @@ async def get_all_roles(current_user=Depends(get_current_admin_user)):
except Exception as e:
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
@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)}")
@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)}")
@router.get("/admin/roles/{role_id}/users")
async def get_role_users(
role_id: int,
page: int = Query(1, ge=1),
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)}")
@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()
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)):
"""
@ -78,16 +481,18 @@ async def get_role_permissions(role_id: int, current_user=Depends(get_current_ad
cursor = connection.cursor(dictionary=True)
# 检查角色是否存在
cursor.execute("SELECT role_id, role_name FROM roles WHERE role_id = %s", (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 menu_id
FROM role_menu_permissions
WHERE role_id = %s
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()
@ -121,38 +526,45 @@ async def update_role_permissions(
cursor = connection.cursor(dictionary=True)
# 检查角色是否存在
cursor.execute("SELECT role_id FROM roles WHERE role_id = %s", (role_id,))
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是否有效
if request.menu_ids:
format_strings = ','.join(['%s'] * len(request.menu_ids))
cursor.execute(
f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})",
tuple(request.menu_ids)
)
valid_count = cursor.fetchone()['count']
if valid_count != len(request.menu_ids):
return create_api_response(code="400", message="包含无效的菜单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 role_menu_permissions WHERE role_id = %s", (role_id,))
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,))
# 插入新的权限
if request.menu_ids:
insert_values = [(role_id, menu_id) for menu_id in request.menu_ids]
if normalized_menu_ids:
insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids]
cursor.executemany(
"INSERT INTO role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
"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(request.menu_ids)}
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)}")
@ -164,21 +576,68 @@ 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 DISTINCT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
m.menu_url, m.menu_type, m.sort_order
FROM menus m
JOIN role_menu_permissions rmp ON m.menu_id = rmp.menu_id
WHERE rmp.role_id = %s AND m.is_active = 1
ORDER BY m.sort_order ASC
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, (current_user['role_id'],))
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="获取用户菜单成功",
@ -186,4 +645,3 @@ async def get_user_menus(current_user=Depends(get_current_user)):
)
except Exception as e:
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")

View File

@ -47,6 +47,8 @@ def _get_online_user_count(redis_client) -> int:
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])
@ -56,6 +58,18 @@ def _get_online_user_count(redis_client) -> int:
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
@ -90,42 +104,57 @@ async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
# 1. 用户统计
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_users = 0
today_new_users = 0
cursor.execute("SELECT COUNT(*) as total FROM users")
total_users = cursor.fetchone()['total']
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 users WHERE created_at >= %s",
(today_start,)
)
today_new_users = cursor.fetchone()['count']
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. 会议统计
cursor.execute("SELECT COUNT(*) as total FROM meetings")
total_meetings = cursor.fetchone()['total']
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()['count']
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()
# 转录任务
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
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}
# 总结任务
cursor.execute(f"{task_stats_query} FROM llm_tasks")
summary_stats = cursor.fetchone() or {'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}
# 知识库任务
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
kb_stats = cursor.fetchone() or {'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()
@ -180,6 +209,8 @@ async def get_online_users(current_user=Depends(get_current_admin_user)):
# 提取用户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])
@ -195,7 +226,7 @@ async def get_online_users(current_user=Depends(get_current_admin_user)):
online_users_list = []
for user_id, tokens in user_tokens.items():
cursor.execute(
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
(user_id,)
)
user = cursor.fetchone()
@ -275,7 +306,7 @@ async def monitor_tasks(
u.username as creator_name
FROM transcript_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN users u ON m.user_id = u.user_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
@ -292,14 +323,14 @@ async def monitor_tasks(
t.meeting_id,
m.title as meeting_title,
t.status,
NULL as progress,
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 users u ON m.user_id = u.user_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
@ -323,7 +354,7 @@ async def monitor_tasks(
u.username as creator_name
FROM knowledge_base_tasks t
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
LEFT JOIN users u ON k.creator_id = u.user_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
@ -416,7 +447,7 @@ async def get_user_stats(current_user=Depends(get_current_admin_user)):
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 users u
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

View File

@ -0,0 +1,835 @@
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
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):
param_key: str
param_name: str
param_value: str
value_type: str = "string"
category: str = "system"
description: str | None = None
is_active: bool = True
class LLMModelUpsertRequest(BaseModel):
model_code: str
model_name: str
provider: str | None = None
endpoint_url: str | None = None
api_key: str | None = None
llm_model_name: str
llm_timeout: int = 120
llm_temperature: float = 0.7
llm_top_p: float = 0.9
llm_max_tokens: int = 2048
llm_system_prompt: str | None = None
description: str | None = None
is_active: bool = True
is_default: bool = False
class AudioModelUpsertRequest(BaseModel):
model_code: str
model_name: str
audio_scene: str # asr / voiceprint
provider: str | None = None
endpoint_url: str | None = None
api_key: str | None = None
extra_config: dict[str, Any] | None = None
asr_model_name: str | None = None
asr_vocabulary_id: str | None = None
hot_word_group_id: int | None = None
asr_speaker_count: int | None = None
asr_language_hints: str | None = None
asr_disfluency_removal_enabled: bool | None = None
asr_diarization_enabled: bool | None = None
vp_template_text: str | None = None
vp_duration_seconds: int | None = None
vp_sample_rate: int | None = None
vp_channels: int | None = None
vp_max_size_bytes: int | None = None
description: str | None = None
is_active: bool = True
is_default: bool = False
class LLMModelTestRequest(LLMModelUpsertRequest):
test_prompt: str | None = None
class AudioModelTestRequest(AudioModelUpsertRequest):
test_file_url: str | None = None
@router.get("/admin/parameters")
async def list_parameters(
category: str | None = Query(None),
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)}")
@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)}")
@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()
return create_api_response(code="200", message="创建参数成功")
except Exception as e:
return create_api_response(code="500", message=f"创建参数失败: {str(e)}")
@router.put("/admin/parameters/{param_key}")
async def update_parameter(
param_key: str,
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()
return create_api_response(code="200", message="更新参数成功")
except Exception as e:
return create_api_response(code="500", message=f"更新参数失败: {str(e)}")
@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()
return create_api_response(code="200", message="删除参数成功")
except Exception as e:
return create_api_response(code="500", message=f"删除参数失败: {str(e)}")
@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)}")
@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)}")
@router.put("/admin/model-configs/llm/{model_code}")
async def update_llm_model_config(
model_code: str,
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)}")
@router.get("/admin/model-configs/audio")
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)}")
@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)}")
@router.put("/admin/model-configs/audio/{model_code}")
async def update_audio_model_config(
model_code: str,
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)}")
@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)}")
@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)}")
@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)}")
@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)}")
@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)}")

View File

@ -7,7 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.auth import get_current_user
from app.core.database import get_db_connection
from app.models.models import LoginRequest, LoginResponse
from app.models.models import LoginRequest, LoginResponse, UserInfo
from app.services.jwt_service import jwt_service
from app.core.response import create_api_response
@ -23,7 +23,21 @@ def login(request_body: LoginRequest, request: Request):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = "SELECT user_id, username, caption, avatar_url, email, password_hash, role_id FROM users WHERE username = %s"
query = """
SELECT
u.user_id,
u.username,
u.caption,
u.avatar_url,
u.email,
u.password_hash,
u.role_id,
u.created_at,
COALESCE(r.role_name, '普通用户') AS role_name
FROM sys_users u
LEFT JOIN sys_roles r ON r.role_id = u.role_id
WHERE u.username = %s
"""
cursor.execute(query, (request_body.username,))
user = cursor.fetchone()
@ -67,19 +81,23 @@ def login(request_body: LoginRequest, request: Request):
print(f"Failed to log user login: {e}")
login_response_data = LoginResponse(
user_id=user['user_id'],
username=user['username'],
caption=user['caption'],
avatar_url=user['avatar_url'],
email=user['email'],
token=token,
role_id=user['role_id']
user=UserInfo(
user_id=user['user_id'],
username=user['username'],
caption=user['caption'],
email=user.get('email'),
role_id=user['role_id'],
role_name=user.get('role_name') or '普通用户',
avatar_url=user.get('avatar_url'),
created_at=user['created_at']
)
)
return create_api_response(
code="200",
message="登录成功",
data=login_response_data.dict()
data=login_response_data.model_dump()
)
@router.post("/auth/logout")

View File

@ -22,7 +22,8 @@ async def get_client_downloads(
platform_code: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = 1,
size: int = 50
size: int = 50,
current_user: dict = Depends(get_current_admin_user)
):
"""
获取客户端下载列表管理后台接口
@ -102,7 +103,7 @@ async def get_latest_clients():
query = """
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
FROM client_downloads cd
LEFT JOIN dict_data dd ON cd.platform_code = dd.dict_code
LEFT JOIN sys_dict_data dd ON cd.platform_code = dd.dict_code
AND dd.dict_type = 'client_platform'
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code

View File

@ -60,7 +60,7 @@ async def get_dict_types():
query = """
SELECT DISTINCT dict_type
FROM dict_data
FROM sys_dict_data
WHERE status = 1
ORDER BY dict_type
"""
@ -99,7 +99,7 @@ async def get_dict_data_by_type(dict_type: str, parent_code: Optional[str] = Non
SELECT id, dict_type, dict_code, parent_code, tree_path,
label_cn, label_en, sort_order, extension_attr,
is_default, status, create_time
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND status = 1
"""
params = [dict_type]
@ -187,7 +187,7 @@ async def get_dict_data_by_code(dict_type: str, dict_code: str):
SELECT id, dict_type, dict_code, parent_code, tree_path,
label_cn, label_en, sort_order, extension_attr,
is_default, status, create_time, update_time
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND dict_code = %s
LIMIT 1
"""
@ -246,7 +246,7 @@ async def create_dict_data(
# 检查是否已存在
cursor.execute(
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
"SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s",
(request.dict_type, request.dict_code)
)
if cursor.fetchone():
@ -258,7 +258,7 @@ async def create_dict_data(
# 插入数据
query = """
INSERT INTO dict_data (
INSERT INTO sys_dict_data (
dict_type, dict_code, parent_code, label_cn, label_en,
sort_order, extension_attr, is_default, status
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
@ -319,7 +319,7 @@ async def update_dict_data(
cursor = conn.cursor(dictionary=True)
# 检查是否存在
cursor.execute("SELECT * FROM dict_data WHERE id = %s", (id,))
cursor.execute("SELECT * FROM sys_dict_data WHERE id = %s", (id,))
existing = cursor.fetchone()
if not existing:
cursor.close()
@ -369,7 +369,7 @@ async def update_dict_data(
# 执行更新
update_query = f"""
UPDATE dict_data
UPDATE sys_dict_data
SET {', '.join(update_fields)}
WHERE id = %s
"""
@ -404,7 +404,7 @@ async def delete_dict_data(
cursor = conn.cursor(dictionary=True)
# 检查是否存在
cursor.execute("SELECT dict_code FROM dict_data WHERE id = %s", (id,))
cursor.execute("SELECT dict_code FROM sys_dict_data WHERE id = %s", (id,))
existing = cursor.fetchone()
if not existing:
cursor.close()
@ -415,7 +415,7 @@ async def delete_dict_data(
# 检查是否有子节点
cursor.execute(
"SELECT COUNT(*) as count FROM dict_data WHERE parent_code = %s",
"SELECT COUNT(*) as count FROM sys_dict_data WHERE parent_code = %s",
(existing['dict_code'],)
)
if cursor.fetchone()['count'] > 0:
@ -438,7 +438,7 @@ async def delete_dict_data(
)
# 执行删除
cursor.execute("DELETE FROM dict_data WHERE id = %s", (id,))
cursor.execute("DELETE FROM sys_dict_data WHERE id = %s", (id,))
conn.commit()
cursor.close()

View File

@ -76,7 +76,7 @@ async def get_external_apps(
list_query = f"""
SELECT ea.*, u.username as creator_username
FROM external_apps ea
LEFT JOIN users u ON ea.created_by = u.user_id
LEFT JOIN sys_users u ON ea.created_by = u.user_id
WHERE {where_clause}
ORDER BY ea.sort_order ASC, ea.created_at DESC
"""

View File

@ -6,7 +6,6 @@ from app.core.config import QWEN_API_KEY
from app.services.system_config_service import SystemConfigService
from pydantic import BaseModel
from typing import Optional, List
import json
import dashscope
from dashscope.audio.asr import VocabularyService
from datetime import datetime
@ -14,48 +13,68 @@ from http import HTTPStatus
router = APIRouter()
class HotWordItem(BaseModel):
id: int
text: str
weight: int
lang: str
status: int
create_time: datetime
update_time: datetime
class CreateHotWordRequest(BaseModel):
# ── Request Models ──────────────────────────────────────────
class CreateGroupRequest(BaseModel):
name: str
description: Optional[str] = None
status: int = 1
class UpdateGroupRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
class CreateItemRequest(BaseModel):
text: str
weight: int = 4
lang: str = "zh"
status: int = 1
class UpdateHotWordRequest(BaseModel):
class UpdateItemRequest(BaseModel):
text: Optional[str] = None
weight: Optional[int] = None
lang: Optional[str] = None
status: Optional[int] = None
@router.get("/admin/hot-words", response_model=dict)
async def list_hot_words(current_user: dict = Depends(get_current_admin_user)):
"""获取热词列表"""
# ── Hot-Word Group CRUD ─────────────────────────────────────
@router.get("/admin/hot-word-groups", response_model=dict)
async def list_groups(current_user: dict = Depends(get_current_admin_user)):
"""列表(含每组热词数量统计)"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC")
items = cursor.fetchall()
cursor.execute("""
SELECT g.*,
COUNT(i.id) AS item_count,
SUM(CASE WHEN i.status = 1 THEN 1 ELSE 0 END) AS enabled_item_count
FROM hot_word_group g
LEFT JOIN hot_word_item i ON i.group_id = g.id
GROUP BY g.id
ORDER BY g.update_time DESC
""")
groups = cursor.fetchall()
cursor.close()
return create_api_response(code="200", message="获取成功", data=items)
return create_api_response(code="200", message="获取成功", data=groups)
except Exception as e:
return create_api_response(code="500", message=f"获取失败: {str(e)}")
@router.post("/admin/hot-words", response_model=dict)
async def create_hot_word(request: CreateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
"""创建热词"""
@router.post("/admin/hot-word-groups", response_model=dict)
async def create_group(request: CreateGroupRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)"
cursor.execute(query, (request.text, request.weight, request.lang, request.status))
cursor.execute(
"INSERT INTO hot_word_group (name, description, status) VALUES (%s, %s, %s)",
(request.name, request.description, request.status),
)
new_id = cursor.lastrowid
conn.commit()
cursor.close()
@ -63,111 +82,209 @@ async def create_hot_word(request: CreateHotWordRequest, current_user: dict = De
except Exception as e:
return create_api_response(code="500", message=f"创建失败: {str(e)}")
@router.put("/admin/hot-words/{id}", response_model=dict)
async def update_hot_word(id: int, request: UpdateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
"""更新热词"""
@router.put("/admin/hot-word-groups/{id}", response_model=dict)
async def update_group(id: int, request: UpdateGroupRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
update_fields = []
params = []
if request.text is not None:
update_fields.append("text = %s")
params.append(request.text)
if request.weight is not None:
update_fields.append("weight = %s")
params.append(request.weight)
if request.lang is not None:
update_fields.append("lang = %s")
params.append(request.lang)
fields, params = [], []
if request.name is not None:
fields.append("name = %s"); params.append(request.name)
if request.description is not None:
fields.append("description = %s"); params.append(request.description)
if request.status is not None:
update_fields.append("status = %s")
params.append(request.status)
if not update_fields:
fields.append("status = %s"); params.append(request.status)
if not fields:
return create_api_response(code="400", message="无更新内容")
query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s"
params.append(id)
cursor.execute(query, tuple(params))
cursor.execute(f"UPDATE hot_word_group SET {', '.join(fields)} WHERE id = %s", tuple(params))
conn.commit()
cursor.close()
return create_api_response(code="200", message="更新成功")
except Exception as e:
return create_api_response(code="500", message=f"更新失败: {str(e)}")
@router.delete("/admin/hot-words/{id}", response_model=dict)
async def delete_hot_word(id: int, current_user: dict = Depends(get_current_admin_user)):
"""删除热词"""
@router.delete("/admin/hot-word-groups/{id}", response_model=dict)
async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)):
"""删除组(级联删除条目),同时清除关联的 audio_model_config"""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM hot_words WHERE id = %s", (id,))
# 清除引用该组的音频模型配置
cursor.execute(
"""
UPDATE audio_model_config
SET hot_word_group_id = NULL,
asr_vocabulary_id = NULL,
extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id')
WHERE hot_word_group_id = %s
""",
(id,),
)
cursor.execute("DELETE FROM hot_word_item WHERE group_id = %s", (id,))
cursor.execute("DELETE FROM hot_word_group WHERE id = %s", (id,))
conn.commit()
cursor.close()
return create_api_response(code="200", message="删除成功")
except Exception as e:
return create_api_response(code="500", message=f"删除失败: {str(e)}")
@router.post("/admin/hot-words/sync", response_model=dict)
async def sync_hot_words(current_user: dict = Depends(get_current_admin_user)):
"""同步热词到阿里云 DashScope"""
@router.post("/admin/hot-word-groups/{id}/sync", response_model=dict)
async def sync_group(id: int, current_user: dict = Depends(get_current_admin_user)):
"""同步指定组到阿里云 DashScope"""
try:
dashscope.api_key = QWEN_API_KEY
# 1. 获取所有启用的热词
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT text, weight, lang FROM hot_words WHERE status = 1")
hot_words = cursor.fetchall()
cursor.close()
# 2. 获取现有的 vocabulary_id
existing_vocab_id = SystemConfigService.get_asr_vocabulary_id()
# 获取组信息
cursor.execute("SELECT * FROM hot_word_group WHERE id = %s", (id,))
group = cursor.fetchone()
if not group:
return create_api_response(code="404", message="热词组不存在")
# 构建热词列表
vocabulary_list = [{"text": hw['text'], "weight": hw['weight'], "lang": hw['lang']} for hw in hot_words]
# 获取该组下启用的热词
cursor.execute(
"SELECT text, weight, lang FROM hot_word_item WHERE group_id = %s AND status = 1",
(id,),
)
items = cursor.fetchall()
if not items:
return create_api_response(code="400", message="该组没有启用的热词可同步")
if not vocabulary_list:
return create_api_response(code="400", message="没有启用的热词可同步")
vocabulary_list = [{"text": it["text"], "weight": it["weight"], "lang": it["lang"]} for it in items]
# ASR 模型名(同步时需要)
asr_model_name = SystemConfigService.get_config_attribute('audio_model', 'model', 'paraformer-v2')
existing_vocab_id = group.get("vocabulary_id")
# 3. 调用阿里云 API
service = VocabularyService()
vocab_id = existing_vocab_id
try:
if existing_vocab_id:
# 尝试更新现有的热词表
try:
service.update_vocabulary(
vocabulary_id=existing_vocab_id,
vocabulary=vocabulary_list
vocabulary=vocabulary_list,
)
# 更新成功保持原有ID
except Exception as update_error:
# 如果更新失败(如资源不存在),尝试创建新的
print(f"Update vocabulary failed: {update_error}, trying to create new one.")
existing_vocab_id = None # 重置,触发创建逻辑
except Exception:
existing_vocab_id = None # 更新失败,重建
if not existing_vocab_id:
# 创建新的热词表
vocab_id = service.create_vocabulary(
prefix='imeeting',
target_model='paraformer-v2',
vocabulary=vocabulary_list
prefix="imeeting",
target_model=asr_model_name,
vocabulary=vocabulary_list,
)
except Exception as api_error:
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
# 4. 更新数据库中的 vocabulary_id
if vocab_id:
SystemConfigService.set_config(
SystemConfigService.ASR_VOCABULARY_ID,
vocab_id
)
# 回写 vocabulary_id 到热词组
cursor.execute(
"UPDATE hot_word_group SET vocabulary_id = %s, last_sync_time = NOW() WHERE id = %s",
(vocab_id, id),
)
return create_api_response(code="200", message="同步成功", data={"vocabulary_id": vocab_id})
# 更新关联该组的所有 audio_model_config.asr_vocabulary_id
cursor.execute(
"""
UPDATE audio_model_config
SET asr_vocabulary_id = %s,
extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s)
WHERE hot_word_group_id = %s
""",
(vocab_id, vocab_id, id),
)
conn.commit()
cursor.close()
return create_api_response(
code="200",
message="同步成功",
data={"vocabulary_id": vocab_id, "synced_count": len(vocabulary_list)},
)
except Exception as e:
return create_api_response(code="500", message=f"同步异常: {str(e)}")
# ── Hot-Word Item CRUD ──────────────────────────────────────
@router.get("/admin/hot-word-groups/{group_id}/items", response_model=dict)
async def list_items(group_id: int, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT * FROM hot_word_item WHERE group_id = %s ORDER BY update_time DESC",
(group_id,),
)
items = cursor.fetchall()
cursor.close()
return create_api_response(code="200", message="获取成功", data=items)
except Exception as e:
return create_api_response(code="500", message=f"获取失败: {str(e)}")
@router.post("/admin/hot-word-groups/{group_id}/items", response_model=dict)
async def create_item(group_id: int, request: CreateItemRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO hot_word_item (group_id, text, weight, lang, status) VALUES (%s, %s, %s, %s, %s)",
(group_id, request.text, request.weight, request.lang, request.status),
)
new_id = cursor.lastrowid
conn.commit()
cursor.close()
return create_api_response(code="200", message="创建成功", data={"id": new_id})
except Exception as e:
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="该组内已存在相同热词")
return create_api_response(code="500", message=f"创建失败: {str(e)}")
@router.put("/admin/hot-word-items/{id}", response_model=dict)
async def update_item(id: int, request: UpdateItemRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
fields, params = [], []
if request.text is not None:
fields.append("text = %s"); params.append(request.text)
if request.weight is not None:
fields.append("weight = %s"); params.append(request.weight)
if request.lang is not None:
fields.append("lang = %s"); params.append(request.lang)
if request.status is not None:
fields.append("status = %s"); params.append(request.status)
if not fields:
return create_api_response(code="400", message="无更新内容")
params.append(id)
cursor.execute(f"UPDATE hot_word_item SET {', '.join(fields)} WHERE id = %s", tuple(params))
conn.commit()
cursor.close()
return create_api_response(code="200", message="更新成功")
except Exception as e:
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="该组内已存在相同热词")
return create_api_response(code="500", message=f"更新失败: {str(e)}")
@router.delete("/admin/hot-word-items/{id}", response_model=dict)
async def delete_item(id: int, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM hot_word_item WHERE id = %s", (id,))
conn.commit()
cursor.close()
return create_api_response(code="200", message="删除成功")
except Exception as e:
return create_api_response(code="500", message=f"删除失败: {str(e)}")

View File

@ -41,7 +41,7 @@ def get_knowledge_bases(
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
base_query = "FROM knowledge_bases kb JOIN users u ON kb.creator_id = u.user_id"
base_query = "FROM knowledge_bases kb JOIN sys_users u ON kb.creator_id = u.user_id"
where_clauses = []
params = []
@ -156,7 +156,7 @@ def get_knowledge_base_detail(
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at,
u.username as created_by_name
FROM knowledge_bases kb
JOIN users u ON kb.creator_id = u.user_id
JOIN sys_users u ON kb.creator_id = u.user_id
WHERE kb.kb_id = %s
"""
cursor.execute(query, (kb_id,))

View File

@ -29,6 +29,7 @@ transcription_service = AsyncTranscriptionService()
class GenerateSummaryRequest(BaseModel):
user_prompt: Optional[str] = ""
prompt_id: Optional[int] = None # 提示词模版ID如果不指定则使用默认模版
model_code: Optional[str] = None # LLM模型编码如果不指定则使用默认模型
def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]:
@ -198,7 +199,7 @@ def get_meetings(
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, m.access_password,
m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path
FROM meetings m
JOIN users u ON m.user_id = u.user_id
JOIN sys_users u ON m.user_id = u.user_id
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
'''
@ -238,16 +239,20 @@ def get_meetings(
meeting_list = []
for meeting in meetings:
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
cursor.execute(attendees_query, (meeting['meeting_id'],))
attendees_data = cursor.fetchall()
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
tags_list = _process_tags(cursor, meeting.get('tags'))
progress_info = _get_meeting_overall_status(meeting['meeting_id'])
meeting_list.append(Meeting(
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
summary=meeting['summary'], created_at=meeting['created_at'], audio_file_path=meeting['audio_file_path'],
attendees=attendees, creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags_list,
access_password=meeting.get('access_password')
access_password=meeting.get('access_password'),
overall_status=progress_info.get('overall_status'),
overall_progress=progress_info.get('overall_progress'),
current_stage=progress_info.get('current_stage'),
))
return create_api_response(code="200", message="获取会议列表成功", data={
@ -318,7 +323,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
af.file_path as audio_file_path, af.duration as audio_duration,
p.name as prompt_name, m.access_password
FROM meetings m
JOIN users u ON m.user_id = u.user_id
JOIN sys_users u ON m.user_id = u.user_id
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
LEFT JOIN prompts p ON m.prompt_id = p.id
WHERE m.meeting_id = %s
@ -327,7 +332,7 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren
meeting = cursor.fetchone()
if not meeting:
return create_api_response(code="404", message="Meeting not found")
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
cursor.execute(attendees_query, (meeting['meeting_id'],))
attendees_data = cursor.fetchall()
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
@ -383,8 +388,14 @@ def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = D
meeting_query = 'INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) VALUES (%s, %s, %s, %s, %s, %s)'
cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat()))
meeting_id = cursor.lastrowid
for attendee_id in meeting_request.attendee_ids:
cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_id))
# 根据 caption 查找用户ID并插入参会人
if meeting_request.attendees:
captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()]
if captions:
placeholders = ','.join(['%s'] * len(captions))
cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions)
for row in cursor.fetchall():
cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id']))
connection.commit()
return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id})
@ -404,9 +415,18 @@ def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, curre
update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s WHERE meeting_id = %s'
cursor.execute(update_query, (meeting_request.title, meeting_request.meeting_time, meeting_request.summary, meeting_request.tags, meeting_id))
cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,))
for attendee_id in meeting_request.attendee_ids:
cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_id))
# 根据 caption 查找用户ID并插入参会人
if meeting_request.attendees:
captions = [c.strip() for c in meeting_request.attendees.split(',') if c.strip()]
if captions:
placeholders = ','.join(['%s'] * len(captions))
cursor.execute(f'SELECT user_id FROM sys_users WHERE caption IN ({placeholders})', captions)
for row in cursor.fetchall():
cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, row['user_id']))
connection.commit()
# 同步导出总结MD文件
if meeting_request.summary:
async_meeting_service._export_summary_md(meeting_id, meeting_request.summary)
return create_api_response(code="200", message="Meeting updated successfully")
@router.delete("/meetings/{meeting_id}")
@ -435,14 +455,14 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre
SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags,
m.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path,
m.access_password
FROM meetings m JOIN users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
FROM meetings m JOIN sys_users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
WHERE m.meeting_id = %s
'''
cursor.execute(query, (meeting_id,))
meeting = cursor.fetchone()
if not meeting:
return create_api_response(code="404", message="Meeting not found")
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
cursor.execute(attendees_query, (meeting['meeting_id'],))
attendees_data = cursor.fetchall()
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
@ -861,8 +881,8 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="Meeting not found")
# 传递 prompt_id 参数给服务层
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id)
# 传递 prompt_id 和 model_code 参数给服务层
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id, request.model_code)
background_tasks.add_task(async_meeting_service._process_task, task_id)
return create_api_response(code="200", message="Summary generation task has been accepted.", data={
"task_id": task_id, "status": "pending", "meeting_id": meeting_id
@ -885,6 +905,19 @@ def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_curr
except Exception as e:
return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(e)}")
@router.get("/llm-models/active")
def list_active_llm_models(current_user: dict = Depends(get_current_user)):
"""获取所有激活的LLM模型列表供普通用户选择"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"SELECT model_code, model_name, provider, is_default FROM llm_model_config WHERE is_active = 1 ORDER BY is_default DESC, model_code ASC"
)
models = cursor.fetchall()
return create_api_response(code="200", message="获取模型列表成功", data={"models": models})
except Exception as e:
return create_api_response(code="500", message=f"获取模型列表失败: {str(e)}")
@router.get("/meetings/{meeting_id}/navigation")
def get_meeting_navigation(
meeting_id: int,
@ -946,7 +979,7 @@ def get_meeting_navigation(
query = '''
SELECT m.meeting_id
FROM meetings m
JOIN users u ON m.user_id = u.user_id
JOIN sys_users u ON m.user_id = u.user_id
'''
if has_attendees_join:
@ -1012,7 +1045,7 @@ def get_meeting_preview_data(meeting_id: int):
m.user_id as creator_id, u.caption as creator_username,
p.name as prompt_name, m.access_password
FROM meetings m
JOIN users u ON m.user_id = u.user_id
JOIN sys_users u ON m.user_id = u.user_id
LEFT JOIN prompts p ON m.prompt_id = p.id
WHERE m.meeting_id = %s
'''
@ -1079,7 +1112,7 @@ def get_meeting_preview_data(meeting_id: int):
# 获取参会人员信息
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN sys_users u ON a.user_id = u.user_id WHERE a.meeting_id = %s'
cursor.execute(attendees_query, (meeting_id,))
attendees_data = cursor.fetchall()
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
@ -1227,4 +1260,3 @@ def verify_meeting_password(meeting_id: int, request: VerifyPasswordRequest):
code="500",
message=f"验证密码失败: {str(e)}"
)

View File

@ -1,240 +1,383 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import List, Optional
from typing import Optional, List
from app.core.auth import get_current_user
from app.core.database import get_db_connection
from app.core.response import create_api_response
from app.models.models import PromptCreate, PromptUpdate
router = APIRouter()
# Pydantic Models
class PromptIn(BaseModel):
name: str
task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK'
content: str
is_default: bool = False
is_active: bool = True
class PromptOut(PromptIn):
id: int
creator_id: int
created_at: str
class PromptConfigItem(BaseModel):
prompt_id: int
is_enabled: bool = True
sort_order: int = 0
class PromptConfigUpdateRequest(BaseModel):
items: List[PromptConfigItem]
def _is_admin(user: dict) -> bool:
return int(user.get("role_id") or 0) == 1
def _can_manage_prompt(current_user: dict, row: dict) -> bool:
if _is_admin(current_user):
return True
return int(row.get("creator_id") or 0) == int(current_user["user_id"]) and int(row.get("is_system") or 0) == 0
class PromptListResponse(BaseModel):
prompts: List[PromptOut]
total: int
@router.post("/prompts")
def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)):
"""Create a new prompt."""
def create_prompt(
prompt: PromptCreate,
current_user: dict = Depends(get_current_user),
):
"""Create a prompt template. Admin can create system prompts, others can only create personal prompts."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
if prompt.is_default:
is_admin = _is_admin(current_user)
requested_is_system = bool(getattr(prompt, "is_system", False))
is_system = 1 if (is_admin and requested_is_system) else 0
owner_user_id = current_user["user_id"]
cursor.execute(
"""
SELECT COUNT(*) as cnt
FROM prompts
WHERE task_type = %s
AND is_system = %s
AND creator_id = %s
""",
(prompt.task_type, is_system, owner_user_id),
)
count = (cursor.fetchone() or {}).get("cnt", 0)
is_default = 1 if count == 0 else (1 if prompt.is_default else 0)
if is_default:
cursor.execute(
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s",
(prompt.task_type,)
"""
UPDATE prompts
SET is_default = 0
WHERE task_type = %s
AND is_system = %s
AND creator_id = %s
""",
(prompt.task_type, is_system, owner_user_id),
)
cursor.execute(
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id)
VALUES (%s, %s, %s, %s, %s, %s)""",
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
prompt.is_active, current_user["user_id"])
"""
INSERT INTO prompts (name, task_type, content, `desc`, is_default, is_active, creator_id, is_system)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
prompt.name,
prompt.task_type,
prompt.content,
prompt.desc,
is_default,
1 if prompt.is_active else 0,
owner_user_id,
is_system,
),
)
prompt_id = cursor.lastrowid
connection.commit()
new_id = cursor.lastrowid
return create_api_response(
code="200",
message="提示词创建成功",
data={"id": new_id, **prompt.dict()}
)
return create_api_response(code="200", message="提示词模版创建成功", data={"id": prompt_id})
except Exception as e:
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="提示词名称已存在")
return create_api_response(code="500", message=f"创建提示词失败: {e}")
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prompts/active/{task_type}")
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
"""Get all active prompts for a specific task type."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""SELECT id, name, is_default
FROM prompts
WHERE task_type = %s AND is_active = TRUE
ORDER BY is_default DESC, created_at DESC""",
(task_type,)
)
prompts = cursor.fetchall()
return create_api_response(
code="200",
message="获取启用模版列表成功",
data={"prompts": prompts}
)
@router.get("/prompts")
def get_prompts(
task_type: Optional[str] = None,
page: int = 1,
size: int = 50,
current_user: dict = Depends(get_current_user)
size: int = 12,
keyword: Optional[str] = Query(None),
is_active: Optional[int] = Query(None),
scope: str = Query("mine"), # mine / system / all / accessible
current_user: dict = Depends(get_current_user),
):
"""Get a paginated list of prompts filtered by current user and optionally by task_type."""
"""Get paginated prompt cards. Normal users can only view their own prompts."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 构建 WHERE 条件
where_conditions = ["creator_id = %s"]
params = [current_user["user_id"]]
is_admin = _is_admin(current_user)
where_conditions = []
params = []
if scope == "all" and not is_admin:
scope = "accessible"
if scope == "system":
where_conditions.append("p.is_system = 1")
elif scope == "all":
where_conditions.append("(p.is_system = 1 OR p.creator_id = %s)")
params.append(current_user["user_id"])
elif scope == "accessible":
where_conditions.append("((p.is_system = 1 AND p.is_active = 1) OR (p.is_system = 0 AND p.creator_id = %s))")
params.append(current_user["user_id"])
else:
where_conditions.append("p.is_system = 0 AND p.creator_id = %s")
params.append(current_user["user_id"])
if task_type:
where_conditions.append("task_type = %s")
where_conditions.append("p.task_type = %s")
params.append(task_type)
where_clause = " AND ".join(where_conditions)
if keyword:
where_conditions.append("(p.name LIKE %s OR p.`desc` LIKE %s)")
like = f"%{keyword}%"
params.extend([like, like])
if is_active in (0, 1):
where_conditions.append("p.is_active = %s")
params.append(is_active)
# 获取总数
cursor.execute(
f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}",
tuple(params)
)
total = cursor.fetchone()['total']
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
# 获取分页数据
offset = (page - 1) * size
cursor.execute(f"SELECT COUNT(*) as total FROM prompts p WHERE {where_clause}", tuple(params))
total = (cursor.fetchone() or {}).get("total", 0)
offset = max(page - 1, 0) * size
cursor.execute(
f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
FROM prompts
WHERE {where_clause}
ORDER BY created_at DESC
LIMIT %s OFFSET %s""",
tuple(params + [size, offset])
f"""
SELECT p.id, p.name, p.task_type, p.content, p.`desc`, p.is_default, p.is_active,
p.creator_id, p.is_system, p.created_at,
u.caption AS creator_name
FROM prompts p
LEFT JOIN sys_users u ON u.user_id = p.creator_id
WHERE {where_clause}
ORDER BY p.is_system DESC, p.task_type ASC, p.is_default DESC, p.created_at DESC
LIMIT %s OFFSET %s
""",
tuple(params + [size, offset]),
)
prompts = cursor.fetchall()
rows = cursor.fetchall()
return create_api_response(
code="200",
message="获取提示词列表成功",
data={"prompts": prompts, "total": total}
data={"prompts": rows, "total": total, "page": page, "size": size},
)
@router.get("/prompts/{prompt_id}")
def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
"""Get a single prompt by its ID."""
@router.get("/prompts/active/{task_type}")
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
"""
Active prompts for task selection.
Includes system prompts + personal prompts, and applies user's prompt config ordering.
"""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
FROM prompts WHERE id = %s""",
(prompt_id,)
"""
SELECT p.id, p.name, p.`desc`, p.content, p.is_default, p.is_system, p.creator_id,
cfg.is_enabled, cfg.sort_order
FROM prompts p
LEFT JOIN prompt_config cfg
ON cfg.prompt_id = p.id
AND cfg.user_id = %s
AND cfg.task_type = %s
WHERE p.task_type = %s
AND p.is_active = 1
AND (p.is_system = 1 OR p.creator_id = %s)
ORDER BY
CASE WHEN cfg.is_enabled = 1 THEN 0 ELSE 1 END,
cfg.sort_order ASC,
p.is_default DESC,
p.created_at DESC
""",
(current_user["user_id"], task_type, task_type, current_user["user_id"]),
)
prompt = cursor.fetchone()
if not prompt:
return create_api_response(code="404", message="提示词不存在")
return create_api_response(code="200", message="获取提示词成功", data=prompt)
prompts = cursor.fetchall()
@router.put("/prompts/{prompt_id}")
def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)):
"""Update an existing prompt."""
print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}")
print(f"[UPDATE PROMPT] user_id={current_user['user_id']}")
print(f"[UPDATE PROMPT] data: name={prompt.name}, task_type={prompt.task_type}, content_len={len(prompt.content)}, is_default={prompt.is_default}, is_active={prompt.is_active}")
enabled = [x for x in prompts if x.get("is_enabled") == 1]
if enabled:
result = enabled
else:
result = prompts
return create_api_response(code="200", message="获取启用模版列表成功", data={"prompts": result})
@router.get("/prompts/config/{task_type}")
def get_prompt_config(task_type: str, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT id, name, task_type, content, `desc`, is_default, is_active, is_system, creator_id, created_at
FROM prompts
WHERE task_type = %s
AND is_active = 1
AND (is_system = 1 OR creator_id = %s)
ORDER BY is_system DESC, is_default DESC, created_at DESC
""",
(task_type, current_user["user_id"]),
)
available = cursor.fetchall()
cursor.execute(
"""
SELECT prompt_id, is_enabled, sort_order
FROM prompt_config
WHERE user_id = %s AND task_type = %s
ORDER BY sort_order ASC, config_id ASC
""",
(current_user["user_id"], task_type),
)
configs = cursor.fetchall()
selected_prompt_ids = [item["prompt_id"] for item in configs if item.get("is_enabled") == 1]
return create_api_response(
code="200",
message="获取提示词配置成功",
data={
"task_type": task_type,
"available_prompts": available,
"configs": configs,
"selected_prompt_ids": selected_prompt_ids,
},
)
@router.put("/prompts/config/{task_type}")
def update_prompt_config(
task_type: str,
request: PromptConfigUpdateRequest,
current_user: dict = Depends(get_current_user),
):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
# 先检查记录是否存在
cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,))
existing = cursor.fetchone()
print(f"[UPDATE PROMPT] existing record: {existing}")
if not existing:
print(f"[UPDATE PROMPT] Prompt {prompt_id} not found in database")
return create_api_response(code="404", message="提示词不存在")
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
if prompt.is_default:
print(f"[UPDATE PROMPT] Setting as default, clearing other defaults for task_type={prompt.task_type}")
requested_ids = [int(item.prompt_id) for item in request.items if item.is_enabled]
if requested_ids:
placeholders = ",".join(["%s"] * len(requested_ids))
cursor.execute(
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s",
(prompt.task_type, prompt_id)
f"""
SELECT id
FROM prompts
WHERE id IN ({placeholders})
AND task_type = %s
AND is_active = 1
AND (is_system = 1 OR creator_id = %s)
""",
tuple(requested_ids + [task_type, current_user["user_id"]]),
)
print(f"[UPDATE PROMPT] Cleared {cursor.rowcount} other default prompts")
valid_ids = {row["id"] for row in cursor.fetchall()}
invalid_ids = [pid for pid in requested_ids if pid not in valid_ids]
if invalid_ids:
raise HTTPException(status_code=400, detail=f"存在无效提示词ID: {invalid_ids}")
print(f"[UPDATE PROMPT] Executing UPDATE query")
cursor.execute(
"""UPDATE prompts
SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s
WHERE id = %s""",
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
prompt.is_active, prompt_id)
"DELETE FROM prompt_config WHERE user_id = %s AND task_type = %s",
(current_user["user_id"], task_type),
)
rows_affected = cursor.rowcount
print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)")
# 注意rowcount=0 不代表记录不存在,可能是所有字段值都相同
# 我们已经在上面确认了记录存在,所以这里直接提交即可
ordered = sorted(
[item for item in request.items if item.is_enabled],
key=lambda x: (x.sort_order, x.prompt_id),
)
for idx, item in enumerate(ordered):
cursor.execute(
"""
INSERT INTO prompt_config (user_id, task_type, prompt_id, is_enabled, sort_order)
VALUES (%s, %s, %s, 1, %s)
""",
(current_user["user_id"], task_type, int(item.prompt_id), idx + 1),
)
connection.commit()
print(f"[UPDATE PROMPT] Success! Committed changes")
return create_api_response(code="200", message="提示词更新成功")
return create_api_response(code="200", message="提示词配置保存成功")
except HTTPException:
connection.rollback()
raise
except Exception as e:
print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}")
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="提示词名称已存在")
return create_api_response(code="500", message=f"更新提示词失败: {e}")
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/prompts/{prompt_id}")
def update_prompt(prompt_id: int, prompt: PromptUpdate, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
cursor.execute("SELECT id, creator_id, task_type, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
existing = cursor.fetchone()
if not existing:
raise HTTPException(status_code=404, detail="模版不存在")
if not _can_manage_prompt(current_user, existing):
raise HTTPException(status_code=403, detail="无权修改此模版")
if prompt.is_default is False and existing["is_default"]:
raise HTTPException(status_code=400, detail="必须保留一个默认模版,请先设置其他模版为默认")
if prompt.is_system is not None and not _is_admin(current_user):
raise HTTPException(status_code=403, detail="普通用户不能修改系统提示词属性")
if prompt.is_default:
task_type = prompt.task_type or existing["task_type"]
cursor.execute(
"""
UPDATE prompts
SET is_default = 0
WHERE task_type = %s
AND is_system = %s
AND creator_id = %s
""",
(task_type, existing.get("is_system", 0), existing["creator_id"]),
)
if prompt.is_active is False:
raise HTTPException(status_code=400, detail="默认模版必须处于启用状态")
update_fields = []
params = []
prompt_data = prompt.dict(exclude_unset=True)
for field, value in prompt_data.items():
if field == "desc":
update_fields.append("`desc` = %s")
else:
update_fields.append(f"{field} = %s")
params.append(value)
if update_fields:
params.append(prompt_id)
cursor.execute(f"UPDATE prompts SET {', '.join(update_fields)} WHERE id = %s", tuple(params))
connection.commit()
return create_api_response(code="200", message="更新成功")
except HTTPException:
raise
except Exception as e:
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prompts/{prompt_id}")
def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
"""Delete a prompt. Only the creator can delete their own prompts."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 首先检查提示词是否存在以及是否属于当前用户
cursor.execute(
"SELECT creator_id FROM prompts WHERE id = %s",
(prompt_id,)
)
prompt = cursor.fetchone()
try:
cursor.execute("SELECT id, creator_id, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
existing = cursor.fetchone()
if not existing:
raise HTTPException(status_code=404, detail="模版不存在")
if not _can_manage_prompt(current_user, existing):
raise HTTPException(status_code=403, detail="无权删除此模版")
if existing["is_default"]:
raise HTTPException(status_code=400, detail="默认模版不允许删除,请先设置其他模版为默认")
if not prompt:
return create_api_response(code="404", message="提示词不存在")
if prompt['creator_id'] != current_user["user_id"]:
return create_api_response(code="403", message="无权删除其他用户的提示词")
# 检查是否有会议引用了该提示词
cursor.execute(
"SELECT COUNT(*) as count FROM meetings WHERE prompt_id = %s",
(prompt_id,)
)
meeting_count = cursor.fetchone()['count']
# 检查是否有知识库引用了该提示词
cursor.execute(
"SELECT COUNT(*) as count FROM knowledge_bases WHERE prompt_id = %s",
(prompt_id,)
)
kb_count = cursor.fetchone()['count']
# 如果有引用,不允许删除
if meeting_count > 0 or kb_count > 0:
references = []
if meeting_count > 0:
references.append(f"{meeting_count}个会议")
if kb_count > 0:
references.append(f"{kb_count}个知识库")
return create_api_response(
code="400",
message=f"无法删除:该提示词被{''.join(references)}引用",
data={
"meeting_count": meeting_count,
"kb_count": kb_count
}
)
# 删除提示词
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
connection.commit()
return create_api_response(code="200", message="提示词删除成功")
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
connection.commit()
return create_api_response(code="200", message="删除成功")
except HTTPException:
raise
except Exception as e:
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, UploadFile, File
from typing import Optional
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo, UserMcpInfo
from app.core.database import get_db_connection
from app.core.auth import get_current_user
from app.core.response import create_api_response
@ -13,6 +13,7 @@ import re
import os
import shutil
import uuid
import secrets
from pathlib import Path
router = APIRouter()
@ -25,6 +26,59 @@ def validate_email(email: str) -> bool:
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def _generate_mcp_bot_id() -> str:
return f"nexbot_{secrets.token_hex(8)}"
def _generate_mcp_bot_secret() -> str:
random_part = secrets.token_urlsafe(24).replace('-', '').replace('_', '')
return f"nxbotsec_{random_part}"
def _get_user_mcp_record(cursor, user_id: int):
cursor.execute(
"""
SELECT id, user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at
FROM sys_user_mcp
WHERE user_id = %s
""",
(user_id,),
)
return cursor.fetchone()
def _ensure_user_exists(cursor, user_id: int) -> bool:
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
return cursor.fetchone() is not None
def _serialize_user_mcp(record: dict) -> dict:
return UserMcpInfo(**record).dict()
def _ensure_user_mcp_record(connection, cursor, user_id: int):
record = _get_user_mcp_record(cursor, user_id)
if record:
return record
bot_id = _generate_mcp_bot_id()
while True:
cursor.execute("SELECT id FROM sys_user_mcp WHERE bot_id = %s", (bot_id,))
if not cursor.fetchone():
break
bot_id = _generate_mcp_bot_id()
cursor.execute(
"""
INSERT INTO sys_user_mcp (user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at)
VALUES (%s, %s, %s, 1, NULL, NOW(), NOW())
""",
(user_id, bot_id, _generate_mcp_bot_secret()),
)
connection.commit()
return _get_user_mcp_record(cursor, user_id)
@router.get("/roles")
def get_all_roles(current_user: dict = Depends(get_current_user)):
"""获取所有角色列表"""
@ -33,7 +87,7 @@ def get_all_roles(current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id, role_name FROM roles ORDER BY role_id")
cursor.execute("SELECT role_id, role_name FROM sys_roles ORDER BY role_id")
roles = cursor.fetchall()
return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles])
@ -48,14 +102,14 @@ def create_user(request: CreateUserRequest, current_user: dict = Depends(get_cur
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE username = %s", (request.username,))
cursor.execute("SELECT user_id FROM sys_users WHERE username = %s", (request.username,))
if cursor.fetchone():
return create_api_response(code="400", message="用户名已存在")
password = request.password if request.password else SystemConfigService.get_default_reset_password()
hashed_password = hash_password(password)
query = "INSERT INTO users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
query = "INSERT INTO sys_users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
created_at = datetime.datetime.utcnow()
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
connection.commit()
@ -74,13 +128,13 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM sys_users WHERE user_id = %s", (user_id,))
existing_user = cursor.fetchone()
if not existing_user:
return create_api_response(code="404", message="用户不存在")
if request.username and request.username != existing_user['username']:
cursor.execute("SELECT user_id FROM users WHERE username = %s AND user_id != %s", (request.username, user_id))
cursor.execute("SELECT user_id FROM sys_users WHERE username = %s AND user_id != %s", (request.username, user_id))
if cursor.fetchone():
return create_api_response(code="400", message="用户名已存在")
@ -97,14 +151,14 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
'role_id': target_role_id
}
query = "UPDATE users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
query = "UPDATE sys_users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id))
connection.commit()
cursor.execute('''
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.role_id
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.role_id
WHERE u.user_id = %s
''', (user_id,))
updated_user = cursor.fetchone()
@ -117,9 +171,7 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
avatar_url=updated_user['avatar_url'],
created_at=updated_user['created_at'],
role_id=updated_user['role_id'],
role_name=updated_user['role_name'],
meetings_created=0,
meetings_attended=0
role_name=updated_user['role_name'] or '普通用户'
)
return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict())
@ -131,11 +183,11 @@ def delete_user(user_id: int, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="用户不存在")
cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,))
cursor.execute("DELETE FROM sys_users WHERE user_id = %s", (user_id,))
connection.commit()
return create_api_response(code="200", message="用户删除成功")
@ -148,13 +200,13 @@ def reset_password(user_id: int, current_user: dict = Depends(get_current_user))
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="用户不存在")
hashed_password = hash_password(SystemConfigService.get_default_reset_password())
query = "UPDATE users SET password_hash = %s WHERE user_id = %s"
query = "UPDATE sys_users SET password_hash = %s WHERE user_id = %s"
cursor.execute(query, (hashed_password, user_id))
connection.commit()
@ -185,7 +237,7 @@ def get_all_users(
count_params.extend([search_pattern, search_pattern])
# 统计查询
count_query = "SELECT COUNT(*) as total FROM users u"
count_query = "SELECT COUNT(*) as total FROM sys_users u"
if where_conditions:
count_query += " WHERE " + " AND ".join(where_conditions)
@ -197,12 +249,16 @@ def get_all_users(
# 主查询
query = '''
SELECT
u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
r.role_name,
(SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created,
(SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended
FROM users u
LEFT JOIN roles r ON u.role_id = r.role_id
u.user_id,
u.username,
u.caption,
u.email,
u.avatar_url,
u.created_at,
u.role_id,
COALESCE(r.role_name, '普通用户') AS role_name
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.role_id
'''
query_params = []
@ -231,9 +287,10 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
cursor = connection.cursor(dictionary=True)
user_query = '''
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.role_id
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
COALESCE(r.role_name, '普通用户') AS role_name
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.role_id
WHERE u.user_id = %s
'''
cursor.execute(user_query, (user_id,))
@ -242,14 +299,6 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
if not user:
return create_api_response(code="404", message="用户不存在")
created_query = "SELECT COUNT(*) as count FROM meetings WHERE user_id = %s"
cursor.execute(created_query, (user_id,))
meetings_created = cursor.fetchone()['count']
attended_query = "SELECT COUNT(*) as count FROM attendees WHERE user_id = %s"
cursor.execute(attended_query, (user_id,))
meetings_attended = cursor.fetchone()['count']
user_info = UserInfo(
user_id=user['user_id'],
username=user['username'],
@ -258,9 +307,7 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
avatar_url=user['avatar_url'],
created_at=user['created_at'],
role_id=user['role_id'],
role_name=user['role_name'],
meetings_created=meetings_created,
meetings_attended=meetings_attended
role_name=user['role_name']
)
return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict())
@ -272,7 +319,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT password_hash FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT password_hash FROM sys_users WHERE user_id = %s", (user_id,))
user = cursor.fetchone()
if not user:
@ -283,7 +330,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
return create_api_response(code="400", message="旧密码错误")
new_password_hash = hash_password(request.new_password)
cursor.execute("UPDATE users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
cursor.execute("UPDATE sys_users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
connection.commit()
return create_api_response(code="200", message="密码修改成功")
@ -305,7 +352,7 @@ def upload_user_avatar(
return create_api_response(code="400", message="不支持的文件类型")
# Ensure upload directory exists: AVATAR_DIR / str(user_id)
user_avatar_dir = AVATAR_DIR / str(user_id)
user_avatar_dir = config_module.get_user_avatar_dir(user_id)
if not user_avatar_dir.exists():
os.makedirs(user_avatar_dir)
@ -321,13 +368,57 @@ def upload_user_avatar(
# AVATAR_DIR is uploads/user/avatar
# file path is uploads/user/avatar/{user_id}/{filename}
# URL should be /uploads/user/avatar/{user_id}/{filename}
avatar_url = f"/uploads/user/avatar/{user_id}/{unique_filename}"
avatar_url = f"/uploads/user/{user_id}/avatar/{unique_filename}"
# Update database
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("UPDATE users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
cursor.execute("UPDATE sys_users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
connection.commit()
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
@router.get("/users/{user_id}/mcp-config")
def get_user_mcp_config(user_id: int, current_user: dict = Depends(get_current_user)):
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
return create_api_response(code="403", message="没有权限查看该用户的MCP配置")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
if not _ensure_user_exists(cursor, user_id):
return create_api_response(code="404", message="用户不存在")
record = _ensure_user_mcp_record(connection, cursor, user_id)
return create_api_response(code="200", message="获取MCP配置成功", data=_serialize_user_mcp(record))
@router.post("/users/{user_id}/mcp-config/regenerate")
def regenerate_user_mcp_secret(user_id: int, current_user: dict = Depends(get_current_user)):
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
return create_api_response(code="403", message="没有权限更新该用户的MCP配置")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
if not _ensure_user_exists(cursor, user_id):
return create_api_response(code="404", message="用户不存在")
record = _get_user_mcp_record(cursor, user_id)
if not record:
record = _ensure_user_mcp_record(connection, cursor, user_id)
else:
cursor.execute(
"""
UPDATE sys_user_mcp
SET bot_secret = %s, status = 1, updated_at = NOW()
WHERE user_id = %s
""",
(_generate_mcp_bot_secret(), user_id),
)
connection.commit()
record = _get_user_mcp_record(cursor, user_id)
return create_api_response(code="200", message="MCP Secret 已重新生成", data=_serialize_user_mcp(record))

View File

@ -89,7 +89,7 @@ async def upload_voiceprint(
try:
# 确保用户目录存在
user_voiceprint_dir = config_module.VOICEPRINT_DIR / str(user_id)
user_voiceprint_dir = config_module.get_user_voiceprint_dir(user_id)
user_voiceprint_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名:时间戳.wav

View File

@ -24,7 +24,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
(user_id,)
)
user = cursor.fetchone()
@ -67,7 +67,7 @@ def get_optional_current_user(request: Request) -> Optional[dict]:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"SELECT user_id, username, caption, email FROM users WHERE user_id = %s",
"SELECT user_id, username, caption, email FROM sys_users WHERE user_id = %s",
(user_id,)
)
return cursor.fetchone()

View File

@ -16,8 +16,22 @@ MARKDOWN_DIR = UPLOAD_DIR / "markdown"
CLIENT_DIR = UPLOAD_DIR / "clients"
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
USER_DIR = UPLOAD_DIR / "user"
VOICEPRINT_DIR = USER_DIR / "voiceprint"
AVATAR_DIR = USER_DIR / "avatar"
LEGACY_VOICEPRINT_DIR = USER_DIR / "voiceprint"
LEGACY_AVATAR_DIR = USER_DIR / "avatar"
VOICEPRINT_DIR = USER_DIR
AVATAR_DIR = USER_DIR
def get_user_data_dir(user_id: int | str) -> Path:
return USER_DIR / str(user_id)
def get_user_voiceprint_dir(user_id: int | str) -> Path:
return get_user_data_dir(user_id) / "voiceprint"
def get_user_avatar_dir(user_id: int | str) -> Path:
return get_user_data_dir(user_id) / "avatar"
# 文件上传配置
ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"}
@ -35,8 +49,8 @@ MARKDOWN_DIR.mkdir(exist_ok=True)
CLIENT_DIR.mkdir(exist_ok=True)
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
USER_DIR.mkdir(exist_ok=True)
VOICEPRINT_DIR.mkdir(exist_ok=True)
AVATAR_DIR.mkdir(exist_ok=True)
LEGACY_VOICEPRINT_DIR.mkdir(exist_ok=True)
LEGACY_AVATAR_DIR.mkdir(exist_ok=True)
# 数据库配置

View File

@ -15,7 +15,25 @@ 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, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps, terminals
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(
@ -49,6 +67,7 @@ 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"])

View File

@ -1,20 +1,12 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, Union, List
from pydantic import BaseModel, Field, EmailStr
from typing import List, Optional, Any, Dict, Union
import datetime
# 认证相关模型
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
user_id: int
username: str
caption: str
email: EmailStr
avatar_url: Optional[str] = None
token: str
role_id: int
class RoleInfo(BaseModel):
role_id: int
role_name: str
@ -23,102 +15,105 @@ class UserInfo(BaseModel):
user_id: int
username: str
caption: str
email: EmailStr
avatar_url: Optional[str] = None
created_at: datetime.datetime
meetings_created: int
meetings_attended: int
email: Optional[str] = None
role_id: int
role_name: str
avatar_url: Optional[str] = None
created_at: datetime.datetime
class LoginResponse(BaseModel):
token: str
user: UserInfo
class UserListResponse(BaseModel):
users: list[UserInfo]
users: List[UserInfo]
total: int
class CreateUserRequest(BaseModel):
username: str
password: Optional[str] = None
caption: str
email: EmailStr
avatar_url: Optional[str] = None
role_id: int
email: Optional[str] = None
role_id: int = 2
class UpdateUserRequest(BaseModel):
username: Optional[str] = None
caption: Optional[str] = None
email: Optional[str] = None
avatar_url: Optional[str] = None
role_id: Optional[int] = None
avatar_url: Optional[str] = None
class UserLog(BaseModel):
log_id: int
user_id: int
action_type: str
username: str
action: str
details: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
metadata: Optional[dict] = None
created_at: datetime.datetime
# 会议相关模型
class AttendeeInfo(BaseModel):
user_id: int
user_id: Optional[int] = None
username: Optional[str] = None
caption: str
class Tag(BaseModel):
id: int
name: str
color: str
class TranscriptionTaskStatus(BaseModel):
task_id: str
status: str # 'pending', 'processing', 'completed', 'failed'
progress: int # 0-100
meeting_id: int
created_at: Optional[str] = None
updated_at: Optional[str] = None
completed_at: Optional[str] = None
error_message: Optional[str] = None
status: str
progress: int
message: Optional[str] = None
class Meeting(BaseModel):
meeting_id: int
title: str
meeting_time: Optional[datetime.datetime]
summary: Optional[str]
created_at: datetime.datetime
attendees: Union[List[str], List[AttendeeInfo]] # Support both formats
meeting_time: datetime.datetime
description: Optional[str] = None
creator_id: int
creator_username: str
created_at: datetime.datetime
attendees: List[AttendeeInfo]
tags: List[Tag]
audio_file_path: Optional[str] = None
audio_duration: Optional[float] = None
prompt_name: Optional[str] = None
summary: Optional[str] = None
transcription_status: Optional[TranscriptionTaskStatus] = None
tags: Optional[List[Tag]] = []
access_password: Optional[str] = None
prompt_id: Optional[int] = None
prompt_name: Optional[str] = None
overall_status: Optional[str] = None
overall_progress: Optional[int] = None
current_stage: Optional[str] = None
class TranscriptSegment(BaseModel):
segment_id: int
meeting_id: int
speaker_id: Optional[int] = None # AI解析的原始结果
speaker_id: int
speaker_tag: str
start_time_ms: int
end_time_ms: int
text_content: str
class CreateMeetingRequest(BaseModel):
user_id: int
title: str
meeting_time: Optional[datetime.datetime]
attendee_ids: list[int]
tags: Optional[str] = None
meeting_time: datetime.datetime
attendees: str # 逗号分隔的姓名
description: Optional[str] = None
tags: Optional[str] = None # 逗号分隔
prompt_id: Optional[int] = None
class UpdateMeetingRequest(BaseModel):
title: str
meeting_time: Optional[datetime.datetime]
summary: Optional[str]
attendee_ids: list[int]
title: Optional[str] = None
meeting_time: Optional[datetime.datetime] = None
attendees: Optional[str] = None
description: Optional[str] = None
tags: Optional[str] = None
summary: Optional[str] = None
prompt_id: Optional[int] = None
class SpeakerTagUpdateRequest(BaseModel):
speaker_id: int # 使用原始speaker_id整数
speaker_id: int
new_tag: str
class BatchSpeakerTagUpdateRequest(BaseModel):
@ -126,7 +121,7 @@ class BatchSpeakerTagUpdateRequest(BaseModel):
class TranscriptUpdateRequest(BaseModel):
segment_id: int
text_content: str
new_text: str
class BatchTranscriptUpdateRequest(BaseModel):
updates: List[TranscriptUpdateRequest]
@ -135,45 +130,66 @@ class PasswordChangeRequest(BaseModel):
old_password: str
new_password: str
# 提示词模版模型
class PromptBase(BaseModel):
name: str
task_type: str # MEETING_TASK, KNOWLEDGE_TASK
content: str
desc: Optional[str] = None
is_system: bool = False
is_default: bool = False
is_active: bool = True
class PromptCreate(PromptBase):
pass
class PromptUpdate(BaseModel):
name: Optional[str] = None
task_type: Optional[str] = None
content: Optional[str] = None
desc: Optional[str] = None
is_system: Optional[bool] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
class PromptInfo(PromptBase):
id: int
creator_id: Optional[int] = None
created_at: datetime.datetime
# 知识库相关模型
class KnowledgeBase(BaseModel):
kb_id: int
title: str
content: Optional[str] = None
content: str
creator_id: int
creator_caption: str # To show in the UI
created_by_name: str
is_shared: bool
source_meeting_ids: Optional[str] = None
user_prompt: Optional[str] = None
tags: Union[Optional[str], Optional[List[Tag]]] = None # 支持字符串或Tag列表
created_at: datetime.datetime
updated_at: datetime.datetime
source_meeting_count: Optional[int] = 0
created_by_name: Optional[str] = None
source_meeting_count: int
source_meetings: Optional[List[Meeting]] = None
user_prompt: Optional[str] = None
tags: Optional[List[str]] = None
prompt_id: Optional[int] = None
class KnowledgeBaseTask(BaseModel):
task_id: str
user_id: int
kb_id: int
user_prompt: Optional[str] = None
status: str
progress: int
error_message: Optional[str] = None
created_at: datetime.datetime
updated_at: datetime.datetime
completed_at: Optional[datetime.datetime] = None
message: Optional[str] = None
result: Optional[str] = None
class CreateKnowledgeBaseRequest(BaseModel):
title: Optional[str] = None # 改为可选,后台自动生成
is_shared: bool
user_prompt: Optional[str] = None
source_meeting_ids: Optional[str] = None
tags: Optional[str] = None
prompt_id: Optional[int] = None # 提示词模版ID如果不指定则使用默认模版
source_meeting_ids: str # 逗号分隔
is_shared: bool = False
prompt_id: Optional[int] = None
class UpdateKnowledgeBaseRequest(BaseModel):
title: str
title: Optional[str] = None
content: Optional[str] = None
tags: Optional[str] = None
is_shared: Optional[bool] = None
class KnowledgeBaseListResponse(BaseModel):
kbs: List[KnowledgeBase]
@ -182,73 +198,63 @@ class KnowledgeBaseListResponse(BaseModel):
# 客户端下载相关模型
class ClientDownload(BaseModel):
id: int
platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal'
platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux'
platform_code: str # 新版平台编码,关联 dict_data.dict_code
platform_code: str
platform_type: str # mobile, desktop, terminal
platform_name: str
version: str
version_code: int
download_url: str
file_size: Optional[int] = None
release_notes: Optional[str] = None
min_system_version: Optional[str] = None
is_active: bool
is_latest: bool
min_system_version: Optional[str] = None
created_at: datetime.datetime
updated_at: datetime.datetime
created_by: Optional[int] = None
class CreateClientDownloadRequest(BaseModel):
platform_type: Optional[str] = None # 兼容旧版
platform_name: Optional[str] = None # 兼容旧版
platform_code: str # 必填,关联 dict_data
platform_code: str
platform_type: Optional[str] = None
platform_name: Optional[str] = None
version: str
version_code: int
download_url: str
file_size: Optional[int] = None
release_notes: Optional[str] = None
min_system_version: Optional[str] = None
is_active: bool = True
is_latest: bool = False
min_system_version: Optional[str] = None
class UpdateClientDownloadRequest(BaseModel):
platform_code: Optional[str] = None
platform_type: Optional[str] = None
platform_name: Optional[str] = None
platform_code: Optional[str] = None
version: Optional[str] = None
version_code: Optional[int] = None
download_url: Optional[str] = None
file_size: Optional[int] = None
release_notes: Optional[str] = None
min_system_version: Optional[str] = None
is_active: Optional[bool] = None
is_latest: Optional[bool] = None
min_system_version: Optional[str] = None
class ClientDownloadListResponse(BaseModel):
clients: List[ClientDownload]
total: int
# 声纹采集相关模型
# 声纹相关模型
class VoiceprintInfo(BaseModel):
vp_id: int
user_id: int
file_path: str
file_size: Optional[int] = None
duration_seconds: Optional[float] = None
collected_at: datetime.datetime
updated_at: datetime.datetime
voiceprint_data: Any
created_at: datetime.datetime
class VoiceprintStatus(BaseModel):
has_voiceprint: bool
vp_id: Optional[int] = None
file_path: Optional[str] = None
duration_seconds: Optional[float] = None
collected_at: Optional[datetime.datetime] = None
updated_at: Optional[datetime.datetime] = None
class VoiceprintTemplate(BaseModel):
template_text: str
content: str
duration_seconds: int
sample_rate: int
channels: int
# 菜单权限相关模型
class MenuInfo(BaseModel):
@ -277,13 +283,51 @@ class RolePermissionInfo(BaseModel):
class UpdateRolePermissionsRequest(BaseModel):
menu_ids: List[int]
class CreateRoleRequest(BaseModel):
role_name: str
class UpdateRoleRequest(BaseModel):
role_name: str
class CreateMenuRequest(BaseModel):
menu_code: str
menu_name: str
menu_icon: Optional[str] = None
menu_url: Optional[str] = None
menu_type: str = "link"
parent_id: Optional[int] = None
sort_order: int = 0
is_active: bool = True
description: Optional[str] = None
class UpdateMenuRequest(BaseModel):
menu_code: Optional[str] = None
menu_name: Optional[str] = None
menu_icon: Optional[str] = None
menu_url: Optional[str] = None
menu_type: Optional[str] = None
parent_id: Optional[int] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
description: Optional[str] = None
class UserMcpInfo(BaseModel):
id: int
user_id: int
bot_id: str
bot_secret: str
status: int
last_used_at: Optional[datetime.datetime] = None
created_at: datetime.datetime
updated_at: datetime.datetime
# 专用终端设备模型
class Terminal(BaseModel):
id: int
imei: str
terminal_name: Optional[str] = None
terminal_type: str
terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取)
terminal_type_name: Optional[str] = None
description: Optional[str] = None
status: int # 1: 启用, 0: 停用
is_activated: int # 1: 已激活, 0: 未激活
@ -296,18 +340,23 @@ class Terminal(BaseModel):
updated_at: datetime.datetime
created_by: Optional[int] = None
creator_username: Optional[str] = None
current_user_id: Optional[int] = None
current_username: Optional[str] = None
current_user_caption: Optional[str] = None
class CreateTerminalRequest(BaseModel):
imei: str
terminal_name: Optional[str] = None
terminal_type: str
description: Optional[str] = None
firmware_version: Optional[str] = None
mac_address: Optional[str] = None
status: int = 1
class UpdateTerminalRequest(BaseModel):
terminal_name: Optional[str] = None
terminal_type: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
firmware_version: Optional[str] = None
mac_address: Optional[str] = None
status: Optional[int] = None

View File

@ -4,11 +4,13 @@
"""
import uuid
import time
import os
from datetime import datetime
from typing import Optional, Dict, Any, List
from pathlib import Path
import redis
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, AUDIO_DIR
from app.core.database import get_db_connection
from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.llm_service import LLMService
@ -23,7 +25,7 @@ class AsyncMeetingService:
self.redis_client = redis.Redis(**REDIS_CONFIG)
self.llm_service = LLMService() # 复用现有的同步LLM服务
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None) -> str:
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None, model_code: Optional[str] = None) -> str:
"""
创建异步总结任务任务的执行将由外部如API层的BackgroundTasks触发
@ -31,6 +33,7 @@ class AsyncMeetingService:
meeting_id: 会议ID
user_prompt: 用户额外提示词
prompt_id: 可选的提示词模版ID如果不指定则使用默认模版
model_code: 可选的LLM模型编码如果不指定则使用默认模型
Returns:
str: 任务ID
@ -49,6 +52,7 @@ class AsyncMeetingService:
'meeting_id': str(meeting_id),
'user_prompt': user_prompt,
'prompt_id': str(prompt_id) if prompt_id else '',
'model_code': model_code or '',
'status': 'pending',
'progress': '0',
'created_at': current_time,
@ -79,6 +83,7 @@ class AsyncMeetingService:
user_prompt = task_data.get('user_prompt', '')
prompt_id_str = task_data.get('prompt_id', '')
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
model_code = task_data.get('model_code', '') or None
# 1. 更新状态为processing
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
@ -93,19 +98,26 @@ class AsyncMeetingService:
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id)
# 4. 调用LLM API
# 4. 调用LLM API(支持指定模型)
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
summary_content = self.llm_service._call_llm_api(full_prompt)
if model_code:
summary_content = self._call_llm_with_model(full_prompt, model_code)
else:
summary_content = self.llm_service._call_llm_api(full_prompt)
if not summary_content:
raise Exception("LLM API调用失败或返回空内容")
# 5. 保存结果到主表
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存总结结果...")
self._update_task_status_in_redis(task_id, 'processing', 90, message="保存总结结果...")
self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id)
# 6. 任务完成
self._update_task_in_db(task_id, 'completed', 100, result=summary_content)
self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content)
# 6. 导出MD文件到音频同目录
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
md_path = self._export_summary_md(meeting_id, summary_content)
# 7. 任务完成result保存MD文件路径
self._update_task_in_db(task_id, 'completed', 100, result=md_path)
self._update_task_status_in_redis(task_id, 'completed', 100, result=md_path)
print(f"Task {task_id} completed successfully")
except Exception as e:
@ -210,6 +222,86 @@ class AsyncMeetingService:
# --- 会议相关方法 ---
def _call_llm_with_model(self, prompt: str, model_code: str) -> Optional[str]:
"""使用指定模型编码调用LLM API"""
import requests
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"SELECT endpoint_url, api_key, llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens FROM llm_model_config WHERE model_code = %s AND is_active = 1",
(model_code,)
)
config = cursor.fetchone()
if not config:
print(f"模型 {model_code} 未找到或未激活,回退到默认模型")
return self.llm_service._call_llm_api(prompt)
endpoint_url = (config['endpoint_url'] or '').rstrip('/')
if not endpoint_url.endswith('/chat/completions'):
endpoint_url = f"{endpoint_url}/chat/completions"
headers = {"Content-Type": "application/json"}
if config['api_key']:
headers["Authorization"] = f"Bearer {config['api_key']}"
payload = {
"model": config['llm_model_name'],
"messages": [{"role": "user", "content": prompt}],
"temperature": float(config.get('llm_temperature', 0.7)),
"top_p": float(config.get('llm_top_p', 0.9)),
"max_tokens": int(config.get('llm_max_tokens', 4096)),
"stream": False,
}
response = requests.post(
endpoint_url,
headers=headers,
json=payload,
timeout=int(config.get('llm_timeout', 120)),
)
response.raise_for_status()
return self.llm_service._extract_response_text(response.json())
except Exception as e:
print(f"使用模型 {model_code} 调用失败: {e}")
return None
def _export_summary_md(self, meeting_id: int, summary_content: str) -> Optional[str]:
"""将总结内容导出为MD文件保存到音频同目录返回文件路径"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT title FROM meetings WHERE meeting_id = %s", (meeting_id,))
meeting = cursor.fetchone()
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
audio = cursor.fetchone()
title = meeting['title'] if meeting else f"meeting_{meeting_id}"
# 始终以 AUDIO_DIR 为基准,避免数据库中的绝对路径指向不可写目录
if audio and audio.get('file_path'):
audio_path = Path(audio['file_path'])
# 提取 meeting_id 层级的子目录(如 "226" 或 "226/sub"
try:
rel = audio_path.relative_to(AUDIO_DIR)
md_dir = AUDIO_DIR / rel.parent
except ValueError:
# file_path 不在 AUDIO_DIR 下(如 Docker 绝对路径),取最后一级目录名
md_dir = AUDIO_DIR / audio_path.parent.name
else:
md_dir = AUDIO_DIR / str(meeting_id)
md_dir.mkdir(parents=True, exist_ok=True)
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip()
if not safe_title:
safe_title = f"meeting_{meeting_id}"
md_path = md_dir / f"{safe_title}_总结.md"
md_path.write_text(summary_content, encoding='utf-8')
md_path_str = str(md_path)
print(f"总结MD文件已保存: {md_path_str}")
return md_path_str
except Exception as e:
print(f"导出总结MD文件失败: {e}")
return None
def _get_meeting_transcript(self, meeting_id: int) -> str:
"""从数据库获取会议转录内容"""
try:
@ -417,14 +509,14 @@ class AsyncMeetingService:
try:
with get_db_connection() as connection:
cursor = connection.cursor()
params = [status, progress, error_message, task_id]
if status == 'completed':
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s"
params.insert(2, result)
query = "UPDATE llm_tasks SET status = %s, progress = %s, result = %s, error_message = NULL, completed_at = NOW() WHERE task_id = %s"
params = (status, progress, result, task_id)
else:
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s"
params = (status, progress, error_message, task_id)
cursor.execute(query, tuple(params))
cursor.execute(query, params)
connection.commit()
except Exception as e:
print(f"Error updating task in database: {e}")

View File

@ -1,5 +1,6 @@
import uuid
import json
import os
import redis
import requests
from datetime import datetime
@ -21,6 +22,83 @@ class AsyncTranscriptionService:
dashscope.api_key = QWEN_API_KEY
self.redis_client = redis.Redis(**REDIS_CONFIG)
self.base_url = APP_CONFIG['base_url']
@staticmethod
def _create_requests_session() -> requests.Session:
session = requests.Session()
session.trust_env = os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"}
return session
@staticmethod
def _normalize_dashscope_base_address(endpoint_url: Optional[str]) -> Optional[str]:
if not endpoint_url:
return None
normalized = str(endpoint_url).strip().rstrip("/")
suffix = "/services/audio/asr/transcription"
if normalized.endswith(suffix):
normalized = normalized[: -len(suffix)]
return normalized or None
@staticmethod
def _build_dashscope_call_params(audio_config: Dict[str, Any], file_url: str) -> Dict[str, Any]:
model_name = audio_config.get("model") or "paraformer-v2"
call_params: Dict[str, Any] = {
"model": model_name,
"file_urls": [file_url],
}
optional_keys = [
"language_hints",
"disfluency_removal_enabled",
"diarization_enabled",
"speaker_count",
"vocabulary_id",
"timestamp_alignment_enabled",
"channel_id",
"special_word_filter",
"audio_event_detection_enabled",
"phrase_id",
]
for key in optional_keys:
value = audio_config.get(key)
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
if isinstance(value, list) and not value:
continue
call_params[key] = value
return call_params
def test_asr_model(self, audio_config: Dict[str, Any], test_file_url: Optional[str] = None) -> Dict[str, Any]:
provider = str(audio_config.get("provider") or "dashscope").strip().lower()
if provider != "dashscope":
raise Exception(f"当前仅支持 DashScope 音频识别测试,暂不支持供应商: {provider}")
dashscope.api_key = audio_config.get("api_key") or QWEN_API_KEY
target_file_url = (
test_file_url
or "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav"
)
call_params = self._build_dashscope_call_params(audio_config, target_file_url)
base_address = self._normalize_dashscope_base_address(audio_config.get("endpoint_url"))
session = self._create_requests_session()
try:
if base_address:
response = Transcription.async_call(base_address=base_address, session=session, **call_params)
else:
response = Transcription.async_call(session=session, **call_params)
finally:
session.close()
if response.status_code != HTTPStatus.OK:
raise Exception(response.message or "音频模型测试失败")
return {
"provider_task_id": response.output.task_id,
"test_file_url": target_file_url,
"used_params": call_params,
}
def start_transcription(self, meeting_id: int, audio_file_path: str) -> str:
"""
@ -59,24 +137,31 @@ class AsyncTranscriptionService:
# 2. 构造完整的文件URL
file_url = f"{self.base_url}{audio_file_path}"
# 获取热词表ID (asr_vocabulary_id)
vocabulary_id = SystemConfigService.get_asr_vocabulary_id()
# 获取音频模型配置
audio_config = SystemConfigService.get_active_audio_model_config("asr")
provider = str(audio_config.get("provider") or "dashscope").strip().lower()
if provider != "dashscope":
raise Exception(f"当前仅支持 DashScope 音频识别,暂不支持供应商: {provider}")
print(f"Starting transcription for meeting_id: {meeting_id}, file_url: {file_url}, vocabulary_id: {vocabulary_id}")
dashscope.api_key = audio_config.get("api_key") or QWEN_API_KEY
call_params = self._build_dashscope_call_params(audio_config, file_url)
base_address = self._normalize_dashscope_base_address(audio_config.get("endpoint_url"))
print(
f"Starting transcription for meeting_id: {meeting_id}, "
f"file_url: {file_url}, model: {call_params.get('model')}, "
f"vocabulary_id: {call_params.get('vocabulary_id')}"
)
# 3. 调用Paraformer异步API
call_params = {
'model': 'paraformer-v2',
'file_urls': [file_url],
'language_hints': ['zh', 'en'],
'disfluency_removal_enabled': True,
'diarization_enabled': True,
'speaker_count': 10
}
if vocabulary_id:
call_params['vocabulary_id'] = vocabulary_id
task_response = Transcription.async_call(**call_params)
session = self._create_requests_session()
try:
if base_address:
task_response = Transcription.async_call(base_address=base_address, session=session, **call_params)
else:
task_response = Transcription.async_call(session=session, **call_params)
finally:
session.close()
if task_response.status_code != HTTPStatus.OK:
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
@ -134,7 +219,11 @@ class AsyncTranscriptionService:
# 2. 查询外部API获取状态
try:
paraformer_response = Transcription.fetch(task=paraformer_task_id)
session = self._create_requests_session()
try:
paraformer_response = Transcription.fetch(task=paraformer_task_id, session=session)
finally:
session.close()
if paraformer_response.status_code != HTTPStatus.OK:
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
@ -411,7 +500,11 @@ class AsyncTranscriptionService:
transcription_url = paraformer_output['results'][0]['transcription_url']
print(f"Fetching transcription from URL: {transcription_url}")
response = requests.get(transcription_url)
session = self._create_requests_session()
try:
response = session.get(transcription_url)
finally:
session.close()
response.raise_for_status()
transcription_data = response.json()

View File

@ -1,7 +1,9 @@
import json
import dashscope
from http import HTTPStatus
from typing import Optional, Dict, List, Generator, Any
import os
from typing import Optional, Dict, Generator, Any
import requests
import app.core.config as config_module
from app.core.database import get_db_connection
from app.services.system_config_service import SystemConfigService
@ -10,23 +12,104 @@ from app.services.system_config_service import SystemConfigService
class LLMService:
"""LLM服务 - 专注于大模型API调用和提示词管理"""
def __init__(self):
# 设置dashscope API key
dashscope.api_key = config_module.QWEN_API_KEY
@staticmethod
def _create_requests_session() -> requests.Session:
session = requests.Session()
session.trust_env = os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"}
return session
@staticmethod
def build_call_params_from_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = config or {}
endpoint_url = config.get("endpoint_url") or SystemConfigService.get_llm_endpoint_url()
api_key = config.get("api_key")
if api_key is None:
api_key = SystemConfigService.get_llm_api_key(config_module.QWEN_API_KEY)
return {
"endpoint_url": endpoint_url,
"api_key": api_key,
"model": config.get("llm_model_name") or config.get("model") or SystemConfigService.get_llm_model_name(),
"timeout": int(config.get("llm_timeout") or config.get("timeout") or SystemConfigService.get_llm_timeout()),
"temperature": float(config.get("llm_temperature") if config.get("llm_temperature") is not None else config.get("temperature", SystemConfigService.get_llm_temperature())),
"top_p": float(config.get("llm_top_p") if config.get("llm_top_p") is not None else config.get("top_p", SystemConfigService.get_llm_top_p())),
"max_tokens": int(config.get("llm_max_tokens") or config.get("max_tokens") or SystemConfigService.get_llm_max_tokens()),
"system_prompt": config.get("llm_system_prompt") or config.get("system_prompt") or SystemConfigService.get_llm_system_prompt(None),
}
def _get_llm_call_params(self) -> Dict[str, Any]:
"""
获取 dashscope.Generation.call() 所需的参数字典
获取 OpenAI 兼容接口调用参数
Returns:
Dict: 包含 modeltimeouttemperaturetop_p 的参数字典
Dict: 包含 endpoint_urlapi_keymodeltimeouttemperaturetop_pmax_tokens 的参数字典
"""
return {
'model': SystemConfigService.get_llm_model_name(),
'timeout': SystemConfigService.get_llm_timeout(),
'temperature': SystemConfigService.get_llm_temperature(),
'top_p': SystemConfigService.get_llm_top_p(),
return self.build_call_params_from_config()
@staticmethod
def _build_chat_url(endpoint_url: str) -> str:
base_url = (endpoint_url or "").rstrip("/")
if base_url.endswith("/chat/completions"):
return base_url
return f"{base_url}/chat/completions"
@staticmethod
def _build_headers(api_key: Optional[str]) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def _build_payload(self, prompt: str, stream: bool = False, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
params = params or self._get_llm_call_params()
messages = []
system_prompt = params.get("system_prompt")
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
payload = {
"model": params["model"],
"messages": messages,
"temperature": params["temperature"],
"top_p": params["top_p"],
"max_tokens": params["max_tokens"],
"stream": stream,
}
return payload
@staticmethod
def _normalize_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for item in content:
if isinstance(item, str):
texts.append(item)
elif isinstance(item, dict):
text = item.get("text")
if text:
texts.append(text)
return "".join(texts)
return ""
def _extract_response_text(self, data: Dict[str, Any]) -> str:
choices = data.get("choices") or []
if not choices:
return ""
first_choice = choices[0] or {}
message = first_choice.get("message") or {}
content = message.get("content")
if content:
return self._normalize_content(content)
delta = first_choice.get("delta") or {}
delta_content = delta.get("content")
if delta_content:
return self._normalize_content(delta_content)
return ""
def get_task_prompt(self, task_type: str, cursor=None, prompt_id: Optional[int] = None) -> str:
"""
@ -79,7 +162,7 @@ class LLMService:
def _get_default_prompt(self, task_name: str) -> str:
"""获取默认提示词"""
system_prompt = config_module.LLM_CONFIG.get("system_prompt", "请根据提供的内容进行总结和分析。")
system_prompt = SystemConfigService.get_llm_system_prompt("请根据提供的内容进行总结和分析。")
default_prompts = {
'MEETING_TASK': system_prompt,
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
@ -87,50 +170,98 @@ class LLMService:
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
"""流式调用阿里Qwen大模型API"""
try:
responses = dashscope.Generation.call(
**self._get_llm_call_params(),
prompt=prompt,
stream=True,
incremental_output=True
)
"""流式调用 OpenAI 兼容大模型API"""
params = self._get_llm_call_params()
if not params["api_key"]:
yield "error: 缺少API Key"
return
for response in responses:
if response.status_code == HTTPStatus.OK:
# 增量输出内容
new_content = response.output.get('text', '')
try:
session = self._create_requests_session()
try:
response = session.post(
self._build_chat_url(params["endpoint_url"]),
headers=self._build_headers(params["api_key"]),
json=self._build_payload(prompt, stream=True),
timeout=params["timeout"],
stream=True,
)
response.raise_for_status()
for line in response.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
data_line = line[5:].strip()
if not data_line or data_line == "[DONE]":
continue
try:
data = json.loads(data_line)
except json.JSONDecodeError:
continue
new_content = self._extract_response_text(data)
if new_content:
yield new_content
else:
error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}"
print(error_msg)
yield f"error: {error_msg}"
break
finally:
session.close()
except Exception as e:
error_msg = f"流式调用大模型API错误: {e}"
print(error_msg)
yield f"error: {error_msg}"
def _call_llm_api(self, prompt: str) -> Optional[str]:
"""调用阿里Qwen大模型API非流式"""
"""调用 OpenAI 兼容大模型API非流式"""
params = self._get_llm_call_params()
return self.call_llm_api_with_config(params, prompt)
def call_llm_api_with_config(self, params: Dict[str, Any], prompt: str) -> Optional[str]:
"""使用指定配置调用 OpenAI 兼容大模型API非流式"""
if not params["api_key"]:
print("调用大模型API错误: 缺少API Key")
return None
try:
response = dashscope.Generation.call(
**self._get_llm_call_params(),
prompt=prompt
)
if response.status_code == HTTPStatus.OK:
return response.output.get('text', '')
else:
print(f"API调用失败: {response.status_code}, {response.message}")
return None
session = self._create_requests_session()
try:
response = session.post(
self._build_chat_url(params["endpoint_url"]),
headers=self._build_headers(params["api_key"]),
json=self._build_payload(prompt, params=params),
timeout=params["timeout"],
)
response.raise_for_status()
content = self._extract_response_text(response.json())
finally:
session.close()
if content:
return content
print("API调用失败: 返回内容为空")
return None
except Exception as e:
print(f"调用大模型API错误: {e}")
return None
def test_model(self, config: Dict[str, Any], prompt: Optional[str] = None) -> Dict[str, Any]:
params = self.build_call_params_from_config(config)
test_prompt = prompt or "请用一句中文回复LLM测试成功。"
content = self.call_llm_api_with_config(params, test_prompt)
if not content:
raise Exception("模型无有效返回内容")
return {
"model": params["model"],
"endpoint_url": params["endpoint_url"],
"response_preview": content[:500],
"used_params": {
"timeout": params["timeout"],
"temperature": params["temperature"],
"top_p": params["top_p"],
"max_tokens": params["max_tokens"],
},
}
# 测试代码
if __name__ == '__main__':

View File

@ -4,9 +4,10 @@ from app.core.database import get_db_connection
class SystemConfigService:
"""系统配置服务 - 从 dict_data 表中读取和保存 system_config 类型的配置"""
"""系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退"""
DICT_TYPE = 'system_config'
DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
# 配置键常量
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
@ -27,6 +28,219 @@ class SystemConfigService:
LLM_TEMPERATURE = 'llm_temperature'
LLM_TOP_P = 'llm_top_p'
@staticmethod
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 {}
@staticmethod
def _normalize_string_list(value: Any) -> Optional[list[str]]:
if value is None:
return None
if isinstance(value, list):
items = [str(item).strip() for item in value if str(item).strip()]
return items or None
if isinstance(value, str):
items = [item.strip() for item in value.split(",") if item.strip()]
return items or None
return None
@classmethod
def _build_audio_runtime_config(cls, audio_row: Dict[str, Any]) -> Dict[str, Any]:
cfg: Dict[str, Any] = {}
if not audio_row:
return cfg
extra_config = cls._parse_json_object(audio_row.get("extra_config"))
if audio_row.get("endpoint_url"):
cfg["endpoint_url"] = audio_row["endpoint_url"]
if audio_row.get("api_key"):
cfg["api_key"] = audio_row["api_key"]
if audio_row.get("provider"):
cfg["provider"] = audio_row["provider"]
if audio_row.get("model_code"):
cfg["model_code"] = audio_row["model_code"]
if audio_row.get("audio_scene"):
cfg["audio_scene"] = audio_row["audio_scene"]
if audio_row.get("hot_word_group_id") is not None:
cfg["hot_word_group_id"] = audio_row["hot_word_group_id"]
if audio_row.get("audio_scene") == "asr":
if extra_config.get("model") is None and audio_row.get("asr_model_name") is not None:
extra_config["model"] = audio_row["asr_model_name"]
if extra_config.get("vocabulary_id") is None and audio_row.get("asr_vocabulary_id") is not None:
extra_config["vocabulary_id"] = audio_row["asr_vocabulary_id"]
if extra_config.get("speaker_count") is None and audio_row.get("asr_speaker_count") is not None:
extra_config["speaker_count"] = audio_row["asr_speaker_count"]
if extra_config.get("language_hints") is None and audio_row.get("asr_language_hints"):
extra_config["language_hints"] = audio_row["asr_language_hints"]
if extra_config.get("disfluency_removal_enabled") is None and audio_row.get("asr_disfluency_removal_enabled") is not None:
extra_config["disfluency_removal_enabled"] = bool(audio_row["asr_disfluency_removal_enabled"])
if extra_config.get("diarization_enabled") is None and audio_row.get("asr_diarization_enabled") is not None:
extra_config["diarization_enabled"] = bool(audio_row["asr_diarization_enabled"])
else:
if extra_config.get("model") is None and audio_row.get("model_name"):
extra_config["model"] = audio_row["model_name"]
if extra_config.get("template_text") is None and audio_row.get("vp_template_text") is not None:
extra_config["template_text"] = audio_row["vp_template_text"]
if extra_config.get("duration_seconds") is None and audio_row.get("vp_duration_seconds") is not None:
extra_config["duration_seconds"] = audio_row["vp_duration_seconds"]
if extra_config.get("sample_rate") is None and audio_row.get("vp_sample_rate") is not None:
extra_config["sample_rate"] = audio_row["vp_sample_rate"]
if extra_config.get("channels") is None and audio_row.get("vp_channels") is not None:
extra_config["channels"] = audio_row["vp_channels"]
if extra_config.get("max_size_bytes") is None and audio_row.get("vp_max_size_bytes") is not None:
extra_config["max_size_bytes"] = audio_row["vp_max_size_bytes"]
language_hints = cls._normalize_string_list(extra_config.get("language_hints"))
if language_hints is not None:
extra_config["language_hints"] = language_hints
cfg.update(extra_config)
return cfg
@classmethod
def get_active_audio_model_config(cls, scene: str = "asr") -> Dict[str, Any]:
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, hot_word_group_id,
asr_model_name, asr_vocabulary_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
FROM audio_model_config
WHERE audio_scene = %s AND is_active = 1
ORDER BY is_default DESC, updated_at DESC, config_id ASC
LIMIT 1
""",
(scene,),
)
row = cursor.fetchone()
cursor.close()
return cls._build_audio_runtime_config(row) if row else {}
except Exception:
return {}
@classmethod
def _get_parameter_value(cls, param_key: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_value
FROM sys_system_parameters
WHERE param_key = %s AND is_active = 1
LIMIT 1
""",
(param_key,),
)
result = cursor.fetchone()
cursor.close()
return result["param_value"] if result else None
except Exception:
return None
@classmethod
def _get_model_config_json(cls, model_code: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 1) llm 专表
cursor.execute(
"""
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout,
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
FROM llm_model_config
WHERE model_code = %s AND is_active = 1
ORDER BY is_default DESC, config_id ASC
LIMIT 1
""",
(model_code,),
)
llm_row = cursor.fetchone()
if not llm_row and model_code == "llm_model":
cursor.execute(
"""
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout,
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
FROM llm_model_config
WHERE is_active = 1
ORDER BY is_default DESC, updated_at DESC, config_id ASC
LIMIT 1
"""
)
llm_row = cursor.fetchone()
if llm_row:
cursor.close()
cfg = {}
if llm_row.get("endpoint_url"):
cfg["endpoint_url"] = llm_row["endpoint_url"]
if llm_row.get("api_key"):
cfg["api_key"] = llm_row["api_key"]
if llm_row.get("llm_model_name") is not None:
cfg["model_name"] = llm_row["llm_model_name"]
if llm_row.get("llm_timeout") is not None:
cfg["time_out"] = llm_row["llm_timeout"]
if llm_row.get("llm_temperature") is not None:
cfg["temperature"] = float(llm_row["llm_temperature"])
if llm_row.get("llm_top_p") is not None:
cfg["top_p"] = float(llm_row["llm_top_p"])
if llm_row.get("llm_max_tokens") is not None:
cfg["max_tokens"] = llm_row["llm_max_tokens"]
if llm_row.get("llm_system_prompt") is not None:
cfg["system_prompt"] = llm_row["llm_system_prompt"]
return cfg
# 2) audio 专表
if model_code in ("audio_model", "voiceprint_model"):
target_scene = "voiceprint" if model_code == "voiceprint_model" else "asr"
cursor.close()
audio_cfg = cls.get_active_audio_model_config(target_scene)
return audio_cfg or None
cursor.execute(
"""
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key, hot_word_group_id,
asr_model_name, asr_vocabulary_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
FROM audio_model_config
WHERE model_code = %s AND is_active = 1
ORDER BY is_default DESC, config_id ASC
LIMIT 1
""",
(model_code,),
)
audio_row = cursor.fetchone()
cursor.close()
if audio_row:
cfg = cls._build_audio_runtime_config(audio_row)
if cfg.get("max_size_bytes") is not None and cfg.get("voiceprint_max_size") is None:
cfg["voiceprint_max_size"] = cfg["max_size_bytes"]
return cfg
return None
except Exception:
return None
@classmethod
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
"""
@ -39,12 +253,18 @@ class SystemConfigService:
Returns:
配置项的值
"""
# 1) 新参数表
value = cls._get_parameter_value(dict_code)
if value is not None:
return value
# 2) 兼容旧 sys_dict_data
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT extension_attr
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND dict_code = %s AND status = 1
LIMIT 1
"""
@ -80,12 +300,18 @@ class SystemConfigService:
Returns:
属性值
"""
# 1) 新模型配置表
model_json = cls._get_model_config_json(dict_code)
if model_json is not None:
return model_json.get(attr_name, default_value)
# 2) 兼容旧 sys_dict_data
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT extension_attr
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND dict_code = %s AND status = 1
LIMIT 1
"""
@ -119,13 +345,74 @@ class SystemConfigService:
Returns:
是否设置成功
"""
# 1) 优先写入新参数表
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
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, 1)
ON DUPLICATE KEY UPDATE
param_name = VALUES(param_name),
param_value = VALUES(param_value),
value_type = VALUES(value_type),
category = VALUES(category),
description = VALUES(description),
is_active = 1
""",
(
dict_code,
label_cn or dict_code,
str(value) if value is not None else "",
"string",
"system",
"Migrated from legacy system_config",
),
)
if dict_code == cls.ASR_VOCABULARY_ID:
cursor.execute(
"""
INSERT INTO audio_model_config
(model_code, model_name, audio_scene, provider, asr_model_name, asr_vocabulary_id, asr_speaker_count,
asr_language_hints, asr_disfluency_removal_enabled, asr_diarization_enabled, description, is_active, is_default)
VALUES (
'audio_model',
'音频识别模型',
'asr',
'dashscope',
'paraformer-v2',
%s,
10,
'zh,en',
1,
1,
'语音识别模型配置',
1,
1
)
ON DUPLICATE KEY UPDATE
asr_vocabulary_id = VALUES(asr_vocabulary_id),
is_active = 1
""",
(str(value),),
)
conn.commit()
cursor.close()
return True
except Exception as e:
print(f"Error setting config in sys_system_parameters {dict_code}: {e}")
# 2) 回退写入旧 sys_dict_data
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 检查配置是否存在
cursor.execute(
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
"SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s",
(cls.DICT_TYPE, dict_code)
)
existing = cursor.fetchone()
@ -135,7 +422,7 @@ class SystemConfigService:
if existing:
# 更新现有配置
update_query = """
UPDATE dict_data
UPDATE sys_dict_data
SET extension_attr = %s, update_time = NOW()
WHERE dict_type = %s AND dict_code = %s
"""
@ -146,7 +433,7 @@ class SystemConfigService:
label_cn = dict_code
insert_query = """
INSERT INTO dict_data (
INSERT INTO sys_dict_data (
dict_type, dict_code, parent_code, label_cn,
extension_attr, status, sort_order
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
@ -169,12 +456,32 @@ class SystemConfigService:
Returns:
配置字典 {dict_code: value}
"""
# 1) 新参数表
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
ORDER BY category, param_key
"""
)
rows = cursor.fetchall()
cursor.close()
if rows:
return {row["param_key"]: row["param_value"] for row in rows}
except Exception as e:
print(f"Error getting all configs from sys_system_parameters: {e}")
# 2) 兼容旧 sys_dict_data
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT dict_code, label_cn, extension_attr
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND status = 1
ORDER BY sort_order
"""
@ -219,19 +526,28 @@ class SystemConfigService:
# 便捷方法:获取特定配置
@classmethod
def get_asr_vocabulary_id(cls) -> Optional[str]:
"""获取ASR热词字典ID"""
"""获取ASR热词字典ID — 优先从 audio_model_config.hot_word_group_id → hot_word_group.vocabulary_id"""
audio_cfg = cls.get_active_audio_model_config("asr")
if audio_cfg.get("vocabulary_id"):
return audio_cfg["vocabulary_id"]
# 回退:直接读 audio_model_config.asr_vocabulary_id
audio_vocab = cls.get_config_attribute('audio_model', 'vocabulary_id')
if audio_vocab:
return audio_vocab
return cls.get_config(cls.ASR_VOCABULARY_ID)
# 声纹配置获取方法(直接使用通用方法)
@classmethod
def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str:
"""获取声纹采集模版"""
return cls.get_config_attribute('voiceprint', 'template_text', default)
return cls.get_config_attribute('voiceprint_model', 'template_text', default)
@classmethod
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
"""获取声纹文件大小限制 (bytes), 默认5MB"""
value = cls.get_config_attribute('voiceprint', 'voiceprint_max_size', default)
value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', None)
if value is None:
value = cls.get_config_attribute('voiceprint_model', 'voiceprint_max_size', default)
try:
return int(value)
except (ValueError, TypeError):
@ -240,7 +556,7 @@ class SystemConfigService:
@classmethod
def get_voiceprint_duration(cls, default: int = 12) -> int:
"""获取声纹采集最短时长 (秒)"""
value = cls.get_config_attribute('voiceprint', 'duration_seconds', default)
value = cls.get_config_attribute('voiceprint_model', 'duration_seconds', default)
try:
return int(value)
except (ValueError, TypeError):
@ -249,7 +565,7 @@ class SystemConfigService:
@classmethod
def get_voiceprint_sample_rate(cls, default: int = 16000) -> int:
"""获取声纹采样率"""
value = cls.get_config_attribute('voiceprint', 'sample_rate', default)
value = cls.get_config_attribute('voiceprint_model', 'sample_rate', default)
try:
return int(value)
except (ValueError, TypeError):
@ -258,7 +574,7 @@ class SystemConfigService:
@classmethod
def get_voiceprint_channels(cls, default: int = 1) -> int:
"""获取声纹通道数"""
value = cls.get_config_attribute('voiceprint', 'channels', default)
value = cls.get_config_attribute('voiceprint_model', 'channels', default)
try:
return int(value)
except (ValueError, TypeError):
@ -319,3 +635,33 @@ class SystemConfigService:
return float(value)
except (ValueError, TypeError):
return default
@classmethod
def get_llm_max_tokens(cls, default: int = 2048) -> int:
"""获取LLM最大输出token"""
value = cls.get_config_attribute('llm_model', 'max_tokens', default)
try:
return int(value)
except (ValueError, TypeError):
return default
@classmethod
def get_llm_system_prompt(cls, default: str = "请根据提供的内容进行总结和分析。") -> str:
"""获取LLM系统提示词"""
value = cls.get_config_attribute('llm_model', 'system_prompt', default)
return value if isinstance(value, str) and value.strip() else default
@classmethod
def get_llm_endpoint_url(cls, default: str = DEFAULT_LLM_ENDPOINT_URL) -> str:
"""获取LLM服务Base API"""
value = cls.get_config_attribute('llm_model', 'endpoint_url', default)
return value if isinstance(value, str) and value.strip() else default
@classmethod
def get_llm_api_key(cls, default: Optional[str] = None) -> Optional[str]:
"""获取LLM服务API Key"""
value = cls.get_config_attribute('llm_model', 'api_key', default)
if value is None:
return default
value_str = str(value).strip()
return value_str or default

View File

@ -49,9 +49,9 @@ class TerminalService:
cu.caption as current_user_caption,
dd.label_cn as terminal_type_name
FROM terminals t
LEFT JOIN users u ON t.created_by = u.user_id
LEFT JOIN users cu ON t.current_user_id = cu.user_id
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
LEFT JOIN sys_users u ON t.created_by = u.user_id
LEFT JOIN sys_users cu ON t.current_user_id = cu.user_id
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
WHERE {where_clause}
ORDER BY t.created_at DESC
LIMIT %s OFFSET %s
@ -75,8 +75,8 @@ class TerminalService:
u.username as creator_username,
dd.label_cn as terminal_type_name
FROM terminals t
LEFT JOIN users u ON t.created_by = u.user_id
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
LEFT JOIN sys_users u ON t.created_by = u.user_id
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
WHERE t.id = %s
"""
cursor.execute(query, (terminal_id,))
@ -105,14 +105,17 @@ class TerminalService:
query = """
INSERT INTO terminals (
imei, terminal_name, terminal_type, description, status, created_by
) VALUES (%s, %s, %s, %s, %s, %s)
imei, terminal_name, terminal_type, description,
firmware_version, mac_address, status, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(query, (
terminal_data.imei,
terminal_data.terminal_name,
terminal_data.terminal_type,
terminal_data.description,
terminal_data.firmware_version,
terminal_data.mac_address,
terminal_data.status,
user_id
))

View File

@ -0,0 +1,97 @@
from pathlib import Path
import shutil
import re
from app.core.database import get_db_connection
import app.core.config as config_module
OLD_AVATAR_PREFIX = "/uploads/user/avatar/"
NEW_AVATAR_PREFIX = "/uploads/user/"
OLD_VOICEPRINT_PREFIX = "uploads/user/voiceprint/"
NEW_VOICEPRINT_PREFIX = "uploads/user/"
def move_tree_contents(old_dir: Path, new_dir: Path):
if not old_dir.exists():
return False
new_dir.mkdir(parents=True, exist_ok=True)
moved = False
for item in old_dir.iterdir():
target = new_dir / item.name
if item.resolve() == target.resolve():
continue
if target.exists():
continue
shutil.move(str(item), str(target))
moved = True
return moved
def migrate_avatar_files():
legacy_root = config_module.LEGACY_AVATAR_DIR
if not legacy_root.exists():
return
for user_dir in legacy_root.iterdir():
if not user_dir.is_dir():
continue
target_dir = config_module.get_user_avatar_dir(user_dir.name)
move_tree_contents(user_dir, target_dir)
def migrate_voiceprint_files():
legacy_root = config_module.LEGACY_VOICEPRINT_DIR
if not legacy_root.exists():
return
for user_dir in legacy_root.iterdir():
if not user_dir.is_dir():
continue
target_dir = config_module.get_user_voiceprint_dir(user_dir.name)
move_tree_contents(user_dir, target_dir)
def migrate_avatar_urls(cursor):
cursor.execute("SELECT user_id, avatar_url FROM sys_users WHERE avatar_url LIKE %s", (f"{OLD_AVATAR_PREFIX}%",))
rows = cursor.fetchall()
for row in rows:
avatar_url = row["avatar_url"]
if not avatar_url:
continue
match = re.match(r"^/uploads/user/avatar/(\d+)/(.*)$", avatar_url)
if not match:
continue
user_id, filename = match.groups()
new_url = f"/uploads/user/{user_id}/avatar/{filename}"
cursor.execute("UPDATE sys_users SET avatar_url = %s WHERE user_id = %s", (new_url, row["user_id"]))
def migrate_voiceprint_paths(cursor):
try:
cursor.execute("SELECT vp_id, file_path FROM user_voiceprint WHERE file_path LIKE %s", (f"{OLD_VOICEPRINT_PREFIX}%",))
except Exception:
return
rows = cursor.fetchall()
for row in rows:
file_path = row["file_path"]
if not file_path:
continue
match = re.match(r"^uploads/user/voiceprint/(\d+)/(.*)$", file_path)
if not match:
continue
user_id, filename = match.groups()
new_path = f"uploads/user/{user_id}/voiceprint/{filename}"
cursor.execute("UPDATE user_voiceprint SET file_path = %s WHERE vp_id = %s", (new_path, row["vp_id"]))
def main():
migrate_avatar_files()
migrate_voiceprint_files()
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
migrate_avatar_urls(cursor)
migrate_voiceprint_paths(cursor)
connection.commit()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,165 @@
#!/usr/bin/env python3
import argparse
import re
import sys
from pathlib import Path
import pymysql
def parse_env_file(env_path: Path):
data = {}
for raw in env_path.read_text(encoding='utf-8').splitlines():
line = raw.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
continue
k, v = line.split('=', 1)
data[k.strip()] = v.strip()
return data
def split_sql_statements(sql_text: str):
statements = []
buf = []
in_single = False
in_double = False
in_line_comment = False
in_block_comment = False
i = 0
while i < len(sql_text):
ch = sql_text[i]
nxt = sql_text[i + 1] if i + 1 < len(sql_text) else ''
if in_line_comment:
if ch == '\n':
in_line_comment = False
buf.append(ch)
i += 1
continue
if in_block_comment:
if ch == '*' and nxt == '/':
in_block_comment = False
i += 2
else:
i += 1
continue
if not in_single and not in_double:
if ch == '-' and nxt == '-':
in_line_comment = True
i += 2
continue
if ch == '#':
in_line_comment = True
i += 1
continue
if ch == '/' and nxt == '*':
in_block_comment = True
i += 2
continue
if ch == "'" and not in_double:
in_single = not in_single
buf.append(ch)
i += 1
continue
if ch == '"' and not in_single:
in_double = not in_double
buf.append(ch)
i += 1
continue
if ch == ';' and not in_single and not in_double:
stmt = ''.join(buf).strip()
if stmt:
statements.append(stmt)
buf = []
i += 1
continue
buf.append(ch)
i += 1
tail = ''.join(buf).strip()
if tail:
statements.append(tail)
return statements
def main():
parser = argparse.ArgumentParser(description='Run SQL migration from file')
parser.add_argument('--env', default='backend/.env', help='Path to .env file')
parser.add_argument('--sql', required=True, help='Path to SQL file')
args = parser.parse_args()
env = parse_env_file(Path(args.env))
sql_path = Path(args.sql)
if not sql_path.exists():
print(f'[ERROR] SQL file not found: {sql_path}')
return 1
sql_text = sql_path.read_text(encoding='utf-8')
statements = split_sql_statements(sql_text)
if not statements:
print('[ERROR] No SQL statements found')
return 1
conn = pymysql.connect(
host=env.get('DB_HOST', '127.0.0.1'),
port=int(env.get('DB_PORT', '3306')),
user=env.get('DB_USER', 'root'),
password=env.get('DB_PASSWORD', ''),
database=env.get('DB_NAME', ''),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
# duplicate column/index tolerated for idempotency rerun
tolerated_errnos = {1060, 1061, 1831}
try:
with conn.cursor() as cur:
print(f'[INFO] Running {len(statements)} statements from {sql_path}')
for idx, stmt in enumerate(statements, start=1):
normalized = re.sub(r'\s+', ' ', stmt).strip()
head = normalized[:120]
try:
cur.execute(stmt)
print(f'[OK] {idx:03d}: {head}')
except pymysql.MySQLError as e:
if e.args and e.args[0] in tolerated_errnos:
print(f'[SKIP] {idx:03d}: errno={e.args[0]} {e.args[1]} | {head}')
continue
conn.rollback()
print(f'[FAIL] {idx:03d}: errno={e.args[0] if e.args else "?"} {e}')
print(f'[STMT] {head}')
return 2
conn.commit()
print('[INFO] Migration committed successfully')
checks = [
"SELECT COUNT(*) AS cnt FROM menus",
"SELECT COUNT(*) AS cnt FROM role_menu_permissions",
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='menus' AND COLUMN_NAME IN ('menu_level','tree_path','is_visible')",
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='role_menu_permissions' AND COLUMN_NAME IN ('granted_by','granted_at')",
]
for q in checks:
cur.execute(q)
row = cur.fetchone()
print(f'[CHECK] {q} => {row}')
finally:
conn.close()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,139 @@
#!/usr/bin/env python3
import argparse
import sys
from pathlib import Path
import mysql.connector
def parse_env_file(env_path: Path):
data = {}
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def split_sql_statements(sql_text: str):
statements = []
buf = []
in_single = False
in_double = False
in_line_comment = False
in_block_comment = False
i = 0
while i < len(sql_text):
ch = sql_text[i]
nxt = sql_text[i + 1] if i + 1 < len(sql_text) else ""
if in_line_comment:
if ch == "\n":
in_line_comment = False
buf.append(ch)
i += 1
continue
if in_block_comment:
if ch == "*" and nxt == "/":
in_block_comment = False
i += 2
else:
i += 1
continue
if not in_single and not in_double:
if ch == "-" and nxt == "-":
in_line_comment = True
i += 2
continue
if ch == "#":
in_line_comment = True
i += 1
continue
if ch == "/" and nxt == "*":
in_block_comment = True
i += 2
continue
if ch == "'" and not in_double:
in_single = not in_single
buf.append(ch)
i += 1
continue
if ch == '"' and not in_single:
in_double = not in_double
buf.append(ch)
i += 1
continue
if ch == ";" and not in_single and not in_double:
stmt = "".join(buf).strip()
if stmt:
statements.append(stmt)
buf = []
i += 1
continue
buf.append(ch)
i += 1
tail = "".join(buf).strip()
if tail:
statements.append(tail)
return statements
def main():
parser = argparse.ArgumentParser(description="Run SQL migration using mysql-connector")
parser.add_argument("--env", default="backend/.env", help="Path to .env file")
parser.add_argument("--sql", required=True, help="Path to SQL file")
args = parser.parse_args()
env = parse_env_file(Path(args.env))
sql_path = Path(args.sql)
if not sql_path.exists():
print(f"[ERROR] SQL file not found: {sql_path}")
return 1
sql_text = sql_path.read_text(encoding="utf-8")
statements = split_sql_statements(sql_text)
if not statements:
print("[ERROR] No SQL statements found")
return 1
conn = mysql.connector.connect(
host=env.get("DB_HOST", "127.0.0.1"),
port=int(env.get("DB_PORT", "3306")),
user=env.get("DB_USER", "root"),
password=env.get("DB_PASSWORD", ""),
database=env.get("DB_NAME", ""),
)
try:
cur = conn.cursor(dictionary=True)
print(f"[INFO] Running {len(statements)} statements from {sql_path}")
for idx, stmt in enumerate(statements, start=1):
head = " ".join(stmt.split())[:120]
cur.execute(stmt)
if cur.with_rows:
cur.fetchall()
print(f"[OK] {idx:03d}: {head}")
conn.commit()
print("[INFO] Migration committed successfully")
except Exception as e:
conn.rollback()
print(f"[FAIL] {e}")
return 2
finally:
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,7 +1,7 @@
-- ===================================================================
-- 菜单权限系统数据库迁移脚本
-- 创建日期: 2025-12-10
-- 说明: 添加 menus 表和 role_menu_permissions 表,实现基于角色的菜单权限管理
-- 说明: 添加 menus 表和 role_menu_permissions 表,实现基于角色的多级菜单权限管理
-- ===================================================================
-- ----------------------------
@ -16,15 +16,21 @@ CREATE TABLE `menus` (
`menu_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单URL/路由',
`menu_type` enum('action','link','divider') COLLATE utf8mb4_unicode_ci DEFAULT 'action' COMMENT '菜单类型: action-操作/link-链接/divider-分隔符',
`parent_id` int(11) DEFAULT NULL COMMENT '父菜单ID用于层级菜单',
`menu_level` tinyint(3) NOT NULL DEFAULT 1 COMMENT '菜单层级根节点为1',
`tree_path` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '树路径(如 /3/6',
`sort_order` int(11) DEFAULT 0 COMMENT '排序顺序',
`is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用: 1-启用, 0-禁用',
`is_visible` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否在侧边菜单显示',
`description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单描述',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`menu_id`),
UNIQUE KEY `uk_menu_code` (`menu_code`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_is_active` (`is_active`)
KEY `idx_menu_level` (`menu_level`),
KEY `idx_tree_path` (`tree_path`),
KEY `idx_is_active` (`is_active`),
KEY `idx_is_visible` (`is_visible`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表';
-- ----------------------------
@ -35,28 +41,66 @@ CREATE TABLE `role_menu_permissions` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '权限ID',
`role_id` int(11) NOT NULL COMMENT '角色ID',
`menu_id` int(11) NOT NULL COMMENT '菜单ID',
`granted_by` int(11) DEFAULT NULL COMMENT '授权操作人ID',
`granted_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_menu` (`role_id`,`menu_id`),
KEY `idx_role_id` (`role_id`),
KEY `idx_menu_id` (`menu_id`),
KEY `idx_granted_by` (`granted_by`),
KEY `idx_granted_at` (`granted_at`),
CONSTRAINT `fk_rmp_role_id` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`) ON DELETE CASCADE,
CONSTRAINT `fk_rmp_menu_id` FOREIGN KEY (`menu_id`) REFERENCES `menus` (`menu_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表';
-- ----------------------------
-- 初始化菜单数据(基于现有系统的下拉菜单)
-- 初始化菜单数据
-- ----------------------------
BEGIN;
-- 用户菜单项
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `sort_order`, `is_active`, `description`)
-- 一级菜单
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
VALUES
('change_password', '修改密码', 'KeyRound', NULL, 'action', 1, 1, '用户修改自己的密码'),
('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', 2, 1, '管理AI提示词模版'),
('platform_admin', '平台管理', 'Shield', '/admin/management', 'link', 3, 1, '平台管理员后台'),
('logout', '退出登录', 'LogOut', NULL, 'action', 99, 1, '退出当前账号');
('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, NULL, 1, 1, 1, '管理个人账户信息'),
('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 1, NULL, 2, 1, 1, '管理AI提示词模版'),
('platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 1, NULL, 3, 1, 1, '平台管理员后台'),
('logout', '退出登录', 'LogOut', NULL, 'action', NULL, 1, NULL, 99, 1, 1, '退出当前账号');
-- 二级菜单(挂载到平台管理)
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link', menu_id, 2, NULL, 1, 1, 1, '账号、角色、密码重置'
FROM `menus` WHERE `menu_code` = 'platform_admin';
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link', menu_id, 2, NULL, 2, 1, 1, '菜单与角色授权矩阵'
FROM `menus` WHERE `menu_code` = 'platform_admin';
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link', menu_id, 2, NULL, 3, 1, 1, '码表、平台类型、扩展属性'
FROM `menus` WHERE `menu_code` = 'platform_admin';
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link', menu_id, 2, NULL, 4, 1, 1, 'ASR 热词与同步'
FROM `menus` WHERE `menu_code` = 'platform_admin';
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link', menu_id, 2, NULL, 5, 1, 1, '版本、下载地址、发布状态'
FROM `menus` WHERE `menu_code` = 'platform_admin';
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link', menu_id, 2, NULL, 6, 1, 1, '外部系统入口与图标配置'
FROM `menus` WHERE `menu_code` = 'platform_admin';
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT 'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link', menu_id, 2, NULL, 7, 1, 1, '专用设备、激活和绑定状态'
FROM `menus` WHERE `menu_code` = 'platform_admin';
-- 回填路径
UPDATE `menus` SET `tree_path` = CONCAT('/', `menu_id`) WHERE `parent_id` IS NULL;
UPDATE `menus` c JOIN `menus` p ON c.`parent_id` = p.`menu_id`
SET c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`);
COMMIT;
@ -70,30 +114,19 @@ BEGIN;
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
SELECT 1, menu_id FROM `menus` WHERE is_active = 1;
-- 普通用户role_id=2拥有除"平台管理"外的所有菜单权限
-- 普通用户role_id=2排除平台管理与其二级子菜单
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
SELECT 2, menu_id FROM `menus` WHERE menu_code != 'platform_admin' AND is_active = 1;
SELECT 2, menu_id FROM `menus`
WHERE is_active = 1
AND menu_code NOT IN (
'platform_admin',
'user_management',
'permission_management',
'dict_management',
'hot_word_management',
'client_management',
'external_app_management',
'terminal_management'
);
COMMIT;
-- ----------------------------
-- 查询验证
-- ----------------------------
-- 查看所有菜单
-- SELECT * FROM menus ORDER BY sort_order;
-- 查看平台管理员的菜单权限
-- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url
-- FROM role_menu_permissions rmp
-- JOIN roles r ON rmp.role_id = r.role_id
-- JOIN menus m ON rmp.menu_id = m.menu_id
-- WHERE r.role_id = 1
-- ORDER BY m.sort_order;
-- 查看普通用户的菜单权限
-- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url
-- FROM role_menu_permissions rmp
-- JOIN roles r ON rmp.role_id = r.role_id
-- JOIN menus m ON rmp.menu_id = m.menu_id
-- WHERE r.role_id = 2
-- ORDER BY m.sort_order;

View File

@ -544,7 +544,7 @@ CREATE TABLE `menus` (
UNIQUE KEY `uk_menu_code` (`menu_code`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_is_active` (`is_active`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表';
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表';
-- ----------------------------
-- Records of menus
@ -552,8 +552,15 @@ CREATE TABLE `menus` (
BEGIN;
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (1, 'account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, 1, '管理个人账户信息', '2025-12-10 15:31:45', '2026-01-15 07:48:05');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (2, 'prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 2, 1, '管理AI提示词模版', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (3, 'platform_admin', '平台管理', 'Shield', '/admin/management', 'link', NULL, 3, 1, '平台管理员后台', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (3, 'platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 3, 1, '平台管理员后台', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (4, 'logout', '退出登录', 'LogOut', NULL, 'action', NULL, 99, 1, '退出当前账号', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (5, 'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link', 3, 1, 1, '账号、角色、密码重置', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (6, 'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link', 3, 2, 1, '菜单与角色授权矩阵', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (7, 'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link', 3, 3, 1, '码表、平台类型、扩展属性', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (8, 'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link', 3, 4, 1, 'ASR 热词与同步', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (9, 'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link', 3, 5, 1, '版本、下载地址、发布状态', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (10, 'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link', 3, 6, 1, '外部系统入口与图标配置', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
INSERT INTO `menus` (`menu_id`, `menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`, `created_at`, `updated_at`) VALUES (11, 'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link', 3, 7, 1, '专用设备、激活和绑定状态', '2025-12-10 15:31:45', '2025-12-10 15:31:45');
COMMIT;
-- ----------------------------
@ -607,7 +614,7 @@ CREATE TABLE `role_menu_permissions` (
KEY `idx_menu_id` (`menu_id`),
CONSTRAINT `fk_rmp_menu_id` FOREIGN KEY (`menu_id`) REFERENCES `menus` (`menu_id`) ON DELETE CASCADE,
CONSTRAINT `fk_rmp_role_id` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表';
) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表';
-- ----------------------------
-- Records of role_menu_permissions
@ -617,8 +624,16 @@ INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (12, 1, 2, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (13, 1, 3, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (14, 1, 4, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (15, 2, 1, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (16, 2, 4, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (15, 1, 5, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (16, 1, 6, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (17, 1, 7, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (18, 1, 8, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (19, 1, 9, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (20, 1, 10, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (21, 1, 11, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (22, 2, 1, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (23, 2, 2, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
INSERT INTO `role_menu_permissions` (`id`, `role_id`, `menu_id`, `created_at`, `updated_at`) VALUES (24, 2, 4, '2025-12-11 11:00:15', '2025-12-11 11:00:15');
COMMIT;
-- ----------------------------

View File

@ -0,0 +1,24 @@
ALTER TABLE `audio_model_config`
ADD COLUMN `extra_config` JSON DEFAULT NULL COMMENT '音频模型差异化配置(JSON)' AFTER `hot_word_group_id`;
UPDATE `audio_model_config`
SET `extra_config` = CASE
WHEN `audio_scene` = 'asr' THEN JSON_OBJECT(
'model', `asr_model_name`,
'vocabulary_id', `asr_vocabulary_id`,
'speaker_count', `asr_speaker_count`,
'language_hints', `asr_language_hints`,
'disfluency_removal_enabled', `asr_disfluency_removal_enabled`,
'diarization_enabled', `asr_diarization_enabled`
)
WHEN `audio_scene` = 'voiceprint' THEN JSON_OBJECT(
'model', `model_name`,
'template_text', `vp_template_text`,
'duration_seconds', `vp_duration_seconds`,
'sample_rate', `vp_sample_rate`,
'channels', `vp_channels`,
'max_size_bytes', `vp_max_size_bytes`
)
ELSE JSON_OBJECT()
END
WHERE `extra_config` IS NULL;

View File

@ -0,0 +1,58 @@
START TRANSACTION;
SET @meeting_manage_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'meeting_manage' LIMIT 1);
SET @history_meetings_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'history_meetings' LIMIT 1);
SET @prompt_config_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'prompt_config' LIMIT 1);
SET @personal_prompt_library_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'personal_prompt_library' LIMIT 1);
INSERT INTO sys_menus (
menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description
)
SELECT
'history_meetings', '历史会议', 'CalendarOutlined', '/meetings/history', 'link', @meeting_manage_id, 1, 1, '普通用户历史会议'
FROM DUAL
WHERE @meeting_manage_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'history_meetings');
SET @history_meetings_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'history_meetings' LIMIT 1);
UPDATE sys_menus
SET
parent_id = @meeting_manage_id,
sort_order = 2,
menu_name = '提示词配置',
menu_icon = COALESCE(menu_icon, 'BookOutlined')
WHERE menu_code = 'prompt_config';
INSERT INTO sys_menus (
menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description
)
SELECT
'personal_prompt_library', '个人提示词仓库', 'ReadOutlined', '/personal-prompts', 'link', @meeting_manage_id, 3, 1, '普通用户个人提示词仓库'
FROM DUAL
WHERE @meeting_manage_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'personal_prompt_library');
SET @personal_prompt_library_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'personal_prompt_library' LIMIT 1);
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 2, @meeting_manage_id, NOW()
FROM DUAL
WHERE @meeting_manage_id IS NOT NULL;
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 2, @history_meetings_id, NOW()
FROM DUAL
WHERE @history_meetings_id IS NOT NULL;
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 2, @prompt_config_id, NOW()
FROM DUAL
WHERE @prompt_config_id IS NOT NULL;
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 2, @personal_prompt_library_id, NOW()
FROM DUAL
WHERE @personal_prompt_library_id IS NOT NULL;
COMMIT;

View File

@ -0,0 +1,173 @@
-- Migration: create parameter/model management and migrate system_config
-- Created at: 2026-03-12
BEGIN;
CREATE TABLE IF NOT EXISTS `sys_system_parameters` (
`param_id` bigint(20) NOT NULL AUTO_INCREMENT,
`param_key` varchar(128) NOT NULL,
`param_name` varchar(255) NOT NULL,
`param_value` text,
`value_type` varchar(32) NOT NULL DEFAULT 'string',
`category` varchar(64) NOT NULL DEFAULT 'system',
`description` varchar(500) DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`param_id`),
UNIQUE KEY `uk_param_key` (`param_key`),
KEY `idx_param_category` (`category`),
KEY `idx_param_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `ai_model_configs` (
`model_id` bigint(20) NOT NULL AUTO_INCREMENT,
`model_code` varchar(128) NOT NULL,
`model_name` varchar(255) NOT NULL,
`model_type` varchar(32) NOT NULL,
`provider` varchar(64) DEFAULT NULL,
`config_json` json DEFAULT NULL,
`description` varchar(500) DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`model_id`),
UNIQUE KEY `uk_model_code` (`model_code`),
KEY `idx_model_type` (`model_type`),
KEY `idx_model_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrate system_config parameters except model-like records
INSERT INTO `sys_system_parameters` (`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`)
SELECT
d.`dict_code`,
d.`label_cn`,
JSON_UNQUOTE(JSON_EXTRACT(d.`extension_attr`, '$.value')),
'string',
'system',
CONCAT('migrated from dict_data.system_config(', d.`dict_code`, ')'),
CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END
FROM `sys_dict_data` d
WHERE d.`dict_type` = 'system_config'
AND d.`dict_code` NOT IN ('llm_model', 'voiceprint')
AND JSON_EXTRACT(d.`extension_attr`, '$.value') IS NOT NULL
ON DUPLICATE KEY UPDATE
`param_name` = VALUES(`param_name`),
`param_value` = VALUES(`param_value`),
`is_active` = VALUES(`is_active`);
-- migrate llm model
INSERT INTO `ai_model_configs` (`model_code`, `model_name`, `model_type`, `provider`, `config_json`, `description`, `is_active`, `is_default`)
SELECT
'llm_model',
'LLM文本模型',
'llm',
'dashscope',
d.`extension_attr`,
'migrated from dict_data.system_config.llm_model',
CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END,
1
FROM `sys_dict_data` d
WHERE d.`dict_type` = 'system_config'
AND d.`dict_code` = 'llm_model'
LIMIT 1
ON DUPLICATE KEY UPDATE
`config_json` = VALUES(`config_json`),
`is_active` = VALUES(`is_active`);
-- migrate audio model (voiceprint)
INSERT INTO `ai_model_configs` (`model_code`, `model_name`, `model_type`, `provider`, `config_json`, `description`, `is_active`, `is_default`)
SELECT
'voiceprint_model',
'声纹模型',
'audio',
'funasr',
d.`extension_attr`,
'migrated from dict_data.system_config.voiceprint',
CASE WHEN d.`status` = 1 THEN 1 ELSE 0 END,
1
FROM `sys_dict_data` d
WHERE d.`dict_type` = 'system_config'
AND d.`dict_code` = 'voiceprint'
LIMIT 1
ON DUPLICATE KEY UPDATE
`config_json` = VALUES(`config_json`),
`is_active` = VALUES(`is_active`);
-- ensure audio ASR model exists (from current hard-coded settings)
INSERT INTO `ai_model_configs` (`model_code`, `model_name`, `model_type`, `provider`, `config_json`, `description`, `is_active`, `is_default`)
SELECT
'audio_model',
'音频识别模型',
'audio',
'dashscope',
JSON_OBJECT(
'model', 'paraformer-v2',
'language_hints', JSON_ARRAY('zh', 'en'),
'disfluency_removal_enabled', TRUE,
'diarization_enabled', TRUE,
'speaker_count', 10,
'vocabulary_id', (
SELECT JSON_UNQUOTE(JSON_EXTRACT(extension_attr, '$.value'))
FROM sys_dict_data
WHERE dict_type = 'system_config' AND dict_code = 'asr_vocabulary_id'
LIMIT 1
)
),
'默认音频识别模型',
1,
1
FROM dual
WHERE NOT EXISTS (
SELECT 1 FROM ai_model_configs WHERE model_code = 'audio_model'
);
-- add new platform submenus
INSERT IGNORE INTO `sys_menus`
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT
'parameter_management',
'参数管理',
'Setting',
'/admin/management/parameter-management',
'link',
m.`menu_id`,
8,
1,
1,
'系统参数管理'
FROM `sys_menus` m
WHERE m.`menu_code` = 'platform_admin';
INSERT IGNORE INTO `sys_menus`
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT
'model_management',
'模型管理',
'Appstore',
'/admin/management/model-management',
'link',
m.`menu_id`,
9,
1,
1,
'音频/LLM模型配置管理'
FROM `sys_menus` m
WHERE m.`menu_code` = 'platform_admin';
-- role 1 gets full access
INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`)
SELECT 1, m.menu_id, 1
FROM sys_menus m
WHERE m.menu_code IN ('parameter_management', 'model_management');
-- backfill menu tree metadata for newly inserted rows
UPDATE `sys_menus` c
JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id`
SET c.`menu_level` = p.`menu_level` + 1,
c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`)
WHERE c.`menu_code` IN ('parameter_management', 'model_management')
AND (c.`tree_path` IS NULL OR c.`menu_level` = 1);
COMMIT;

View File

@ -0,0 +1,25 @@
-- Migration: add system management root menu and regroup selected modules
-- Created at: 2026-03-12
BEGIN;
-- ensure system_management root menu exists
INSERT INTO sys_menus
(menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, menu_level, tree_path, sort_order, is_active, is_visible, description)
SELECT
'system_management',
'系统管理',
'Setting',
'/admin/management/user-management',
'link',
NULL,
1,
NULL,
4,
1,
1,
'系统基础配置管理(用户、权限、字段、参数)'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'system_management');
COMMIT;

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `sys_user_mcp` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`bot_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`bot_secret` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` tinyint(1) NOT NULL DEFAULT 1,
`last_used_at` datetime DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sys_user_mcp_user_id` (`user_id`),
UNIQUE KEY `uk_sys_user_mcp_bot_id` (`bot_id`),
KEY `idx_sys_user_mcp_status` (`status`),
CONSTRAINT `fk_sys_user_mcp_user` FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户MCP接入凭证';

View File

@ -0,0 +1,52 @@
-- Migration: expand ai_model_configs with structured columns
-- Created at: 2026-03-12
BEGIN;
ALTER TABLE `ai_model_configs`
ADD COLUMN `endpoint_url` varchar(512) DEFAULT NULL COMMENT '模型服务 API 地址' AFTER `provider`,
ADD COLUMN `api_key` varchar(512) DEFAULT NULL COMMENT '模型服务 API Key' AFTER `endpoint_url`,
ADD COLUMN `llm_model_name` varchar(128) DEFAULT NULL COMMENT 'LLM 模型名称' AFTER `api_key`,
ADD COLUMN `llm_timeout` int(11) DEFAULT NULL COMMENT 'LLM 超时(秒)' AFTER `llm_model_name`,
ADD COLUMN `llm_temperature` decimal(5,2) DEFAULT NULL COMMENT 'LLM temperature' AFTER `llm_timeout`,
ADD COLUMN `llm_top_p` decimal(5,2) DEFAULT NULL COMMENT 'LLM top_p' AFTER `llm_temperature`,
ADD COLUMN `llm_max_tokens` int(11) DEFAULT NULL COMMENT 'LLM 最大token' AFTER `llm_top_p`,
ADD COLUMN `llm_system_prompt` text DEFAULT NULL COMMENT 'LLM 系统提示词' AFTER `llm_max_tokens`,
ADD COLUMN `asr_model_name` varchar(128) DEFAULT NULL COMMENT 'ASR 模型名称' AFTER `llm_system_prompt`,
ADD COLUMN `asr_vocabulary_id` varchar(255) DEFAULT NULL COMMENT 'ASR 热词词表ID' AFTER `asr_model_name`,
ADD COLUMN `asr_speaker_count` int(11) DEFAULT NULL COMMENT 'ASR 说话人数' AFTER `asr_vocabulary_id`,
ADD COLUMN `asr_language_hints` varchar(255) DEFAULT NULL COMMENT 'ASR 语言提示,逗号分隔' AFTER `asr_speaker_count`,
ADD COLUMN `asr_disfluency_removal_enabled` tinyint(1) DEFAULT NULL COMMENT 'ASR 去口头语开关' AFTER `asr_language_hints`,
ADD COLUMN `asr_diarization_enabled` tinyint(1) DEFAULT NULL COMMENT 'ASR 说话人分离开关' AFTER `asr_disfluency_removal_enabled`;
-- backfill structured columns from existing config_json
UPDATE `ai_model_configs`
SET
endpoint_url = COALESCE(endpoint_url, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.endpoint_url'))),
api_key = COALESCE(api_key, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.api_key')))
WHERE config_json IS NOT NULL;
UPDATE `ai_model_configs`
SET
llm_model_name = COALESCE(llm_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model_name'))),
llm_timeout = COALESCE(llm_timeout, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.time_out')) AS UNSIGNED)),
llm_temperature = COALESCE(llm_temperature, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.temperature')) AS DECIMAL(5,2))),
llm_top_p = COALESCE(llm_top_p, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.top_p')) AS DECIMAL(5,2))),
llm_max_tokens = COALESCE(llm_max_tokens, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.max_tokens')) AS UNSIGNED)),
llm_system_prompt = COALESCE(llm_system_prompt, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.system_prompt')))
WHERE model_type = 'llm' AND config_json IS NOT NULL;
UPDATE `ai_model_configs`
SET
asr_model_name = COALESCE(asr_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model'))),
asr_vocabulary_id = COALESCE(asr_vocabulary_id, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.vocabulary_id'))),
asr_speaker_count = COALESCE(asr_speaker_count, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.speaker_count')) AS UNSIGNED)),
asr_language_hints = COALESCE(
asr_language_hints,
REPLACE(REPLACE(REPLACE(JSON_EXTRACT(config_json, '$.language_hints'), '\"', ''), '[', ''), ']', '')
),
asr_disfluency_removal_enabled = COALESCE(asr_disfluency_removal_enabled, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.disfluency_removal_enabled')) AS UNSIGNED)),
asr_diarization_enabled = COALESCE(asr_diarization_enabled, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.diarization_enabled')) AS UNSIGNED))
WHERE model_type = 'audio' AND config_json IS NOT NULL;
COMMIT;

View File

@ -0,0 +1,87 @@
-- Migration: expand sys_menus and sys_role_menu_permissions for menu-tree governance
-- Created at: 2026-03-03
BEGIN;
-- 1) Extend sys_menus table
ALTER TABLE `sys_menus`
ADD COLUMN `menu_level` tinyint(3) NOT NULL DEFAULT 1 COMMENT '菜单层级根节点为1' AFTER `parent_id`,
ADD COLUMN `tree_path` varchar(255) DEFAULT NULL COMMENT '树路径(如 /3/6' AFTER `menu_level`,
ADD COLUMN `is_visible` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否在侧边菜单显示' AFTER `is_active`;
ALTER TABLE `sys_menus`
ADD KEY `idx_menu_level` (`menu_level`),
ADD KEY `idx_tree_path` (`tree_path`),
ADD KEY `idx_is_visible` (`is_visible`);
-- 2) Extend sys_role_menu_permissions table
ALTER TABLE `sys_role_menu_permissions`
ADD COLUMN `granted_by` int(11) DEFAULT NULL COMMENT '授权操作人ID' AFTER `menu_id`,
ADD COLUMN `granted_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间' AFTER `granted_by`;
ALTER TABLE `sys_role_menu_permissions`
ADD KEY `idx_granted_by` (`granted_by`),
ADD KEY `idx_granted_at` (`granted_at`);
-- 3) Backfill tree metadata (supports current 1~2 level menus)
UPDATE `sys_menus`
SET `menu_level` = 1,
`tree_path` = CONCAT('/', `menu_id`)
WHERE `parent_id` IS NULL;
UPDATE `sys_menus` c
JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id`
SET c.`menu_level` = p.`menu_level` + 1,
c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`);
-- 4) Add sample child menus under existing modules
INSERT INTO `sys_menus`
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`, `sort_order`, `is_active`, `is_visible`, `description`)
SELECT
'permission_menu_tree',
'菜单树维护',
'AppstoreAdd',
'/admin/management/permission-management',
'link',
m.`menu_id`,
3,
NULL,
20,
1,
0,
'权限管理中的菜单树维护入口(隐藏于侧栏)'
FROM `sys_menus` m
WHERE m.`menu_code` = 'permission_management'
AND NOT EXISTS (SELECT 1 FROM `sys_menus` WHERE `menu_code` = 'permission_menu_tree');
-- backfill tree_path for newly inserted rows
UPDATE `sys_menus` c
JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id`
SET c.`menu_level` = p.`menu_level` + 1,
c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`)
WHERE c.`tree_path` IS NULL;
-- 5) Align permissions: role 1 owns all active menus
INSERT IGNORE INTO `sys_role_menu_permissions` (`role_id`, `menu_id`, `granted_by`)
SELECT 1, m.`menu_id`, 1
FROM `sys_menus` m
WHERE m.`is_active` = 1;
-- role 2 excludes platform admin tree
DELETE rmp
FROM `sys_role_menu_permissions` rmp
JOIN `sys_menus` m ON m.`menu_id` = rmp.`menu_id`
WHERE rmp.`role_id` = 2
AND m.`menu_code` IN (
'platform_admin',
'user_management',
'permission_management',
'dict_management',
'hot_word_management',
'client_management',
'external_app_management',
'terminal_management',
'permission_menu_tree'
);
COMMIT;

View File

@ -0,0 +1,8 @@
START TRANSACTION;
UPDATE sys_menus
SET is_visible = 1,
is_active = 1
WHERE menu_code IN ('dashboard', 'desktop');
COMMIT;

View File

@ -0,0 +1,24 @@
-- 让所有角色都能看到 Dashboard 和 Desktop 菜单
-- Dashboard sort_order=1, Desktop sort_order=2
START TRANSACTION;
SET @dashboard_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'dashboard' LIMIT 1);
SET @desktop_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'desktop' LIMIT 1);
-- 确保 sort_order 有序
UPDATE sys_menus SET sort_order = 1 WHERE menu_code = 'dashboard';
UPDATE sys_menus SET sort_order = 2 WHERE menu_code = 'desktop';
-- 为 role_id=1 (admin) 补充 desktop 权限
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 1, @desktop_id, NOW()
FROM DUAL
WHERE @desktop_id IS NOT NULL;
-- 为 role_id=2 (普通用户) 补充 dashboard 权限
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 2, @dashboard_id, NOW()
FROM DUAL
WHERE @dashboard_id IS NOT NULL;
COMMIT;

View File

@ -0,0 +1,12 @@
START TRANSACTION;
SET @personal_prompt_library_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'personal_prompt_library' LIMIT 1);
DELETE FROM sys_role_menu_permissions
WHERE menu_id = @personal_prompt_library_id;
UPDATE sys_menus
SET is_active = 0
WHERE menu_id = @personal_prompt_library_id;
COMMIT;

View File

@ -0,0 +1,88 @@
START TRANSACTION;
SET @dashboard_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'dashboard' LIMIT 1);
SET @desktop_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'desktop' LIMIT 1);
SET @account_settings_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'account_settings' LIMIT 1);
SET @logout_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'logout' LIMIT 1);
UPDATE sys_menus
SET
menu_code = 'dashboard',
menu_name = 'Dashboard',
menu_icon = 'DashboardOutlined',
menu_url = '/dashboard',
menu_type = 'link',
parent_id = NULL,
sort_order = 1,
is_active = 1,
description = '管理员桌面'
WHERE @dashboard_id IS NULL
AND menu_id = @account_settings_id;
INSERT INTO sys_menus (
menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description
)
SELECT
'dashboard', 'Dashboard', 'DashboardOutlined', '/dashboard', 'link', NULL, 1, 1, '管理员桌面'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM sys_menus WHERE menu_code = 'dashboard'
);
SET @dashboard_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'dashboard' LIMIT 1);
UPDATE sys_menus
SET
menu_code = 'desktop',
menu_name = 'Desktop',
menu_icon = 'DesktopOutlined',
menu_url = '/dashboard',
menu_type = 'link',
parent_id = NULL,
sort_order = 1,
is_active = 1,
description = '普通用户桌面'
WHERE @desktop_id IS NULL
AND menu_id = @logout_id;
INSERT INTO sys_menus (
menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description
)
SELECT
'desktop', 'Desktop', 'DesktopOutlined', '/dashboard', 'link', NULL, 1, 1, '普通用户桌面'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM sys_menus WHERE menu_code = 'desktop'
);
SET @desktop_id := (SELECT menu_id FROM sys_menus WHERE menu_code = 'desktop' LIMIT 1);
UPDATE sys_menus
SET sort_order = 2
WHERE menu_code = 'meeting_manage';
DELETE rmp
FROM sys_role_menu_permissions rmp
JOIN sys_menus m ON m.menu_id = rmp.menu_id
WHERE m.menu_code IN ('account_settings', 'logout');
DELETE FROM sys_menus
WHERE menu_code IN ('account_settings', 'logout');
DELETE FROM sys_role_menu_permissions
WHERE role_id = 1 AND menu_id = @desktop_id;
DELETE FROM sys_role_menu_permissions
WHERE role_id = 2 AND menu_id = @dashboard_id;
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 1, @dashboard_id, NOW()
FROM DUAL
WHERE @dashboard_id IS NOT NULL;
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id, granted_at)
SELECT 2, @desktop_id, NOW()
FROM DUAL
WHERE @desktop_id IS NOT NULL;
COMMIT;

View File

@ -0,0 +1,24 @@
-- Migration: optimize menu loading performance
-- Created at: 2026-03-12
BEGIN;
-- 1) remove duplicate role-menu mapping rows to allow unique key
DELETE r1
FROM role_menu_permissions r1
JOIN role_menu_permissions r2
ON r1.role_id = r2.role_id
AND r1.menu_id = r2.menu_id
AND r1.id > r2.id;
-- 2) speed up role-menu lookup and prevent duplicate permission rows
ALTER TABLE `role_menu_permissions`
ADD UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
ADD KEY `idx_rmp_role` (`role_id`),
ADD KEY `idx_rmp_menu` (`menu_id`);
-- 3) speed up visible active menu ordering by parent/sort
ALTER TABLE `menus`
ADD KEY `idx_menus_visible_tree` (`is_active`, `is_visible`, `parent_id`, `sort_order`, `menu_id`);
COMMIT;

View File

@ -0,0 +1,12 @@
-- Migration: optimize user management query performance
-- Created at: 2026-03-12
BEGIN;
ALTER TABLE `meetings`
ADD KEY `idx_meetings_user_id` (`user_id`);
ALTER TABLE `attendees`
ADD KEY `idx_attendees_user_id` (`user_id`);
COMMIT;

View File

@ -0,0 +1,67 @@
-- 热词管理:单表 → 主从表(热词组 + 热词条目)
-- 执行前请备份 hot_words 表
-- 1. 创建热词组主表
CREATE TABLE IF NOT EXISTS `hot_word_group` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL COMMENT '热词组名称',
`description` VARCHAR(500) DEFAULT NULL COMMENT '描述',
`vocabulary_id` VARCHAR(255) DEFAULT NULL COMMENT '阿里云 DashScope 词表ID',
`last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1:启用 0:停用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='热词组主表';
-- 2. 创建热词条目从表
CREATE TABLE IF NOT EXISTS `hot_word_item` (
`id` INT NOT NULL AUTO_INCREMENT,
`group_id` INT NOT NULL COMMENT '热词组ID',
`text` VARCHAR(255) NOT NULL COMMENT '热词内容',
`weight` INT NOT NULL DEFAULT 4 COMMENT '权重 1-10',
`lang` VARCHAR(20) NOT NULL DEFAULT 'zh' COMMENT 'zh/en',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1:启用 0:停用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_group_id` (`group_id`),
UNIQUE KEY `idx_group_text` (`group_id`, `text`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='热词条目从表';
-- 3. audio_model_config 新增 hot_word_group_id 列
ALTER TABLE `audio_model_config`
ADD COLUMN `hot_word_group_id` INT DEFAULT NULL COMMENT '关联热词组ID'
AFTER `asr_vocabulary_id`;
-- 4. 数据迁移:将现有 hot_words 数据迁移到默认组
INSERT INTO `hot_word_group` (`name`, `description`, `status`)
SELECT '默认热词组', '从旧 hot_words 表迁移的热词', 1
FROM DUAL
WHERE EXISTS (SELECT 1 FROM `hot_words` LIMIT 1);
-- 将旧表中已有的 vocabulary_id 回填到默认组(如果存在于 sys_system_parameters
UPDATE `hot_word_group` g
JOIN (
SELECT param_value FROM `sys_system_parameters`
WHERE param_key = 'asr_vocabulary_id' AND is_active = 1
LIMIT 1
) p ON 1=1
SET g.vocabulary_id = p.param_value,
g.last_sync_time = NOW()
WHERE g.name = '默认热词组';
-- 迁移热词条目
INSERT INTO `hot_word_item` (`group_id`, `text`, `weight`, `lang`, `status`, `create_time`, `update_time`)
SELECT g.id, hw.text, hw.weight, hw.lang, hw.status, hw.create_time, hw.update_time
FROM `hot_words` hw
CROSS JOIN `hot_word_group` g
WHERE g.name = '默认热词组';
-- 5. 将已有 ASR 模型配置关联到默认组
UPDATE `audio_model_config` a
JOIN `hot_word_group` g ON g.name = '默认热词组'
SET a.hot_word_group_id = g.id
WHERE a.audio_scene = 'asr'
AND a.asr_vocabulary_id IS NOT NULL
AND a.asr_vocabulary_id != '';

View File

@ -0,0 +1,12 @@
START TRANSACTION;
UPDATE sys_menus
SET
menu_code = 'meeting_center',
menu_name = '会议中心',
menu_icon = 'CalendarOutlined',
menu_url = '/meetings/center',
description = '普通用户会议中心'
WHERE menu_code = 'history_meetings';
COMMIT;

View File

@ -0,0 +1,157 @@
-- Migration: rename model config tables to singular naming
-- Target names:
-- llm_model_config
-- audio_model_config
SET @rename_llm_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'llm_model_configs'
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config'
),
'RENAME TABLE llm_model_configs TO llm_model_config',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_llm_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @rename_audio_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'audio_model_configs'
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'audio_model_config'
),
'RENAME TABLE audio_model_configs TO audio_model_config',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_audio_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Remove possible redundant audio/voiceprint fields from llm table (idempotent)
SET @drop_audio_scene_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'audio_scene'
),
'ALTER TABLE llm_model_config DROP COLUMN audio_scene',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_audio_scene_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_asr_model_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'asr_model_name'
),
'ALTER TABLE llm_model_config DROP COLUMN asr_model_name',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_asr_model_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_asr_vocab_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'asr_vocabulary_id'
),
'ALTER TABLE llm_model_config DROP COLUMN asr_vocabulary_id',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_asr_vocab_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_vp_tpl_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_template_text'
),
'ALTER TABLE llm_model_config DROP COLUMN vp_template_text',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_vp_tpl_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_vp_duration_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_duration_seconds'
),
'ALTER TABLE llm_model_config DROP COLUMN vp_duration_seconds',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_vp_duration_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_vp_rate_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_sample_rate'
),
'ALTER TABLE llm_model_config DROP COLUMN vp_sample_rate',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_vp_rate_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_vp_channels_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_channels'
),
'ALTER TABLE llm_model_config DROP COLUMN vp_channels',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_vp_channels_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @drop_vp_size_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'llm_model_config' AND column_name = 'vp_max_size_bytes'
),
'ALTER TABLE llm_model_config DROP COLUMN vp_max_size_bytes',
'SELECT 1'
)
);
PREPARE stmt FROM @drop_vp_size_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Clean potential non-LLM rows in llm table
DELETE FROM llm_model_config
WHERE model_code IN ('audio_model', 'voiceprint_model');

View File

@ -0,0 +1,31 @@
-- Migration: rename user prompt config table and adjust prompt_config menu URL
-- Created at: 2026-03-13
BEGIN;
SET @rename_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'sys_user_prompt_config'
AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'prompt_config'
AND table_type = 'BASE TABLE'
),
'RENAME TABLE sys_user_prompt_config TO prompt_config',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE sys_menus
SET menu_url = '/prompt-config'
WHERE menu_code = 'prompt_config';
COMMIT;

View File

@ -0,0 +1,170 @@
-- Migration: split LLM and audio model configs into dedicated tables
-- Created at: 2026-03-12
BEGIN;
CREATE TABLE IF NOT EXISTS `llm_model_config` (
`config_id` bigint(20) NOT NULL AUTO_INCREMENT,
`model_code` varchar(128) NOT NULL,
`model_name` varchar(255) NOT NULL,
`provider` varchar(64) DEFAULT NULL,
`endpoint_url` varchar(512) DEFAULT NULL,
`api_key` varchar(512) DEFAULT NULL,
`llm_model_name` varchar(128) NOT NULL,
`llm_timeout` int(11) NOT NULL DEFAULT 120,
`llm_temperature` decimal(5,2) NOT NULL DEFAULT 0.70,
`llm_top_p` decimal(5,2) NOT NULL DEFAULT 0.90,
`llm_max_tokens` int(11) NOT NULL DEFAULT 2048,
`llm_system_prompt` text DEFAULT NULL,
`description` varchar(500) DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_llm_model_code` (`model_code`),
KEY `idx_llm_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `audio_model_config` (
`config_id` bigint(20) NOT NULL AUTO_INCREMENT,
`model_code` varchar(128) NOT NULL,
`model_name` varchar(255) NOT NULL,
`audio_scene` varchar(32) NOT NULL COMMENT 'asr / voiceprint',
`provider` varchar(64) DEFAULT NULL,
`endpoint_url` varchar(512) DEFAULT NULL,
`api_key` varchar(512) DEFAULT NULL,
`asr_model_name` varchar(128) DEFAULT NULL,
`asr_vocabulary_id` varchar(255) DEFAULT NULL,
`asr_speaker_count` int(11) DEFAULT NULL,
`asr_language_hints` varchar(255) DEFAULT NULL,
`asr_disfluency_removal_enabled` tinyint(1) DEFAULT NULL,
`asr_diarization_enabled` tinyint(1) DEFAULT NULL,
`vp_template_text` text DEFAULT NULL,
`vp_duration_seconds` int(11) DEFAULT NULL,
`vp_sample_rate` int(11) DEFAULT NULL,
`vp_channels` int(11) DEFAULT NULL,
`vp_max_size_bytes` bigint(20) DEFAULT NULL,
`description` varchar(500) DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT 1,
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_audio_model_code` (`model_code`),
KEY `idx_audio_scene` (`audio_scene`),
KEY `idx_audio_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrate llm rows
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)
SELECT
model_code,
model_name,
provider,
endpoint_url,
api_key,
COALESCE(llm_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model_name')), 'qwen-plus'),
COALESCE(llm_timeout, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.time_out')) AS UNSIGNED), 120),
COALESCE(llm_temperature, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.temperature')) AS DECIMAL(5,2)), 0.70),
COALESCE(llm_top_p, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.top_p')) AS DECIMAL(5,2)), 0.90),
COALESCE(llm_max_tokens, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.max_tokens')) AS UNSIGNED), 2048),
COALESCE(llm_system_prompt, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.system_prompt'))),
description,
is_active,
is_default
FROM ai_model_configs
WHERE model_type = 'llm'
ON DUPLICATE KEY UPDATE
model_name = VALUES(model_name),
provider = VALUES(provider),
endpoint_url = VALUES(endpoint_url),
api_key = VALUES(api_key),
llm_model_name = VALUES(llm_model_name),
llm_timeout = VALUES(llm_timeout),
llm_temperature = VALUES(llm_temperature),
llm_top_p = VALUES(llm_top_p),
llm_max_tokens = VALUES(llm_max_tokens),
llm_system_prompt = VALUES(llm_system_prompt),
description = VALUES(description),
is_active = VALUES(is_active),
is_default = VALUES(is_default);
-- migrate audio recognition rows
INSERT INTO `audio_model_config`
(model_code, model_name, audio_scene, provider, endpoint_url, api_key, asr_model_name, asr_vocabulary_id,
asr_speaker_count, asr_language_hints, asr_disfluency_removal_enabled, asr_diarization_enabled,
description, is_active, is_default)
SELECT
model_code,
model_name,
'asr',
provider,
endpoint_url,
api_key,
COALESCE(asr_model_name, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.model')), 'paraformer-v2'),
COALESCE(asr_vocabulary_id, JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.vocabulary_id'))),
COALESCE(asr_speaker_count, CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.speaker_count')) AS UNSIGNED), 10),
COALESCE(asr_language_hints, REPLACE(REPLACE(REPLACE(JSON_EXTRACT(config_json, '$.language_hints'), '"', ''), '[', ''), ']', ''), 'zh,en'),
COALESCE(asr_disfluency_removal_enabled,
CASE LOWER(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.disfluency_removal_enabled'))) WHEN 'true' THEN 1 WHEN '1' THEN 1 ELSE 0 END),
COALESCE(asr_diarization_enabled,
CASE LOWER(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.diarization_enabled'))) WHEN 'true' THEN 1 WHEN '1' THEN 1 ELSE 0 END),
description,
is_active,
is_default
FROM ai_model_configs
WHERE model_code = 'audio_model'
ON DUPLICATE KEY UPDATE
model_name = VALUES(model_name),
provider = VALUES(provider),
endpoint_url = VALUES(endpoint_url),
api_key = VALUES(api_key),
asr_model_name = VALUES(asr_model_name),
asr_vocabulary_id = VALUES(asr_vocabulary_id),
asr_speaker_count = VALUES(asr_speaker_count),
asr_language_hints = VALUES(asr_language_hints),
asr_disfluency_removal_enabled = VALUES(asr_disfluency_removal_enabled),
asr_diarization_enabled = VALUES(asr_diarization_enabled),
description = VALUES(description),
is_active = VALUES(is_active),
is_default = VALUES(is_default);
-- migrate voiceprint rows
INSERT INTO `audio_model_config`
(model_code, model_name, audio_scene, provider, endpoint_url, api_key, vp_template_text, vp_duration_seconds,
vp_sample_rate, vp_channels, vp_max_size_bytes, description, is_active, is_default)
SELECT
model_code,
model_name,
'voiceprint',
provider,
endpoint_url,
api_key,
JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.template_text')),
CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.duration_seconds')) AS UNSIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.sample_rate')) AS UNSIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.channels')) AS UNSIGNED),
CAST(JSON_UNQUOTE(JSON_EXTRACT(config_json, '$.voiceprint_max_size')) AS UNSIGNED),
description,
is_active,
is_default
FROM ai_model_configs
WHERE model_code = 'voiceprint_model'
ON DUPLICATE KEY UPDATE
model_name = VALUES(model_name),
provider = VALUES(provider),
endpoint_url = VALUES(endpoint_url),
api_key = VALUES(api_key),
vp_template_text = VALUES(vp_template_text),
vp_duration_seconds = VALUES(vp_duration_seconds),
vp_sample_rate = VALUES(vp_sample_rate),
vp_channels = VALUES(vp_channels),
vp_max_size_bytes = VALUES(vp_max_size_bytes),
description = VALUES(description),
is_active = VALUES(is_active),
is_default = VALUES(is_default);
COMMIT;

View File

@ -0,0 +1,121 @@
-- Migration: standardize system-level table names with sys_ prefix
-- Strategy:
-- 1) Rename physical tables to sys_*
-- 2) Create compatibility views with legacy names
SET @rename_users_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'users' AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'sys_users'
),
'RENAME TABLE users TO sys_users',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_users_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @rename_roles_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'roles' AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'sys_roles'
),
'RENAME TABLE roles TO sys_roles',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_roles_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @rename_menus_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'menus' AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'sys_menus'
),
'RENAME TABLE menus TO sys_menus',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_menus_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @rename_rmp_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'role_menu_permissions' AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'sys_role_menu_permissions'
),
'RENAME TABLE role_menu_permissions TO sys_role_menu_permissions',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_rmp_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @rename_dict_data_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'dict_data' AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'sys_dict_data'
),
'RENAME TABLE dict_data TO sys_dict_data',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_dict_data_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @rename_sys_param_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'system_parameters' AND table_type = 'BASE TABLE'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'sys_system_parameters'
),
'RENAME TABLE system_parameters TO sys_system_parameters',
'SELECT 1'
)
);
PREPARE stmt FROM @rename_sys_param_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop existing legacy-name views if present, then recreate compatibility views.
DROP VIEW IF EXISTS users;
DROP VIEW IF EXISTS roles;
DROP VIEW IF EXISTS menus;
DROP VIEW IF EXISTS role_menu_permissions;
DROP VIEW IF EXISTS dict_data;
DROP VIEW IF EXISTS system_parameters;
CREATE VIEW users AS SELECT * FROM sys_users;
CREATE VIEW roles AS SELECT * FROM sys_roles;
CREATE VIEW menus AS SELECT * FROM sys_menus;
CREATE VIEW role_menu_permissions AS SELECT * FROM sys_role_menu_permissions;
CREATE VIEW dict_data AS SELECT * FROM sys_dict_data;
CREATE VIEW system_parameters AS SELECT * FROM sys_system_parameters;

View File

@ -0,0 +1,176 @@
-- Migration: convert platform admin menu to hierarchical navigation
-- Created at: 2026-03-03
BEGIN;
-- 1) Ensure top-level menus exist and are aligned
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
VALUES ('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, 1, '管理个人账户信息')
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
VALUES ('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 2, 1, '管理AI提示词模版')
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
VALUES ('platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 3, 1, '平台管理员后台')
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
VALUES ('logout', '退出登录', 'LogOut', NULL, 'action', NULL, 99, 1, '退出当前账号')
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
-- 2) Ensure children under platform_admin
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link', m.menu_id, 1, 1, '账号、角色、密码重置'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link', m.menu_id, 2, 1, '菜单与角色授权矩阵'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link', m.menu_id, 3, 1, '码表、平台类型、扩展属性'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link', m.menu_id, 4, 1, 'ASR 热词与同步'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link', m.menu_id, 5, 1, '版本、下载地址、发布状态'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link', m.menu_id, 6, 1, '外部系统入口与图标配置'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `sort_order`, `is_active`, `description`)
SELECT 'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link', m.menu_id, 7, 1, '专用设备、激活和绑定状态'
FROM `menus` m
WHERE m.`menu_code` = 'platform_admin'
ON DUPLICATE KEY UPDATE
`menu_name` = VALUES(`menu_name`),
`menu_icon` = VALUES(`menu_icon`),
`menu_url` = VALUES(`menu_url`),
`menu_type` = VALUES(`menu_type`),
`parent_id` = VALUES(`parent_id`),
`sort_order` = VALUES(`sort_order`),
`is_active` = VALUES(`is_active`),
`description` = VALUES(`description`);
-- 3) Permission alignment
DELETE FROM `role_menu_permissions`
WHERE `role_id` = 2
AND `menu_id` IN (
SELECT `menu_id` FROM `menus`
WHERE `menu_code` IN (
'platform_admin',
'user_management',
'permission_management',
'dict_management',
'hot_word_management',
'client_management',
'external_app_management',
'terminal_management'
)
);
INSERT IGNORE INTO `role_menu_permissions` (`role_id`, `menu_id`)
SELECT 1, `menu_id`
FROM `menus`
WHERE `is_active` = 1;
COMMIT;

View File

@ -0,0 +1,113 @@
-- Migration: prompt library upgrade + user prompt config + menu regroup
-- Created at: 2026-03-13
BEGIN;
-- 1) prompts table: support system prompt library
SET @add_is_system_sql = (
SELECT IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'prompts' AND column_name = 'is_system'
),
'SELECT 1',
'ALTER TABLE prompts ADD COLUMN is_system TINYINT(1) NOT NULL DEFAULT 0 AFTER creator_id'
)
);
PREPARE stmt FROM @add_is_system_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_prompts_idx_sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'prompts'
AND index_name = 'idx_prompts_task_scope_active'
),
'SELECT 1',
'CREATE INDEX idx_prompts_task_scope_active ON prompts (task_type, is_system, creator_id, is_active, is_default)'
)
);
PREPARE stmt FROM @add_prompts_idx_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Existing admin-created prompts become system prompts by default
UPDATE prompts
SET is_system = 1
WHERE creator_id = 1;
-- 2) user prompt config table
CREATE TABLE IF NOT EXISTS prompt_config (
config_id BIGINT(20) NOT NULL AUTO_INCREMENT,
user_id INT(11) NOT NULL,
task_type ENUM('MEETING_TASK','KNOWLEDGE_TASK') NOT NULL,
prompt_id INT(11) NOT NULL,
is_enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT(11) NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (config_id),
UNIQUE KEY uk_user_task_prompt (user_id, task_type, prompt_id),
KEY idx_user_task_order (user_id, task_type, sort_order),
KEY idx_prompt_id (prompt_id),
CONSTRAINT fk_upc_user FOREIGN KEY (user_id) REFERENCES sys_users(user_id) ON DELETE CASCADE,
CONSTRAINT fk_upc_prompt FOREIGN KEY (prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 3) Menu regroup:
-- move prompt_management under platform_admin (2nd level)
UPDATE sys_menus child
JOIN sys_menus parent ON parent.menu_code = 'platform_admin'
SET child.parent_id = parent.menu_id,
child.sort_order = 8,
child.menu_level = 2,
child.tree_path = CONCAT(parent.tree_path, '/', child.menu_id)
WHERE child.menu_code = 'prompt_management';
-- add prompt_config entry
INSERT INTO sys_menus
(
menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, menu_level, tree_path, sort_order, is_active, is_visible, description
)
SELECT
'prompt_config',
'提示词配置',
'Book',
'/prompt-config',
'link',
p.menu_id,
2,
NULL,
9,
1,
1,
'用户可配置启用提示词与排序'
FROM sys_menus p
WHERE p.menu_code = 'platform_admin'
AND NOT EXISTS (SELECT 1 FROM sys_menus WHERE menu_code = 'prompt_config');
UPDATE sys_menus c
JOIN sys_menus p ON c.parent_id = p.menu_id
SET c.menu_level = p.menu_level + 1,
c.tree_path = CONCAT(p.tree_path, '/', c.menu_id)
WHERE c.menu_code = 'prompt_config';
-- permissions:
-- admin gets both
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id)
SELECT 1, m.menu_id
FROM sys_menus m
WHERE m.menu_code IN ('platform_admin', 'prompt_management', 'prompt_config');
-- normal user gets platform_admin + prompt_management + prompt_config
INSERT IGNORE INTO sys_role_menu_permissions (role_id, menu_id)
SELECT 2, m.menu_id
FROM sys_menus m
WHERE m.menu_code IN ('platform_admin', 'prompt_management', 'prompt_config');
COMMIT;

View File

@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"version": "1.1.0",
"dependencies": {
"@uiw/react-md-editor": "^4.0.8",
"antd": "^5.27.3",

View File

@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -20,7 +20,6 @@
"canvg": "^4.0.3",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.2",
"lucide-react": "^0.294.0",
"markmap-common": "^0.18.9",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12",

View File

@ -7,12 +7,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'MiSans', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f8fafc;
background-color: transparent;
color: #1e293b;
line-height: 1.6;
}
@ -22,6 +20,228 @@ body {
width: 100%;
}
.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
font-weight: 600;
letter-spacing: 0.01em;
transition: transform 0.18s ease, box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, color 0.22s ease;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
.ant-btn:hover {
transform: translateY(-1px);
}
.ant-btn:active {
transform: translateY(0);
}
.ant-btn .anticon {
font-size: 0.98em;
}
.ant-btn.ant-btn-default,
.ant-btn.ant-btn-dashed {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(148, 163, 184, 0.2);
color: #294261;
}
.ant-btn.ant-btn-default:hover,
.ant-btn.ant-btn-dashed:hover {
background: #ffffff;
border-color: rgba(59, 130, 246, 0.28);
color: #1d4ed8;
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.12);
}
.ant-btn.ant-btn-primary {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 45%, #1e40af 100%);
border-color: transparent;
color: #fff;
}
.ant-btn.ant-btn-primary:hover {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 48%, #1d4ed8 100%);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
}
.ant-btn.ant-btn-primary.ant-btn-dangerous,
.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 48%, #b91c1c 100%);
border-color: transparent;
color: #fff;
}
.ant-btn.ant-btn-primary.ant-btn-dangerous:hover,
.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text):hover {
box-shadow: 0 14px 28px rgba(220, 38, 38, 0.24);
}
.ant-btn.ant-btn-link,
.ant-btn.ant-btn-text {
box-shadow: none;
transform: none;
}
.ant-btn.ant-btn-link {
padding-inline: 6px;
color: #31568b;
}
.ant-btn.ant-btn-link:hover {
color: #1d4ed8;
background: rgba(37, 99, 235, 0.08);
}
.ant-btn.ant-btn-link.ant-btn-dangerous,
.ant-btn.ant-btn-text.ant-btn-dangerous {
color: #dc2626;
}
.ant-btn.ant-btn-link.ant-btn-dangerous:hover,
.ant-btn.ant-btn-text.ant-btn-dangerous:hover {
background: rgba(220, 38, 38, 0.08);
color: #b91c1c;
}
.ant-btn.btn-soft-blue,
.ant-btn.ant-btn-primary.btn-soft-blue {
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
border-color: #bfdbfe;
color: #1d4ed8;
box-shadow: 0 10px 22px rgba(59, 130, 246, 0.12);
}
.ant-btn.btn-soft-blue:hover,
.ant-btn.ant-btn-primary.btn-soft-blue:hover {
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
border-color: #93c5fd;
color: #1d4ed8;
box-shadow: 0 14px 26px rgba(59, 130, 246, 0.18);
}
.ant-btn.btn-soft-violet,
.ant-btn.ant-btn-primary.btn-soft-violet {
background: linear-gradient(180deg, #faf5ff 0%, #f3e8ff 100%);
border-color: #d8b4fe;
color: #7c3aed;
box-shadow: 0 10px 22px rgba(124, 58, 237, 0.12);
}
.ant-btn.btn-soft-violet:hover,
.ant-btn.ant-btn-primary.btn-soft-violet:hover {
background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 100%);
border-color: #c084fc;
color: #6d28d9;
box-shadow: 0 14px 26px rgba(124, 58, 237, 0.18);
}
.ant-btn.btn-soft-green,
.ant-btn.ant-btn-primary.btn-soft-green {
background: linear-gradient(180deg, #f0fdf4 0%, #dcfce7 100%);
border-color: #86efac;
color: #15803d;
box-shadow: 0 10px 22px rgba(34, 197, 94, 0.12);
}
.ant-btn.btn-soft-green:hover,
.ant-btn.ant-btn-primary.btn-soft-green:hover {
background: linear-gradient(180deg, #dcfce7 0%, #bbf7d0 100%);
border-color: #4ade80;
color: #166534;
box-shadow: 0 14px 26px rgba(34, 197, 94, 0.18);
}
.ant-btn.btn-icon-soft-blue {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
box-shadow: none;
}
.ant-btn.btn-icon-soft-blue:hover {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.ant-btn.btn-icon-soft-red,
.ant-btn.ant-btn-dangerous.btn-icon-soft-red {
background: #fff1f2;
border-color: #fecdd3;
color: #dc2626;
box-shadow: none;
}
.ant-btn.btn-icon-soft-red:hover,
.ant-btn.ant-btn-dangerous.btn-icon-soft-red:hover {
background: #ffe4e6;
border-color: #fda4af;
color: #b91c1c;
}
.ant-btn.ant-btn-link.btn-text-view,
.ant-btn.ant-btn-text.btn-text-view {
color: #2563eb;
}
.ant-btn.ant-btn-link.btn-text-view:hover,
.ant-btn.ant-btn-text.btn-text-view:hover {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
}
.ant-btn.ant-btn-link.btn-text-edit,
.ant-btn.ant-btn-text.btn-text-edit {
color: #0f766e;
}
.ant-btn.ant-btn-link.btn-text-edit:hover,
.ant-btn.ant-btn-text.btn-text-edit:hover {
background: rgba(13, 148, 136, 0.1);
color: #0f766e;
}
.ant-btn.ant-btn-link.btn-text-accent,
.ant-btn.ant-btn-text.btn-text-accent {
color: #7c3aed;
}
.ant-btn.ant-btn-link.btn-text-accent:hover,
.ant-btn.ant-btn-text.btn-text-accent:hover {
background: rgba(124, 58, 237, 0.1);
color: #6d28d9;
}
.ant-btn.ant-btn-link.btn-text-delete,
.ant-btn.ant-btn-text.btn-text-delete,
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete,
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete {
color: #dc2626;
}
.ant-btn.ant-btn-link.btn-text-delete:hover,
.ant-btn.ant-btn-text.btn-text-delete:hover,
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete:hover,
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete:hover {
background: rgba(220, 38, 38, 0.1);
color: #b91c1c;
}
.ant-btn.ant-btn-icon-only.ant-btn-text,
.ant-btn.ant-btn-icon-only.ant-btn-link {
min-width: 36px;
}
.ant-btn-icon-only {
min-width: 40px;
}
.app-loading {
display: flex;
flex-direction: column;
@ -147,4 +367,4 @@ body {
.text-gray-500 { color: #64748b; }
.text-gray-600 { color: #475569; }
.text-gray-700 { color: #334155; }
.text-gray-900 { color: #0f172a; }
.text-gray-900 { color: #0f172a; }

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { ConfigProvider, theme, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import apiClient from './utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from './config/api';
import HomePage from './pages/HomePage';
@ -7,15 +9,75 @@ import Dashboard from './pages/Dashboard';
import AdminDashboard from './pages/AdminDashboard';
import MeetingDetails from './pages/MeetingDetails';
import MeetingPreview from './pages/MeetingPreview';
import CreateMeeting from './pages/CreateMeeting';
import EditMeeting from './pages/EditMeeting';
import AdminManagement from './pages/AdminManagement';
import PromptManagementPage from './pages/PromptManagementPage';
import PromptConfigPage from './pages/PromptConfigPage';
import KnowledgeBasePage from './pages/KnowledgeBasePage';
import EditKnowledgeBase from './pages/EditKnowledgeBase';
import ClientDownloadPage from './pages/ClientDownloadPage';
import AccountSettings from './pages/AccountSettings';
import MeetingCenterPage from './pages/MeetingCenterPage';
import MainLayout from './components/MainLayout';
import menuService from './services/menuService';
import './App.css';
import './styles/console-theme.css';
// Layout Wrapper to inject user and handleLogout
const AuthenticatedLayout = ({ user, handleLogout }) => {
//
if (!user) return null;
return (
<MainLayout user={user} onLogout={handleLogout}>
<Outlet />
</MainLayout>
);
};
const DefaultMenuRedirect = ({ user }) => {
const [targetPath, setTargetPath] = useState(null);
useEffect(() => {
let active = true;
const resolveDefaultPath = async () => {
try {
const path = await menuService.getDefaultPath();
if (active) {
setTargetPath(path || '/dashboard');
}
} catch (error) {
console.error('Resolve default menu path failed:', error);
if (active) {
setTargetPath('/dashboard');
}
}
};
if (user) {
resolveDefaultPath();
}
return () => {
active = false;
};
}, [user]);
if (!user) {
return <Navigate to="/" replace />;
}
if (!targetPath) {
return (
<div className="app-loading">
<div className="loading-spinner"></div>
<p>加载菜单中...</p>
</div>
);
}
return <Navigate to={targetPath} replace />;
};
function App() {
const [user, setUser] = useState(null);
@ -23,13 +85,19 @@ function App() {
// Load user from localStorage on app start
useEffect(() => {
const savedUser = localStorage.getItem('iMeetingUser');
console.log('Saved user from localStorage:', savedUser);
if (savedUser) {
const savedAuth = localStorage.getItem('iMeetingUser');
if (savedAuth && savedAuth !== "undefined" && savedAuth !== "null") {
try {
const parsedUser = JSON.parse(savedUser);
console.log('Parsed user:', parsedUser);
setUser(parsedUser);
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');
@ -38,22 +106,27 @@ function App() {
setIsLoading(false);
}, []);
const handleLogin = (userData) => {
setUser(userData);
localStorage.setItem('iMeetingUser', JSON.stringify(userData));
const handleLogin = (authData) => {
if (authData) {
menuService.clearCache();
// UI
const userData = authData.user || authData;
setUser(userData);
// auth token使
localStorage.setItem('iMeetingUser', JSON.stringify(authData));
}
};
const handleLogout = async () => {
try {
// APItoken
await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGOUT));
} catch (error) {
console.error('Logout API error:', error);
// 使API
} finally {
//
setUser(null);
localStorage.removeItem('iMeetingUser');
menuService.clearCache();
window.location.href = '/';
}
};
@ -67,49 +140,119 @@ function App() {
}
return (
<Router>
<div className="app">
<Routes>
<Route path="/" element={
user ? <Navigate to="/dashboard" /> : <HomePage onLogin={handleLogin} />
} />
<Route path="/dashboard" element={
user ? (
user.role_id === 1
? <AdminDashboard user={user} onLogout={handleLogout} />
: <Dashboard user={user} onLogout={handleLogout} />
) : <Navigate to="/" />
} />
<Route path="/meetings/:meeting_id" element={
user ? <MeetingDetails user={user} /> : <Navigate to="/" />
} />
<Route path="/meetings/create" element={
user ? <CreateMeeting user={user} /> : <Navigate to="/" />
} />
<Route path="/meetings/edit/:meeting_id" element={
user ? <EditMeeting user={user} /> : <Navigate to="/" />
} />
<Route path="/admin/management" element={
user && user.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" />
} />
<Route path="/prompt-management" element={
user ? <PromptManagementPage user={user} /> : <Navigate to="/" />
} />
<Route path="/knowledge-base" element={
user ? <KnowledgeBasePage user={user} /> : <Navigate to="/" />
} />
<Route path="/knowledge-base/edit/:kb_id" element={
user ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
} />
<Route path="/account-settings" element={
user ? <AccountSettings user={user} onUpdateUser={handleLogin} /> : <Navigate to="/" />
} />
<Route path="/downloads" element={<ClientDownloadPage />} />
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
</Routes>
</div>
</Router>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#1d4ed8',
colorSuccess: '#0f766e',
colorWarning: '#d97706',
colorError: '#c2410c',
borderRadius: 12,
borderRadiusLG: 16,
wireframe: false,
fontFamily: '"MiSans", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
colorTextBase: '#112b4e',
colorBgLayout: 'transparent',
},
algorithm: theme.defaultAlgorithm,
components: {
Layout: {
bodyBg: 'transparent',
siderBg: 'rgba(255,255,255,0.82)',
headerBg: 'transparent',
},
Card: {
paddingLG: 18,
},
Table: {
headerBorderRadius: 14,
},
Button: {
controlHeight: 40,
controlHeightLG: 46,
borderRadius: 12,
fontWeight: 600,
paddingInline: 18,
defaultBorderColor: 'rgba(148, 163, 184, 0.24)',
defaultColor: '#274365',
defaultBg: 'rgba(255,255,255,0.92)',
defaultHoverBg: '#ffffff',
defaultHoverBorderColor: 'rgba(59, 130, 246, 0.3)',
defaultHoverColor: '#1d4ed8',
defaultActiveBg: '#eff6ff',
primaryShadow: '0 12px 24px rgba(29, 78, 216, 0.18)',
dangerShadow: '0 12px 24px rgba(220, 38, 38, 0.16)',
},
Switch: {
trackMinWidth: 40,
trackHeight: 22,
trackPadding: 2,
handleSize: 18,
innerMinMargin: 4,
innerMaxMargin: 26,
borderRadius: 100,
},
},
}}
>
<AntdApp message={{ top: 64, maxCount: 3 }}>
<Router>
<div className="app">
<Routes>
{/* Public Routes */}
<Route path="/" element={
user ? <DefaultMenuRedirect user={user} /> : <HomePage onLogin={handleLogin} />
} />
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
<Route path="/downloads" element={<ClientDownloadPage />} />
{/* Authenticated Routes */}
<Route element={user ? <AuthenticatedLayout user={user} handleLogout={handleLogout} /> : <Navigate to="/" replace />}>
<Route path="/dashboard" element={
user?.role_id === 1
? <AdminDashboard user={user} onLogout={handleLogout} />
: <Dashboard user={user} onLogout={handleLogout} />
} />
<Route path="/meetings/center" element={
user?.role_id === 1
? <Navigate to="/dashboard" replace />
: <MeetingCenterPage user={user} />
} />
<Route path="/meetings/history" element={<Navigate to="/meetings/center" replace />} />
<Route path="/meetings/:meeting_id" element={<MeetingDetails user={user} />} />
<Route path="/meetings/create" element={<Navigate to="/meetings/center" replace />} />
<Route path="/meetings/edit/:meeting_id" element={<Navigate to="/meetings/center" replace />} />
<Route path="/admin/management" element={
user?.role_id === 1
? <Navigate to="/admin/management/system-overview" replace />
: <Navigate to="/dashboard" replace />
} />
<Route path="/admin/management/:moduleKey" element={
user?.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" replace />
} />
<Route path="/prompt-management" element={
user?.role_id === 1 ? <PromptManagementPage user={user} /> : <Navigate to="/dashboard" replace />
} />
<Route path="/prompt-config" element={<PromptConfigPage user={user} />} />
<Route path="/personal-prompts" element={
user?.role_id === 1 ? <Navigate to="/dashboard" replace /> : <Navigate to="/prompt-config" replace />
} />
<Route path="/knowledge-base" element={<KnowledgeBasePage user={user} />} />
<Route path="/knowledge-base/edit/:kb_id" element={<EditKnowledgeBase user={user} />} />
<Route path="/account-settings" element={<AccountSettings user={user} onUpdateUser={handleLogin} />} />
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</Router>
</AntdApp>
</ConfigProvider>
);
}
export default App;
export default App;

View File

@ -0,0 +1,63 @@
import React from 'react';
import { Card, Space, Typography } from 'antd';
const { Text } = Typography;
const AdminModuleShell = ({
icon,
title,
subtitle,
rightActions,
stats,
toolbar,
children,
}) => {
return (
<div className="admin-module-shell">
<Card className="console-surface" style={{ marginBottom: 14 }}>
<div className="console-toolbar" style={{ marginBottom: stats?.length ? 12 : 6 }}>
<div>
<Space size={8} align="center" style={{ marginBottom: 6 }}>
{icon}
<h3 className="console-section-title" style={{ fontSize: 18 }}>{title}</h3>
</Space>
{subtitle && <p className="console-section-subtitle" style={{ marginTop: 0 }}>{subtitle}</p>}
</div>
{rightActions}
</div>
{stats?.length ? (
<div className="admin-module-stats-grid">
{stats.map((item) => (
<div
key={item.label}
className={`admin-module-stat-item admin-module-stat-item-${item.tone || 'blue'}`}
>
<div className="admin-module-stat-top">
<div>
<Text className="admin-module-stat-label">{item.label}</Text>
<div className="admin-module-stat-value">{item.value}</div>
</div>
{item.icon ? (
<div className="admin-module-stat-icon">
{item.icon}
</div>
) : null}
</div>
{item.desc ? (
<div className="admin-module-stat-desc">{item.desc}</div>
) : null}
</div>
))}
</div>
) : null}
{toolbar ? <div className="admin-module-toolbar">{toolbar}</div> : null}
</Card>
<Card className="console-surface">{children}</Card>
</div>
);
};
export default AdminModuleShell;

View File

@ -0,0 +1,44 @@
import React from 'react';
const BrandLogo = ({
title = 'iMeeting',
size = 32,
titleSize = 18,
gap = 10,
titleColor = '#1f2f4a',
weight = 700,
}) => (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap,
minWidth: 0,
}}
>
<img
src="/favicon.svg"
alt="iMeeting"
style={{
width: size,
height: size,
display: 'block',
objectFit: 'contain',
flexShrink: 0,
}}
/>
<span
style={{
fontSize: titleSize,
fontWeight: weight,
color: titleColor,
lineHeight: 1.1,
whiteSpace: 'nowrap',
}}
>
{title}
</span>
</span>
);
export default BrandLogo;

View File

@ -1,79 +0,0 @@
/* 面包屑导航容器 */
.breadcrumb-container {
background: white;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.breadcrumb-content {
max-width: 1400px;
margin: 0 auto;
padding: 0.875rem 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 面包屑项 */
.breadcrumb-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
}
/* 首页链接 */
.breadcrumb-home {
color: #667eea;
background: none;
border: none;
padding: 0.375rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.breadcrumb-home:hover {
background: #eff6ff;
color: #5568d3;
transform: translateY(-1px);
}
/* 分隔符 */
.breadcrumb-separator {
color: #cbd5e1;
flex-shrink: 0;
}
/* 当前页 */
.breadcrumb-current {
color: #1e293b;
padding: 0.375rem 0.75rem;
background: #f8fafc;
border-radius: 6px;
}
.breadcrumb-current svg {
color: #667eea;
}
/* 响应式 */
@media (max-width: 768px) {
.breadcrumb-content {
padding: 0.75rem 1rem;
}
.breadcrumb-item {
font-size: 0.85rem;
}
.breadcrumb-item span {
display: none;
}
.breadcrumb-separator {
margin: 0 0.25rem;
}
}

View File

@ -1,35 +0,0 @@
import React from 'react';
import { Home, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import './Breadcrumb.css';
/**
* 面包屑导航组件
* @param {string} currentPage - 当前页面名称
* @param {string} icon - 当前页面图标可选lucide-react组件
*/
const Breadcrumb = ({ currentPage, icon: Icon }) => {
const navigate = useNavigate();
const handleHomeClick = () => {
navigate('/dashboard');
};
return (
<div className="breadcrumb-container">
<div className="breadcrumb-content">
<button className="breadcrumb-item breadcrumb-home" onClick={handleHomeClick}>
<Home size={16} />
<span>首页</span>
</button>
<ChevronRight size={16} className="breadcrumb-separator" />
<div className="breadcrumb-item breadcrumb-current">
{Icon && <Icon size={16} />}
<span>{currentPage}</span>
</div>
</div>
</div>
);
};
export default Breadcrumb;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Button, Space, Typography } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
const { Text } = Typography;
const CenterPager = ({
current = 1,
total = 0,
pageSize = 10,
onChange,
}) => {
const totalPages = Math.max(1, Math.ceil((total || 0) / pageSize));
return (
<div className="console-center-pager">
<Text type="secondary">{`${total || 0}`}</Text>
<Space size={12} align="center">
<Button
shape="circle"
icon={<LeftOutlined />}
disabled={current <= 1}
onClick={() => onChange?.(current - 1)}
/>
<Text className="console-center-pager-text">
{current} / {totalPages}
</Text>
<Button
shape="circle"
icon={<RightOutlined />}
disabled={current >= totalPages}
onClick={() => onChange?.(current + 1)}
/>
</Space>
</div>
);
};
export default CenterPager;

View File

@ -1,150 +0,0 @@
/* 客户端下载组件样式 */
.client-downloads-section {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.section-header {
margin-bottom: 2rem;
text-align: center;
}
.section-header h2 {
font-size: 1.75rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 0.5rem 0;
}
.section-header p {
color: #64748b;
margin: 0;
}
.downloads-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.platform-group {
background: #f8fafc;
border-radius: 10px;
padding: 1.5rem;
}
.group-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
color: #667eea;
}
.group-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.clients-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.client-download-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
cursor: pointer;
}
.client-download-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
transform: translateY(-2px);
}
.card-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-info h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
.version-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.version {
font-size: 0.875rem;
color: #667eea;
font-weight: 500;
}
.file-size {
font-size: 0.75rem;
color: #94a3b8;
}
.system-req {
margin: 0;
font-size: 0.75rem;
color: #64748b;
}
.download-icon {
flex-shrink: 0;
color: #667eea;
}
.loading-message,
.empty-message {
text-align: center;
padding: 3rem;
color: #94a3b8;
font-size: 1rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.clients-list {
grid-template-columns: 1fr;
}
.client-download-card {
padding: 1rem;
}
}

View File

@ -1,202 +1,97 @@
import React, { useState, useEffect } from 'react';
import { Download, Smartphone, Monitor, Apple, ChevronRight, Cpu } from 'lucide-react';
import { Card, Button, Space, Typography, Tag, List, Badge, Empty, Skeleton } from 'antd';
import {
CloudOutlined,
PhoneOutlined,
DesktopOutlined,
AppleOutlined,
RobotOutlined,
WindowsOutlined,
RightOutlined
} from '@ant-design/icons';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import './ClientDownloads.css';
const { Title, Text } = Typography;
const ClientDownloads = () => {
const [clients, setClients] = useState({
mobile: [],
desktop: [],
terminal: []
});
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchLatestClients();
fetchClients();
}, []);
const fetchLatestClients = async () => {
setLoading(true);
const fetchClients = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST));
console.log('Latest clients response:', response);
setClients(response.data || { mobile: [], desktop: [], terminal: [] });
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST));
setClients(response.data.clients || []);
} catch (error) {
console.error('获取客户端下载失败:', error);
console.error('获取下载列表失败:', error);
} finally {
setLoading(false);
}
};
const getPlatformIcon = (platformCode) => {
const code = (platformCode || '').toUpperCase();
// platform_code
if (code.includes('IOS') || code.includes('MAC')) {
return <Apple size={32} />;
} else if (code.includes('ANDROID')) {
return <Smartphone size={32} />;
} else if (code.includes('TERM') || code.includes('MCU')) {
return <Cpu size={32} />;
} else {
return <Monitor size={32} />;
}
const code = platformCode.toLowerCase();
if (code.includes('win')) return <WindowsOutlined />;
if (code.includes('mac') || code.includes('ios')) return <AppleOutlined />;
if (code.includes('android')) return <RobotOutlined />;
return <DesktopOutlined />;
};
const getPlatformLabel = (client) => {
// 使 dict_data
return client.label_cn || client.platform_code || '未知平台';
};
const formatFileSize = (bytes) => {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
};
if (loading) {
return (
<div className="client-downloads-section">
<div className="section-header">
<h2>下载客户端</h2>
</div>
<div className="loading-message">加载中...</div>
</div>
);
}
if (loading) return <Skeleton active />;
return (
<div className="client-downloads-section">
<div className="section-header">
<h2>下载客户端</h2>
<p>选择适合您设备的版本</p>
</div>
<div className="downloads-container">
{/* 移动端 */}
{clients.mobile && clients.mobile.length > 0 && (
<div className="platform-group">
<div className="group-header">
<Smartphone size={24} />
<h3>移动端</h3>
</div>
<div className="clients-list">
{clients.mobile.map(client => (
<a
key={client.id}
href={client.download_url}
target="_blank"
rel="noopener noreferrer"
className="client-download-card"
>
<div className="card-icon">
{getPlatformIcon(client.platform_code)}
</div>
<div className="card-info">
<h4>{getPlatformLabel(client)}</h4>
<div className="version-info">
<span className="version">v{client.version}</span>
{client.file_size && (
<span className="file-size">{formatFileSize(client.file_size)}</span>
)}
<div className="client-downloads-modern">
<Title level={4} style={{ marginBottom: 24 }}>
<Space><CloudOutlined /> 客户端下载</Space>
</Title>
{clients.length === 0 ? (
<Empty description="暂无可用下载版本" />
) : (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3 }}
dataSource={clients}
renderItem={client => (
<List.Item>
<Card hoverable style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<div style={{
width: 40, height: 40, background: '#f0f7ff',
borderRadius: 8, display: 'flex', alignItems: 'center',
justifyContent: 'center', color: '#1677ff', fontSize: 20
}}>
{getPlatformIcon(client.platform_code)}
</div>
{client.min_system_version && (
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
)}
</div>
<div className="download-icon">
<ChevronRight size={20} />
</div>
</a>
))}
</div>
</div>
)}
{/* 桌面端 */}
{clients.desktop && clients.desktop.length > 0 && (
<div className="platform-group">
<div className="group-header">
<Monitor size={24} />
<h3>桌面端</h3>
</div>
<div className="clients-list">
{clients.desktop.map(client => (
<a
key={client.id}
href={client.download_url}
target="_blank"
rel="noopener noreferrer"
className="client-download-card"
>
<div className="card-icon">
{getPlatformIcon(client.platform_code)}
</div>
<div className="card-info">
<h4>{getPlatformLabel(client)}</h4>
<div className="version-info">
<span className="version">v{client.version}</span>
{client.file_size && (
<span className="file-size">{formatFileSize(client.file_size)}</span>
)}
<div>
<Text strong>{client.platform_name_cn || client.platform_code}</Text>
{client.is_latest && <Badge status="success" text="最新" style={{ marginLeft: 8 }} />}
</div>
{client.min_system_version && (
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
)}
</div>
<div className="download-icon">
<Download size={20} />
</div>
</a>
))}
</div>
</div>
)}
</Space>
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary" size="small">版本: </Text>
<Text strong>{client.version}</Text>
</div>
{/* 专用终端 */}
{clients.terminal && clients.terminal.length > 0 && (
<div className="platform-group">
<div className="group-header">
<Cpu size={24} />
<h3>专用终端</h3>
</div>
<div className="clients-list">
{clients.terminal.map(client => (
<a
key={client.id}
href={client.download_url}
<Button
type="primary"
block
icon={<CloudOutlined />}
href={client.download_url}
target="_blank"
rel="noopener noreferrer"
className="client-download-card"
>
<div className="card-icon">
{getPlatformIcon(client.platform_code)}
</div>
<div className="card-info">
<h4>{getPlatformLabel(client)}</h4>
<div className="version-info">
<span className="version">v{client.version}</span>
{client.file_size && (
<span className="file-size">{formatFileSize(client.file_size)}</span>
)}
</div>
{client.min_system_version && (
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
)}
</div>
<div className="download-icon">
<Download size={20} />
</div>
</a>
))}
</div>
</div>
)}
</div>
{!clients.mobile?.length && !clients.desktop?.length && !clients.terminal?.length && (
<div className="empty-message">暂无可用的客户端下载</div>
立即下载
</Button>
</Card>
</List.Item>
)}
/>
)}
</div>
);

View File

@ -1,188 +0,0 @@
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.confirm-dialog-content {
background: white;
border-radius: 16px;
width: 90%;
max-width: 420px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease-out;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.confirm-dialog-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.confirm-dialog-icon.warning {
background: #fff7e6;
color: #fa8c16;
}
.confirm-dialog-icon.danger {
background: #fff2f0;
color: #ff4d4f;
}
.confirm-dialog-icon.info {
background: #e6f7ff;
color: #1890ff;
}
.confirm-dialog-body {
margin-bottom: 28px;
}
.confirm-dialog-title {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #262626;
}
.confirm-dialog-message {
margin: 0;
font-size: 15px;
color: #595959;
line-height: 1.6;
}
.confirm-dialog-actions {
display: flex;
gap: 12px;
width: 100%;
}
.confirm-dialog-btn {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.confirm-dialog-btn.cancel {
background: white;
color: #595959;
border: 1px solid #d9d9d9;
}
.confirm-dialog-btn.cancel:hover {
color: #262626;
border-color: #40a9ff;
background: #fafafa;
}
.confirm-dialog-btn.confirm.warning {
background: #fa8c16;
color: white;
}
.confirm-dialog-btn.confirm.warning:hover {
background: #ff9c2e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(250, 140, 22, 0.4);
}
.confirm-dialog-btn.confirm.danger {
background: #ff4d4f;
color: white;
}
.confirm-dialog-btn.confirm.danger:hover {
background: #ff7875;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.4);
}
.confirm-dialog-btn.confirm.info {
background: #1890ff;
color: white;
}
.confirm-dialog-btn.confirm.info:hover {
background: #40a9ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
}
/* 响应式 */
@media (max-width: 640px) {
.confirm-dialog-content {
width: 95%;
padding: 24px;
}
.confirm-dialog-icon {
width: 64px;
height: 64px;
margin-bottom: 20px;
}
.confirm-dialog-icon svg {
width: 36px;
height: 36px;
}
.confirm-dialog-title {
font-size: 18px;
}
.confirm-dialog-message {
font-size: 14px;
}
.confirm-dialog-actions {
flex-direction: column;
}
.confirm-dialog-btn {
width: 100%;
}
}

View File

@ -1,53 +0,0 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import './ConfirmDialog.css';
const ConfirmDialog = ({
isOpen,
onClose,
onConfirm,
title = '确认操作',
message,
confirmText = '确认',
cancelText = '取消',
type = 'warning' // 'warning', 'danger', 'info'
}) => {
if (!isOpen) return null;
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<div className="confirm-dialog-overlay" onClick={onClose}>
<div className="confirm-dialog-content" onClick={(e) => e.stopPropagation()}>
<div className={`confirm-dialog-icon ${type}`}>
<AlertTriangle size={48} />
</div>
<div className="confirm-dialog-body">
<h3 className="confirm-dialog-title">{title}</h3>
<p className="confirm-dialog-message">{message}</p>
</div>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-btn cancel"
onClick={onClose}
>
{cancelText}
</button>
<button
className={`confirm-dialog-btn confirm ${type}`}
onClick={handleConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@ -1,165 +0,0 @@
/* Content Viewer Component */
.content-viewer {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.content-viewer .ant-tabs-nav {
margin: 0;
padding: 0 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.content-viewer .ant-tabs-tab {
font-size: 1rem;
color: #475569;
padding: 16px 4px;
margin: 0 16px;
}
.content-viewer .ant-tabs-tab .ant-tabs-tab-btn {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.content-viewer .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #667eea;
}
.content-viewer .ant-tabs-ink-bar {
background: #667eea;
height: 3px;
}
.content-viewer .ant-tabs-content-holder {
padding: 2rem;
}
/* Tab Header with Actions */
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.tab-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-actions {
display: flex;
gap: 0.5rem;
}
/* Content Markdown Area */
.content-markdown {
line-height: 1.8;
color: #475569;
}
.content-markdown h1 {
color: #1e293b;
font-size: 1.75rem;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e2e8f0;
}
.content-markdown h2 {
color: #1e293b;
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.content-markdown h3 {
color: #1e293b;
font-size: 1.25rem;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.content-markdown p {
margin-bottom: 1rem;
}
.content-markdown ul,
.content-markdown ol {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.content-markdown li {
margin-bottom: 0.5rem;
}
.content-markdown code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
font-family: 'Courier New', monospace;
}
.content-markdown pre {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1rem;
}
.content-markdown pre code {
background: none;
padding: 0;
color: inherit;
}
.content-markdown table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.content-markdown th,
.content-markdown td {
border: 1px solid #e2e8f0;
padding: 0.5rem;
text-align: left;
}
.content-markdown th {
background: #f8fafc;
font-weight: 600;
}
.content-markdown blockquote {
border-left: 4px solid #667eea;
padding-left: 1rem;
margin-left: 0;
color: #64748b;
font-style: italic;
}
/* Empty State */
.empty-content {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
color: #94a3b8;
font-size: 1rem;
}

View File

@ -1,82 +1,73 @@
import React from 'react';
import { Tabs } from 'antd';
import { FileText, Brain } from 'lucide-react';
import MindMap from './MindMap';
import React, { useState } from 'react';
import { Card, Tabs, Typography, Space, Button, Empty } from 'antd';
import {
FileTextOutlined,
PartitionOutlined,
CopyOutlined,
PictureOutlined,
BulbOutlined
} from '@ant-design/icons';
import MarkdownRenderer from './MarkdownRenderer';
import './ContentViewer.css';
import MindMap from './MindMap';
const { TabPane } = Tabs;
const { Title, Text } = Typography;
/**
* ContentViewer - 纯展示组件用于显示Markdown内容和脑图
*
* 设计原则
* 1. 组件只负责纯展示不处理数据获取
* 2. 父组件负责数据准备和导出功能
* 3. 通过props传入已准备好的content
*
* @param {Object} props
* @param {string} props.content - Markdown格式的内容必须由父组件准备好
* @param {string} props.title - 标题用于脑图显示
* @param {string} props.emptyMessage - 内容为空时的提示消息
* @param {React.ReactNode} props.summaryActions - 总结tab的额外操作按钮如导出
* @param {React.ReactNode} props.mindmapActions - 脑图tab的额外操作按钮如导出
*/
const ContentViewer = ({
content,
title,
emptyMessage = '暂无内容',
summaryActions,
mindmapActions
const ContentViewer = ({
content,
title,
emptyMessage = "暂无内容",
summaryActions = null,
mindmapActions = null
}) => {
return (
<div className="content-viewer">
<Tabs defaultActiveKey="content">
<TabPane
tab={
<span>
<FileText size={16} /> 摘要
</span>
}
key="content"
>
<div className="tab-header">
<h2><FileText size={20} /> AI总结</h2>
{summaryActions && <div className="tab-actions">{summaryActions}</div>}
</div>
<div className="content-markdown">
<MarkdownRenderer
content={content}
className=""
emptyMessage={emptyMessage}
/>
</div>
</TabPane>
const [activeTab, setActiveTab] = useState('summary');
<TabPane
tab={
<span>
<Brain size={16} /> 脑图
</span>
}
key="mindmap"
>
<div className="tab-header">
<h2><Brain size={18} /> 思维导图</h2>
{mindmapActions && <div className="tab-actions">{mindmapActions}</div>}
if (!content) {
return (
<Card bordered={false} style={{ borderRadius: 12 }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />
</Card>
);
}
const items = [
{
key: 'summary',
label: <Space><FileTextOutlined />智能摘要</Space>,
children: (
<div style={{ padding: '8px 0' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Space>{summaryActions}</Space>
</div>
{content ? (
<MindMap
content={content}
title={title}
initialScale={1.8}
/>
) : (
<div className="empty-content">等待内容生成后查看脑图</div>
)}
</TabPane>
</Tabs>
</div>
<MarkdownRenderer content={content} />
</div>
)
},
{
key: 'mindmap',
label: <Space><PartitionOutlined />思维导图</Space>,
children: (
<div style={{ padding: '8px 0' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Space>{mindmapActions}</Space>
</div>
<div style={{ minHeight: 500, background: '#f8fafc', borderRadius: 12 }}>
<MindMap content={content} title={title} />
</div>
</div>
)
}
];
return (
<Card bordered={false} bodyStyle={{ padding: '12px 24px' }} style={{ borderRadius: 12 }}>
<Tabs
className="console-tabs"
activeKey={activeTab}
onChange={setActiveTab}
items={items}
size="large"
/>
</Card>
);
};

View File

@ -1,258 +0,0 @@
.datetime-picker {
position: relative;
width: 100%;
}
.datetime-display {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-height: 48px;
}
.datetime-display:hover {
border-color: #9ca3af;
}
.datetime-display:focus-within,
.datetime-display:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.display-text {
flex: 1;
color: #374151;
font-size: 14px;
}
.display-text.placeholder {
color: #9ca3af;
}
.clear-btn {
background: none;
border: none;
color: #9ca3af;
font-size: 18px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.clear-btn:hover {
background: #f3f4f6;
color: #6b7280;
}
.datetime-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
z-index: 10;
pointer-events: auto;
}
.datetime-picker-panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
z-index: 20;
margin-top: 4px;
padding: 20px;
min-width: 320px;
max-height: 500px;
height: auto;
min-height: 400px;
overflow-y: auto;
}
.picker-section {
margin-bottom: 24px;
}
.picker-section:last-of-type {
margin-bottom: 16px;
}
.picker-section h4 {
margin: 0 0 12px 0;
color: #374151;
font-size: 14px;
font-weight: 600;
}
.quick-date-options,
.quick-time-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.quick-time-options {
grid-template-columns: repeat(4, 1fr);
}
.quick-option {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.quick-option:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.quick-option.selected {
background: #667eea;
border-color: #667eea;
color: white;
}
.custom-date-input,
.custom-time-input {
display: flex;
align-items: center;
gap: 8px;
}
.custom-time-input {
background: #f8fafc;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.date-input,
.time-input {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
background: white;
transition: all 0.2s ease;
flex: 1;
}
.time-input {
border: none;
background: transparent;
flex: 1;
}
.date-input:focus,
.time-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.time-input:focus {
box-shadow: none;
}
.picker-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.action-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.action-btn.cancel {
background: #f8fafc;
color: #6b7280;
border-color: #d1d5db;
}
.action-btn.cancel:hover {
background: #f1f5f9;
border-color: #9ca3af;
}
.action-btn.confirm {
background: #667eea;
color: white;
}
.action-btn.confirm:hover {
background: #5a67d8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.datetime-picker-panel {
min-width: 280px;
padding: 16px;
}
.quick-date-options {
grid-template-columns: repeat(2, 1fr);
}
.quick-time-options {
grid-template-columns: repeat(3, 1fr);
}
.picker-actions {
flex-direction: column;
gap: 8px;
}
.action-btn {
width: 100%;
text-align: center;
}
}
/* 改进输入框在Safari中的显示 */
.date-input::-webkit-calendar-picker-indicator,
.time-input::-webkit-calendar-picker-indicator {
background: transparent;
color: #6b7280;
cursor: pointer;
}
.date-input::-webkit-calendar-picker-indicator:hover,
.time-input::-webkit-calendar-picker-indicator:hover {
background: #f3f4f6;
border-radius: 4px;
}

View File

@ -1,260 +1,18 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock } from 'lucide-react';
import './DateTimePicker.css';
const DateTimePicker = ({ value, onChange, placeholder = "选择会议时间" }) => {
const [date, setDate] = useState('');
const [time, setTime] = useState('');
const [showQuickSelect, setShowQuickSelect] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
//
useEffect(() => {
return () => {
setShowQuickSelect(false);
};
}, []);
//
useEffect(() => {
if (value && !isInitialized) {
const dateObj = new Date(value);
if (!isNaN(dateObj.getTime())) {
//
const timeZoneOffset = dateObj.getTimezoneOffset() * 60000;
const localDate = new Date(dateObj.getTime() - timeZoneOffset);
const isoString = localDate.toISOString();
setDate(isoString.split('T')[0]);
setTime(isoString.split('T')[1].slice(0, 5));
}
setIsInitialized(true);
} else if (!value && !isInitialized) {
setDate('');
setTime('');
setIsInitialized(true);
}
}, [value, isInitialized]);
//
useEffect(() => {
// onChange
if (!isInitialized) return;
if (date && time) {
const dateTimeString = `${date}T${time}`;
onChange?.(dateTimeString);
} else if (!date && !time) {
onChange?.('');
}
}, [date, time, isInitialized]); // onChange
//
const timeOptions = [
{ label: '09:00', value: '09:00' },
{ label: '10:00', value: '10:00' },
{ label: '11:00', value: '11:00' },
{ label: '14:00', value: '14:00' },
{ label: '15:00', value: '15:00' },
{ label: '16:00', value: '16:00' },
{ label: '17:00', value: '17:00' },
];
//
const getQuickDateOptions = () => {
const today = new Date();
const options = [];
//
options.push({
label: '今天',
value: today.toISOString().split('T')[0]
});
//
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
options.push({
label: '明天',
value: tomorrow.toISOString().split('T')[0]
});
//
const dayAfterTomorrow = new Date(today);
dayAfterTomorrow.setDate(today.getDate() + 2);
options.push({
label: '后天',
value: dayAfterTomorrow.toISOString().split('T')[0]
});
return options;
};
const quickDateOptions = getQuickDateOptions();
const formatDisplayText = () => {
if (!date && !time) return placeholder;
if (date && time) {
const dateObj = new Date(`${date}T${time}`);
return dateObj.toLocaleString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
if (date) {
const dateObj = new Date(date);
return dateObj.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
return placeholder;
};
const clearDateTime = () => {
setDate('');
setTime('');
//
setIsInitialized(false);
onChange?.('');
};
import React from 'react';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
const DateTimePicker = ({ value, onChange, placeholder = "选择日期时间" }) => {
return (
<div className="datetime-picker">
<div className="datetime-display" onClick={(e) => {
e.stopPropagation();
setShowQuickSelect(!showQuickSelect);
}}>
<Calendar size={18} />
<span className={`display-text ${(!date && !time) ? 'placeholder' : ''}`}>
{formatDisplayText()}
</span>
{(date || time) && (
<button
type="button"
className="clear-btn"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearDateTime();
}}
>
×
</button>
)}
</div>
{showQuickSelect && (
<div className="datetime-picker-panel">
<div className="picker-section">
<h4>选择日期</h4>
<div className="quick-date-options">
{quickDateOptions.map((option) => (
<button
key={option.value}
type="button"
className={`quick-option ${date === option.value ? 'selected' : ''}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDate(option.value);
}}
>
{option.label}
</button>
))}
</div>
<div className="custom-date-input">
<input
type="date"
value={date}
onChange={(e) => {
e.preventDefault();
e.stopPropagation();
setDate(e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="date-input"
/>
</div>
</div>
<div className="picker-section">
<h4>选择时间</h4>
<div className="quick-time-options">
{timeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`quick-option ${time === option.value ? 'selected' : ''}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTime(option.value);
}}
>
{option.label}
</button>
))}
</div>
<div className="custom-time-input">
<Clock size={16} />
<input
type="time"
value={time}
onChange={(e) => {
e.preventDefault();
e.stopPropagation();
setTime(e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="time-input"
/>
</div>
</div>
<div className="picker-actions">
<button
type="button"
className="action-btn cancel"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowQuickSelect(false);
}}
>
取消
</button>
<button
type="button"
className="action-btn confirm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowQuickSelect(false);
}}
>
确认
</button>
</div>
</div>
)}
{showQuickSelect && (
<div
className="datetime-picker-overlay"
onClick={() => setShowQuickSelect(false)}
/>
)}
</div>
<DatePicker
showTime
placeholder={placeholder}
value={value ? dayjs(value) : null}
onChange={(date) => onChange(date ? date.format('YYYY-MM-DD HH:mm:ss') : null)}
style={{ width: '100%' }}
size="large"
/>
);
};
export default DateTimePicker;
export default DateTimePicker;

View File

@ -1,101 +0,0 @@
/* Dropdown Container */
.dropdown-container {
position: relative;
display: inline-block;
}
.dropdown-trigger-wrapper {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Dropdown Menu */
.dropdown-menu-wrapper {
position: absolute;
top: calc(100% + 0.5rem);
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #e2e8f0;
padding: 0.5rem;
min-width: 140px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
animation: dropdown-fade-in 0.2s ease;
}
@keyframes dropdown-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Alignment */
.dropdown-align-left {
left: 0;
}
.dropdown-align-right {
right: 0;
}
/* Menu Item */
.dropdown-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 1rem;
border: none;
background: none;
cursor: pointer;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
text-decoration: none;
transition: all 0.2s ease;
width: 100%;
text-align: left;
white-space: nowrap;
}
.dropdown-menu-item:hover:not(:disabled) {
background: #f3f4f6;
color: #111827;
}
.dropdown-menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Danger Item */
.dropdown-menu-item.danger {
color: #dc2626;
}
.dropdown-menu-item.danger:hover:not(:disabled) {
background: #fef2f2;
color: #b91c1c;
}
/* Item Icon and Label */
.dropdown-item-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dropdown-item-label {
flex: 1;
}

View File

@ -1,81 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import './Dropdown.css';
/**
* Dropdown - 通用下拉菜单组件
*
* @param {Object} props
* @param {React.ReactNode} props.trigger - 触发器元素按钮
* @param {Array<Object>} props.items - 菜单项数组
* - label: string - 显示文本
* - icon: React.ReactNode - 图标可选
* - onClick: function - 点击回调
* - className: string - 自定义样式类可选
* - danger: boolean - 是否为危险操作红色
* @param {string} props.align - 对齐方式: 'left' | 'right'默认 'right'
* @param {string} props.className - 外层容器自定义类名
*/
const Dropdown = ({
trigger,
items = [],
align = 'right',
className = ''
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
//
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleTriggerClick = (e) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
};
const handleItemClick = (item, e) => {
e.preventDefault();
e.stopPropagation();
if (item.onClick) {
item.onClick(e);
}
setIsOpen(false);
};
return (
<div className={`dropdown-container ${className}`} ref={dropdownRef}>
<div className="dropdown-trigger-wrapper" onClick={handleTriggerClick}>
{trigger}
</div>
{isOpen && (
<div className={`dropdown-menu-wrapper dropdown-align-${align}`}>
{items.map((item, index) => (
<button
key={index}
className={`dropdown-menu-item ${item.danger ? 'danger' : ''} ${item.className || ''}`}
onClick={(e) => handleItemClick(item, e)}
disabled={item.disabled}
>
{item.icon && <span className="dropdown-item-icon">{item.icon}</span>}
<span className="dropdown-item-label">{item.label}</span>
</button>
))}
</div>
)}
</div>
);
};
export default Dropdown;

View File

@ -1,104 +0,0 @@
/* 可展开搜索框组件样式 */
/* 只读标题样式(当没有搜索功能时) */
.expand-search-readonly {
display: flex;
align-items: center;
gap: 0.5rem;
}
.expand-search-readonly h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
}
.expand-search-readonly svg {
color: #667eea;
}
/* 可展开的紧凑搜索框 */
.expand-search-box {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
width: fit-content;
max-width: 150px;
height: 40px;
}
.expand-search-box .search-icon {
color: #667eea;
flex-shrink: 0;
transition: color 0.3s ease;
display: flex;
align-items: center;
}
.expand-search-box:hover:not(.expanded) {
border-color: #cbd5e1;
background: white;
}
.expand-search-box:hover:not(.expanded) .search-icon {
color: #5a67d8;
}
.expand-search-box .search-placeholder {
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
user-select: none;
line-height: 1.4;
}
/* 展开状态 */
.expand-search-box.expanded {
cursor: text;
max-width: 100%;
width: 100%;
border-color: #e2e8f0;
background: white;
}
.expand-search-box.expanded .search-icon {
color: #667eea;
}
/* Ant Design Input 样式定制 */
.expand-search-box .search-input-antd {
flex: 1;
height: 32px;
border-radius: 6px;
}
.expand-search-box .search-input-antd .ant-input {
font-size: 0.9rem;
}
/* 移除默认的焦点阴影,使用自定义样式 */
.expand-search-box .search-input-antd:focus,
.expand-search-box .search-input-antd:focus-within {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.expand-search-box {
width: 100%;
max-width: 100%;
}
.expand-search-box.expanded {
width: 100%;
}
}

View File

@ -1,119 +1,16 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState } from 'react';
import { Input } from 'antd';
import { Search } from 'lucide-react';
import './ExpandSearchBox.css';
const ExpandSearchBox = ({
searchQuery = '',
onSearchChange = null,
placeholder = '搜索会议名称或发起人...',
collapsedText = '会议搜索',
showIcon = true,
realTimeSearch = false, // false
debounceDelay = 500 // ()
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [inputValue, setInputValue] = useState(searchQuery);
const debounceTimerRef = useRef(null);
// searchQuery
useEffect(() => {
setInputValue(searchQuery);
}, [searchQuery]);
const handleInputChange = (e) => {
const value = e.target.value;
setInputValue(value);
// 使
if (realTimeSearch && onSearchChange) {
//
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
//
debounceTimerRef.current = setTimeout(() => {
onSearchChange(value.trim());
}, debounceDelay);
}
};
const handleSearch = () => {
//
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (onSearchChange) {
onSearchChange(inputValue.trim());
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleClear = () => {
//
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
setInputValue('');
if (onSearchChange) {
onSearchChange('');
}
};
//
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// ,
if (!onSearchChange) {
return (
<div className="expand-search-readonly">
{showIcon && <Search size={18} />}
<h3>{collapsedText}</h3>
</div>
);
}
import { SearchOutlined } from '@ant-design/icons';
const ExpandSearchBox = ({ onSearch, placeholder = "搜索会议..." }) => {
return (
<div
className={`expand-search-box ${isExpanded ? 'expanded' : ''}`}
onClick={() => !isExpanded && setIsExpanded(true)}
>
{showIcon && <Search size={18} className="search-icon" />}
{isExpanded ? (
<Input
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
onPressEnter={handleSearch}
onKeyPress={handleKeyPress}
onBlur={() => {
if (!inputValue) setIsExpanded(false);
}}
allowClear={{
clearIcon: <span onClick={handleClear}>×</span>
}}
onClear={handleClear}
autoFocus
className="search-input-antd"
/>
) : (
<span className="search-placeholder">{collapsedText}</span>
)}
</div>
<Input.Search
placeholder={placeholder}
onSearch={onSearch}
style={{ width: 300 }}
allowClear
enterButton
/>
);
};

View File

@ -1,196 +0,0 @@
/* FormModal 通用模态框样式 */
/* 遮罩层 */
.form-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 模态框主体 */
.form-modal-content {
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 90%;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 尺寸变体 */
.form-modal-small {
max-width: 500px;
}
.form-modal-medium {
max-width: 700px;
}
.form-modal-large {
max-width: 900px;
}
/* 模态框头部 */
.form-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.form-modal-header-left {
display: flex;
align-items: center;
gap: 1.5rem;
flex: 1;
min-width: 0; /* 允许flex项目收缩 */
}
.form-modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
white-space: nowrap; /* 防止标题换行 */
}
.form-modal-header-extra {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.form-modal-close-btn {
padding: 0.5rem;
border: none;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.form-modal-close-btn:hover {
background: #e2e8f0;
color: #1e293b;
}
/* 模态框主体内容 - 可滚动区域 */
.form-modal-body {
padding: 1.5rem 2rem;
overflow-y: auto;
flex: 1;
min-height: 0; /* 重要允许flex项目正确滚动 */
}
/* 模态框底部操作区 */
.form-modal-actions {
padding: 1rem 2rem;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 1rem;
justify-content: flex-end;
flex-shrink: 0;
}
/* 滚动条样式 */
.form-modal-body::-webkit-scrollbar {
width: 6px;
}
.form-modal-body::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.form-modal-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.form-modal-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.form-modal-content {
width: 95%;
max-height: 90vh;
}
.form-modal-header {
padding: 1rem 1.5rem;
}
.form-modal-header h2 {
font-size: 1.125rem;
}
.form-modal-body {
padding: 1rem 1.5rem;
}
.form-modal-actions {
padding: 0.75rem 1.5rem;
}
/* 小屏幕下标题和额外内容垂直排列 */
.form-modal-header-left {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
/* 低分辨率优化 */
@media (max-height: 700px) {
.form-modal-content {
max-height: 90vh;
}
.form-modal-body {
padding: 1rem 2rem;
}
}

View File

@ -1,67 +0,0 @@
import React from 'react';
import { X } from 'lucide-react';
import './FormModal.css';
/**
* 通用表单模态框组件
* @param {boolean} isOpen - 是否显示模态框
* @param {function} onClose - 关闭回调
* @param {string} title - 标题
* @param {React.ReactNode} children - 表单内容
* @param {React.ReactNode} actions - 底部操作按钮
* @param {string} size - 尺寸 'small' | 'medium' | 'large'
* @param {React.ReactNode} headerExtra - 标题栏额外内容如步骤指示器
* @param {string} className - 自定义类名
*/
const FormModal = ({
isOpen,
onClose,
title,
children,
actions,
size = 'medium',
headerExtra = null,
className = ''
}) => {
if (!isOpen) return null;
const sizeClass = `form-modal-${size}`;
return (
<div className="form-modal-overlay" onClick={onClose}>
<div
className={`form-modal-content ${sizeClass} ${className}`}
onClick={(e) => e.stopPropagation()}
>
{/* 模态框头部 */}
<div className="form-modal-header">
<div className="form-modal-header-left">
<h2>{title}</h2>
{headerExtra && <div className="form-modal-header-extra">{headerExtra}</div>}
</div>
<button
onClick={onClose}
className="form-modal-close-btn"
aria-label="关闭"
>
<X size={20} />
</button>
</div>
{/* 模态框主体内容 */}
<div className="form-modal-body">
{children}
</div>
{/* 模态框底部操作区 */}
{actions && (
<div className="form-modal-actions">
{actions}
</div>
)}
</div>
</div>
);
};
export default FormModal;

View File

@ -1,12 +0,0 @@
.app-header {
background-color: #ffffff;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.app-header h1 {
margin: 0;
font-size: 1.8em;
color: #2c3e50;
}

View File

@ -1,11 +1,39 @@
import React from 'react';
import './Header.css';
import { Layout, Space, Avatar, Dropdown, Typography, Button } from 'antd';
import { UserOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
import BrandLogo from './BrandLogo';
const { Header: AntdHeader } = Layout;
const { Text } = Typography;
const Header = ({ user, onLogout }) => {
const items = [
{ key: 'settings', label: '个人设置', icon: <SettingOutlined /> },
{ type: 'divider' },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true, onClick: onLogout }
];
const Header = () => {
return (
<header className="app-header">
<h1>iMeeting (慧会议)</h1>
</header>
<AntdHeader style={{
background: '#fff',
padding: '0 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
zIndex: 10
}}>
<Space size={12}>
<BrandLogo title="iMeeting" size={28} titleSize={18} gap={10} />
</Space>
<Dropdown menu={{ items }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar src={user?.avatar_url} icon={<UserOutlined />} />
<Text strong>{user?.caption}</Text>
</Space>
</Dropdown>
</AntdHeader>
);
};

View File

@ -0,0 +1,417 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Layout, Menu, Button, Avatar, Space, Drawer, Grid, Badge, Breadcrumb, Tooltip } from 'antd';
import {
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import menuService from '../services/menuService';
import { renderMenuIcon } from '../utils/menuIcons';
const { Header, Content, Sider } = Layout;
const { useBreakpoint } = Grid;
const MainLayout = ({ children, user, onLogout }) => {
const [collapsed, setCollapsed] = useState(false);
const [userMenus, setUserMenus] = useState([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [openKeys, setOpenKeys] = useState([]);
const [activeMenuKey, setActiveMenuKey] = useState(null);
const navigate = useNavigate();
const location = useLocation();
const screens = useBreakpoint();
const isMobile = !screens.lg;
useEffect(() => {
const fetchMenus = async () => {
try {
const response = await menuService.getUserMenus();
if (response.code === '200') {
setUserMenus(response.data.menus || []);
}
} catch (error) {
console.error('Error fetching menus:', error);
}
};
fetchMenus();
}, []);
useEffect(() => {
if (isMobile) {
setCollapsed(false);
}
}, [isMobile]);
const menuItems = useMemo(() => {
const sortedMenus = [...userMenus]
.sort((a, b) => {
if ((a.parent_id || 0) !== (b.parent_id || 0)) {
return (a.parent_id || 0) - (b.parent_id || 0);
}
if ((a.sort_order || 0) !== (b.sort_order || 0)) {
return (a.sort_order || 0) - (b.sort_order || 0);
}
return a.menu_id - b.menu_id;
});
const menuById = new Map();
sortedMenus.forEach((menu) => {
menuById.set(menu.menu_id, {
...menu,
key: `menu_${menu.menu_id}`,
icon: renderMenuIcon(menu.menu_icon, menu.menu_code),
children: [],
});
});
const roots = [];
menuById.forEach((menu) => {
if (menu.parent_id && menuById.has(menu.parent_id)) {
menuById.get(menu.parent_id).children.push(menu);
} else {
roots.push(menu);
}
});
const toMenuItem = (menu) => {
const hasChildren = menu.children.length > 0;
return {
key: menu.key,
menu_code: menu.menu_code,
icon: menu.icon,
label: menu.menu_name,
path: hasChildren ? null : menu.menu_url,
children: hasChildren ? menu.children.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)).map(toMenuItem) : undefined,
onClick: () => {
if (menu.menu_type === 'link' && menu.menu_url && !hasChildren) {
setActiveMenuKey(`menu_${menu.menu_id}`);
navigate(menu.menu_url);
setDrawerOpen(false);
}
},
};
};
return roots
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
.map(toMenuItem);
}, [navigate, onLogout, userMenus]);
const flatMenuKeys = useMemo(() => {
const keys = [];
const walk = (items) => {
items.forEach((item) => {
keys.push(item.key);
if (item.children?.length) {
walk(item.children);
}
});
};
walk(menuItems);
return keys;
}, [menuItems]);
const pathKeyEntries = useMemo(() => {
const entries = [];
const walk = (items) => {
items.forEach((item) => {
if (item.path && String(item.path).startsWith('/')) {
entries.push({ path: item.path, key: item.key, menuCode: item.menu_code, label: item.label });
}
if (item.children?.length) {
walk(item.children);
}
});
};
walk(menuItems);
return entries;
}, [menuItems]);
const keyParentMap = useMemo(() => {
const map = new Map();
const walk = (items, parentKey = null) => {
items.forEach((item) => {
if (parentKey) {
map.set(item.key, parentKey);
}
if (item.children?.length) {
walk(item.children, item.key);
}
});
};
walk(menuItems);
return map;
}, [menuItems]);
const menuItemByKey = useMemo(() => {
const map = new Map();
const walk = (items) => {
items.forEach((item) => {
map.set(item.key, item);
if (item.children?.length) {
walk(item.children);
}
});
};
walk(menuItems);
return map;
}, [menuItems]);
useEffect(() => {
if (activeMenuKey && !flatMenuKeys.includes(activeMenuKey)) {
setActiveMenuKey(null);
}
}, [activeMenuKey, flatMenuKeys]);
const selectedMenuKey = useMemo(() => {
if (!pathKeyEntries.length) return [];
const exactMatches = pathKeyEntries.filter((item) => item.path === location.pathname);
if (exactMatches.length) {
if (activeMenuKey && exactMatches.some((item) => item.key === activeMenuKey)) {
return [activeMenuKey];
}
return [exactMatches[0].key];
}
const prefixMatches = [...pathKeyEntries]
.sort((a, b) => String(b.path).length - String(a.path).length)
.filter((item) => location.pathname.startsWith(String(item.path)));
if (prefixMatches.length) {
if (activeMenuKey && prefixMatches.some((item) => item.key === activeMenuKey)) {
return [activeMenuKey];
}
return [prefixMatches[0].key];
}
return [];
}, [activeMenuKey, location.pathname, pathKeyEntries]);
useEffect(() => {
const currentKey = selectedMenuKey[0];
if (!currentKey) return;
const ancestors = [];
const visited = new Set();
let cursor = keyParentMap.get(currentKey);
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
ancestors.unshift(cursor);
cursor = keyParentMap.get(cursor);
}
setOpenKeys((prev) => {
const next = Array.from(new Set([...prev, ...ancestors]));
if (next.length === prev.length && next.every((item, index) => item === prev[index])) {
return prev;
}
return next;
});
}, [keyParentMap, selectedMenuKey]);
const breadcrumbItems = useMemo(() => {
const create = (title, path) => ({ title, path });
const path = location.pathname;
const activeMenuItem = selectedMenuKey[0] ? menuItemByKey.get(selectedMenuKey[0]) : null;
if (path === '/dashboard') return [create(activeMenuItem?.label || '工作台')];
if (path === '/prompt-management') return [create('平台管理', '/admin/management'), create('提示词仓库')];
if (path === '/prompt-config') {
return user?.role_id === 1
? [create('平台管理', '/admin/management'), create('提示词配置')]
: [create('会议管理'), create('提示词配置')];
}
if (path === '/personal-prompts') return [create('会议管理'), create('个人提示词仓库')];
if (path === '/meetings/center' || path === '/meetings/history') return [create('会议管理'), create('会议中心')];
if (path === '/knowledge-base') return [create('知识库')];
if (path.startsWith('/knowledge-base/edit/')) return [create('知识库', '/knowledge-base'), create('编辑知识库')];
if (path === '/account-settings') return [create('个人设置')];
if (path.startsWith('/meetings/')) return [create('会议管理', '/meetings/center'), create('会议详情')];
if (path === '/downloads') return [create('客户端下载')];
if (path === '/admin/management') {
return [create('平台管理')];
}
if (path.startsWith('/admin/management/')) {
const moduleKey = path.split('/')[3];
const moduleNameMap = {
'user-management': '用户管理',
'permission-management': '权限管理',
'dict-management': '字典管理',
'hot-word-management': '热词管理',
'client-management': '客户端管理',
'external-app-management': '外部应用管理',
'terminal-management': '终端管理',
'parameter-management': '参数管理',
'model-management': '模型管理',
};
const systemModules = new Set(['user-management', 'permission-management', 'dict-management', 'parameter-management']);
const parentTitle = systemModules.has(moduleKey) ? '系统管理' : '平台管理';
return [create(parentTitle, '/admin/management'), create(moduleNameMap[moduleKey] || parentTitle)];
}
return [create('当前页面')];
}, [location.pathname, menuItemByKey, selectedMenuKey, user?.role_id]);
const sidebar = (
<>
<div className={`main-layout-brand ${collapsed ? 'collapsed' : ''}`}>
<span className="main-layout-brand-icon">
<img src="/favicon.svg" alt="iMeeting" className="main-layout-brand-icon-image" />
</span>
{!collapsed && (
<div>
<div className="main-layout-brand-title">iMeeting</div>
<div className="main-layout-brand-subtitle">智能会议控制台</div>
</div>
)}
{!isMobile && (
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
className="main-layout-brand-toggle"
/>
)}
</div>
<Menu
mode="inline"
selectedKeys={selectedMenuKey}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
className="main-layout-menu"
/>
<div className={`main-layout-user-panel ${collapsed ? 'collapsed' : ''}`}>
<div
className="main-layout-user-card"
role="button"
tabIndex={0}
onClick={() => navigate('/account-settings')}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
navigate('/account-settings');
}
}}
>
<Avatar
src={user.avatar_url}
icon={!user.avatar_url && <UserOutlined />}
className="main-layout-user-avatar"
/>
{!collapsed && (
<div className="main-layout-user-meta">
<div className="main-layout-user-name">{user.caption || user.username || '用户'}</div>
<div className="main-layout-user-role">
{user.role_name ? String(user.role_name).toUpperCase() : user.role_id === 1 ? 'ADMIN' : 'USER'}
</div>
</div>
)}
</div>
<Tooltip title="退出系统" placement="top">
<Button
type="text"
icon={<LogoutOutlined />}
className="main-layout-logout-btn"
onClick={onLogout}
/>
</Tooltip>
</div>
</>
);
return (
<Layout className="main-layout-shell">
{isMobile ? (
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
placement="left"
width={280}
styles={{ body: { padding: 0, background: '#f7fbff' } }}
closable={false}
>
{sidebar}
</Drawer>
) : (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme="light"
width={252}
collapsedWidth={84}
className="main-layout-sider"
style={{
boxShadow: '2px 0 18px rgba(31, 78, 146, 0.08)',
zIndex: 10,
position: 'fixed',
height: '100vh',
left: 0,
top: 0,
bottom: 0,
background: 'rgba(255,255,255,0.8)',
borderRight: '1px solid #dbe6f2',
}}
>
{sidebar}
</Sider>
)}
<Layout
style={{
marginLeft: isMobile ? 0 : collapsed ? 84 : 252,
transition: 'all 0.25s ease',
}}
>
<Header className="main-layout-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#ffffff' }}>
<Space align="center" size={14}>
{isMobile && (
<Button
type="text"
icon={<MenuUnfoldOutlined />}
onClick={() => setDrawerOpen(true)}
style={{ fontSize: 18 }}
/>
)}
<Breadcrumb
className="main-layout-page-path"
separator={<RightOutlined />}
items={breadcrumbItems.map((item, index) => {
const clickable = Boolean(item.path) && index < breadcrumbItems.length - 1;
return {
title: clickable ? (
<a
href={item.path}
onClick={(event) => {
event.preventDefault();
navigate(item.path);
}}
>
{item.title}
</a>
) : (
item.title
),
};
})}
/>
</Space>
<Space size={14}>
<Badge color="#22c55e" text="在线" />
{!isMobile && <span style={{ fontWeight: 600, color: '#3b4f6c' }}>{user.caption}</span>}
</Space>
</Header>
<Content className="main-layout-content">{children}</Content>
</Layout>
</Layout>
);
};
export default MainLayout;

View File

@ -1,280 +0,0 @@
/* Markdown Editor Component */
.markdown-editor-wrapper {
margin-top: 0.5rem;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 8px 8px 0 0;
flex-wrap: wrap;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0.25rem 0.5rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
color: #374155;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.toolbar-btn:hover {
background: #f0f4ff;
border-color: #667eea;
color: #667eea;
}
.toolbar-btn.active {
background: #667eea;
border-color: #667eea;
color: white;
}
.toolbar-btn.active:hover {
background: #5a67d8;
border-color: #5a67d8;
}
.toolbar-btn strong,
.toolbar-btn em {
font-style: normal;
font-weight: 600;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: #d1d5db;
margin: 0 0.25rem;
}
/* 标题下拉菜单 */
.toolbar-dropdown {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
min-width: 160px;
overflow: visible;
}
.dropdown-menu button {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: white;
text-align: left;
cursor: pointer;
transition: background 0.2s ease;
font-family: inherit;
color: #374155;
}
.dropdown-menu button:hover {
background: #f0f4ff;
color: #667eea;
}
.dropdown-menu button h1,
.dropdown-menu button h2,
.dropdown-menu button h3,
.dropdown-menu button h4,
.dropdown-menu button h5,
.dropdown-menu button h6 {
font-weight: 600;
color: inherit;
}
/* CodeMirror 样式覆盖 */
.markdown-editor-wrapper .cm-editor {
border-top: none !important;
border-radius: 0 0 8px 8px !important;
}
/* 预览区域 */
.markdown-preview {
border: 2px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 2rem;
background: white;
min-height: 400px;
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {
margin: 1.5rem 0 0.75rem 0;
font-weight: 600;
color: #1e293b;
line-height: 1.3;
}
.markdown-preview h1:first-child,
.markdown-preview h2:first-child,
.markdown-preview h3:first-child,
.markdown-preview h4:first-child,
.markdown-preview h5:first-child,
.markdown-preview h6:first-child {
margin-top: 0;
}
.markdown-preview h1 { font-size: 1.875rem; }
.markdown-preview h2 { font-size: 1.5rem; }
.markdown-preview h3 { font-size: 1.25rem; }
.markdown-preview h4 { font-size: 1.125rem; }
.markdown-preview h5 { font-size: 1rem; }
.markdown-preview h6 { font-size: 0.875rem; }
.markdown-preview p {
margin: 0.75rem 0;
line-height: 1.7;
color: #475569;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 1rem 0;
padding-left: 2rem;
}
.markdown-preview li {
margin: 0.5rem 0;
line-height: 1.6;
color: #475569;
}
.markdown-preview strong {
font-weight: 600;
color: #1e293b;
}
.markdown-preview em {
font-style: italic;
}
.markdown-preview code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.875rem;
color: #dc2626;
}
.markdown-preview pre {
background: #f8fafc;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
border: 1px solid #e2e8f0;
margin: 1rem 0;
}
.markdown-preview pre code {
background: none;
padding: 0;
color: #334155;
}
.markdown-preview blockquote {
border-left: 4px solid #667eea;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: #64748b;
background: #f8fafc;
padding: 1rem;
border-radius: 0 8px 8px 0;
}
.markdown-preview table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
border: 1px solid #e2e8f0;
}
.markdown-preview th,
.markdown-preview td {
border: 1px solid #e2e8f0;
padding: 0.75rem;
text-align: left;
}
.markdown-preview th {
background: #f8fafc;
font-weight: 600;
color: #334155;
}
.markdown-preview hr {
border: none;
height: 1px;
background: #e2e8f0;
margin: 2rem 0;
}
.markdown-preview img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
.markdown-preview a {
color: #667eea;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s ease;
}
.markdown-preview a:hover {
border-bottom-color: #667eea;
}
/* 响应式 */
@media (max-width: 768px) {
.editor-toolbar {
gap: 0.125rem;
padding: 0.375rem;
}
.toolbar-btn {
min-width: 28px;
height: 28px;
font-size: 0.75rem;
}
.markdown-preview {
padding: 1rem;
}
}

View File

@ -2,8 +2,20 @@ import React, { useState, useRef, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { EditorView } from '@codemirror/view';
import {
Space, Button, Tooltip, Card,
Divider, Dropdown, Typography
} from 'antd';
import {
BoldOutlined, ItalicOutlined, FontSizeOutlined,
MessageOutlined, CodeOutlined, LinkOutlined,
TableOutlined, PictureOutlined, OrderedListOutlined,
UnorderedListOutlined, LineOutlined, EyeOutlined,
EditOutlined
} from '@ant-design/icons';
import MarkdownRenderer from './MarkdownRenderer';
import './MarkdownEditor.css';
const { Text } = Typography;
const MarkdownEditor = ({
value,
@ -16,45 +28,37 @@ const MarkdownEditor = ({
const editorRef = useRef(null);
const imageInputRef = useRef(null);
const [showPreview, setShowPreview] = useState(false);
const [showHeadingMenu, setShowHeadingMenu] = useState(false);
// CodeMirror extensions
const editorExtensions = useMemo(() => [
markdown({ base: markdownLanguage }),
EditorView.lineWrapping,
EditorView.theme({
"&": {
fontSize: "14px",
border: "2px solid #e2e8f0",
border: "1px solid #d9d9d9",
borderRadius: "0 0 8px 8px",
borderTop: "none",
},
".cm-content": {
fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace",
padding: "1rem",
fontFamily: "var(--ant-font-family-code), monospace",
padding: "16px",
minHeight: `${height}px`,
},
".cm-scroller": {
fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace",
},
"&.cm-focused": {
outline: "none",
borderColor: "#667eea",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.1)",
borderColor: "#1677ff",
boxShadow: "0 0 0 2px rgba(22, 119, 255, 0.1)",
}
})
], [height]);
// Markdown
const insertMarkdown = (before, after = '', placeholder = '') => {
if (!editorRef.current?.view) return;
const view = editorRef.current.view;
const selection = view.state.selection.main;
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
const text = selectedText || placeholder;
const newText = `${before}${text}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: newText },
selection: { anchor: selection.from + before.length, head: selection.from + before.length + text.length }
@ -62,139 +66,75 @@ const MarkdownEditor = ({
view.focus();
};
//
const toolbarActions = {
bold: () => insertMarkdown('**', '**', '粗体文字'),
italic: () => insertMarkdown('*', '*', '斜体文字'),
heading: (level) => {
setShowHeadingMenu(false);
insertMarkdown('#'.repeat(level) + ' ', '', '标题');
},
quote: () => insertMarkdown('> ', '', '引用内容'),
bold: () => insertMarkdown('**', '**', '粗体'),
italic: () => insertMarkdown('*', '*', '斜体'),
heading: (level) => insertMarkdown('#'.repeat(level) + ' ', '', '标题'),
quote: () => insertMarkdown('> ', '', '引用'),
code: () => insertMarkdown('`', '`', '代码'),
codeBlock: () => insertMarkdown('```\n', '\n```', '代码块'),
link: () => insertMarkdown('[', '](url)', '链接文字'),
link: () => insertMarkdown('[', '](url)', '链接'),
unorderedList: () => insertMarkdown('- ', '', '列表项'),
orderedList: () => insertMarkdown('1. ', '', '列表项'),
table: () => {
const tableTemplate = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 单元格 | 单元格 | 单元格 |\n| 单元格 | 单元格 | 单元格 |\n';
insertMarkdown(tableTemplate, '', '');
},
table: () => insertMarkdown('\n| 列1 | 列2 |\n| --- | --- |\n| 单元格 | 单元格 |\n', '', ''),
hr: () => insertMarkdown('\n---\n', '', ''),
image: () => imageInputRef.current?.click(),
};
//
const handleImageSelect = async (event) => {
const file = event.target.files[0];
if (file && onImageUpload) {
const imageUrl = await onImageUpload(file);
if (imageUrl) {
insertMarkdown(`![${file.name}](${imageUrl})`, '', '');
}
}
// Reset file input
if (imageInputRef.current) {
imageInputRef.current.value = '';
}
const headingMenu = {
items: [1, 2, 3, 4, 5, 6].map(level => ({
key: level,
label: `标题 ${level}`,
onClick: () => toolbarActions.heading(level)
}))
};
return (
<div className="markdown-editor-wrapper">
<div className="editor-toolbar">
<button type="button" className="toolbar-btn" onClick={toolbarActions.bold} title="粗体 (Ctrl+B)">
<strong>B</strong>
</button>
<button type="button" className="toolbar-btn" onClick={toolbarActions.italic} title="斜体 (Ctrl+I)">
<em>I</em>
</button>
<div className="markdown-editor-modern">
<Card
size="small"
bodyStyle={{ padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' }}
bordered={false}
>
<Space split={<Divider type="vertical" />} size={4}>
<Space size={2}>
<Tooltip title="粗体"><Button type="text" size="small" icon={<BoldOutlined />} onClick={toolbarActions.bold} /></Tooltip>
<Tooltip title="斜体"><Button type="text" size="small" icon={<ItalicOutlined />} onClick={toolbarActions.italic} /></Tooltip>
<Dropdown menu={headingMenu} placement="bottomLeft">
<Button type="text" size="small" icon={<FontSizeOutlined />} />
</Dropdown>
</Space>
<Space size={2}>
<Tooltip title="引用"><Button type="text" size="small" icon={<MessageOutlined />} onClick={toolbarActions.quote} /></Tooltip>
<Tooltip title="代码"><Button type="text" size="small" icon={<CodeOutlined />} onClick={toolbarActions.code} /></Tooltip>
<Tooltip title="链接"><Button type="text" size="small" icon={<LinkOutlined />} onClick={toolbarActions.link} /></Tooltip>
<Tooltip title="表格"><Button type="text" size="small" icon={<TableOutlined />} onClick={toolbarActions.table} /></Tooltip>
{showImageUpload && (
<Tooltip title="图片"><Button type="text" size="small" icon={<PictureOutlined />} onClick={toolbarActions.image} /></Tooltip>
)}
</Space>
{/* 多级标题下拉菜单 */}
<div className="toolbar-dropdown">
<button
type="button"
className="toolbar-btn"
onClick={() => setShowHeadingMenu(!showHeadingMenu)}
title="标题"
<Space size={2}>
<Tooltip title="无序列表"><Button type="text" size="small" icon={<UnorderedListOutlined />} onClick={toolbarActions.unorderedList} /></Tooltip>
<Tooltip title="有序列表"><Button type="text" size="small" icon={<OrderedListOutlined />} onClick={toolbarActions.orderedList} /></Tooltip>
<Tooltip title="分隔线"><Button type="text" size="small" icon={<LineOutlined />} onClick={toolbarActions.hr} /></Tooltip>
</Space>
<Button
type={showPreview ? "primary" : "text"}
size="small"
icon={showPreview ? <EditOutlined /> : <EyeOutlined />}
onClick={() => setShowPreview(!showPreview)}
>
H
</button>
{showHeadingMenu && (
<div className="dropdown-menu">
<button type="button" onClick={() => toolbarActions.heading(1)}>
<h1 style={{ fontSize: '1.5rem', margin: 0 }}>标题 1</h1>
</button>
<button type="button" onClick={() => toolbarActions.heading(2)}>
<h2 style={{ fontSize: '1.3rem', margin: 0 }}>标题 2</h2>
</button>
<button type="button" onClick={() => toolbarActions.heading(3)}>
<h3 style={{ fontSize: '1.1rem', margin: 0 }}>标题 3</h3>
</button>
<button type="button" onClick={() => toolbarActions.heading(4)}>
<h4 style={{ fontSize: '1rem', margin: 0 }}>标题 4</h4>
</button>
<button type="button" onClick={() => toolbarActions.heading(5)}>
<h5 style={{ fontSize: '0.9rem', margin: 0 }}>标题 5</h5>
</button>
<button type="button" onClick={() => toolbarActions.heading(6)}>
<h6 style={{ fontSize: '0.85rem', margin: 0 }}>标题 6</h6>
</button>
</div>
)}
</div>
<span className="toolbar-divider"></span>
<button type="button" className="toolbar-btn" onClick={toolbarActions.quote} title="引用">
"
</button>
<button type="button" className="toolbar-btn" onClick={toolbarActions.code} title="代码">
{'<>'}
</button>
<button type="button" className="toolbar-btn" onClick={toolbarActions.link} title="链接">
🔗
</button>
<button type="button" className="toolbar-btn" onClick={toolbarActions.table} title="表格">
</button>
{showImageUpload && (
<button type="button" className="toolbar-btn" onClick={toolbarActions.image} title="上传图片">
</button>
)}
<span className="toolbar-divider"></span>
<button type="button" className="toolbar-btn" onClick={toolbarActions.unorderedList} title="无序列表">
</button>
<button type="button" className="toolbar-btn" onClick={toolbarActions.orderedList} title="有序列表">
1.
</button>
<button type="button" className="toolbar-btn" onClick={toolbarActions.hr} title="分隔线">
</button>
<span className="toolbar-divider"></span>
{/* 预览按钮 */}
<button
type="button"
className={`toolbar-btn ${showPreview ? 'active' : ''}`}
onClick={() => setShowPreview(!showPreview)}
title={showPreview ? "编辑" : "预览"}
>
{showPreview ? '编辑' : '预览'}
</button>
</div>
{showPreview ? "编辑" : "预览"}
</Button>
</Space>
</Card>
{showPreview ? (
<MarkdownRenderer
content={value}
className="markdown-preview"
emptyMessage="*暂无内容*"
/>
<Card bordered bodyStyle={{ padding: 16, minHeight: height, overflowY: 'auto' }} style={{ borderRadius: '0 0 8px 8px' }}>
<MarkdownRenderer content={value} />
</Card>
) : (
<CodeMirror
ref={editorRef}
@ -202,24 +142,17 @@ const MarkdownEditor = ({
onChange={onChange}
extensions={editorExtensions}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLineGutter: false,
highlightActiveLine: false,
}}
basicSetup={{ lineNumbers: false, foldGutter: false }}
/>
)}
{showImageUpload && (
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
style={{ display: 'none' }}
/>
)}
<input ref={imageInputRef} type="file" accept="image/*" onChange={(e) => {
const file = e.target.files[0];
if (file && onImageUpload) {
onImageUpload(file).then(url => url && insertMarkdown(`![${file.name}](${url})`, '', ''));
}
e.target.value = '';
}} style={{ display: 'none' }} />
</div>
);
};

View File

@ -1,285 +0,0 @@
/* Unified Markdown Renderer Styles */
.markdown-renderer {
font-size: 1rem;
line-height: 1.8;
color: #475569;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.markdown-empty {
text-align: center;
color: #94a3b8;
font-style: italic;
padding: 2rem;
}
/* Headings */
.markdown-renderer h1 {
color: #1e293b;
font-size: 1.5rem;
margin: 1.5rem 0 0.75rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h1:first-child {
margin-top: 0;
}
.markdown-renderer h2 {
color: #374151;
font-size: 1.375rem;
margin: 1.25rem 0 0.625rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h2:first-child {
margin-top: 0;
}
.markdown-renderer h3 {
color: #475569;
font-size: 1.25rem;
margin: 1.125rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h3:first-child {
margin-top: 0;
}
.markdown-renderer h4 {
color: #475569;
font-size: 1.125rem;
margin: 1rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
}
.markdown-renderer h5,
.markdown-renderer h6 {
color: #475569;
font-size: 1rem;
margin: 0.875rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
}
/* Paragraphs */
.markdown-renderer p {
margin: 0.75rem 0;
color: #475569;
line-height: 1.7;
}
.markdown-renderer p:first-child {
margin-top: 0;
}
.markdown-renderer p:last-child {
margin-bottom: 0;
}
/* Lists */
.markdown-renderer ul,
.markdown-renderer ol {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.markdown-renderer li {
margin: 0.5rem 0;
line-height: 1.6;
color: #475569;
}
.markdown-renderer li p {
margin: 0.25rem 0;
}
/* Text formatting */
.markdown-renderer strong {
color: #1e293b;
font-weight: 600;
}
.markdown-renderer em {
font-style: italic;
}
/* Inline code */
.markdown-renderer code {
background: #f1f5f9;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.875rem;
color: #e11d48;
border: 1px solid #e2e8f0;
}
/* Code blocks */
.markdown-renderer pre {
background: #1e293b;
padding: 1.25rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.25rem 0;
border: 1px solid #334155;
}
.markdown-renderer pre code {
background: transparent;
padding: 0;
color: #e2e8f0;
border: none;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Blockquotes */
.markdown-renderer blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
margin: 1rem 0;
padding: 1rem 1.25rem;
font-style: italic;
border-radius: 0 8px 8px 0;
}
.markdown-renderer blockquote p {
margin: 0.5rem 0;
}
/* Tables */
.markdown-renderer table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.markdown-renderer th,
.markdown-renderer td {
border: 1px solid #e2e8f0;
padding: 0.75rem;
text-align: left;
}
.markdown-renderer th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
.markdown-renderer tbody tr:nth-child(even) {
background: #fafbfc;
}
.markdown-renderer tbody tr:hover {
background: #f1f5f9;
transition: background 0.2s ease;
}
/* Links */
.markdown-renderer a {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.markdown-renderer a:hover {
color: #2563eb;
border-bottom-color: #2563eb;
}
/* Horizontal rules */
.markdown-renderer hr {
border: none;
border-top: 2px solid #e5e7eb;
margin: 2rem 0;
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
}
/* Images */
.markdown-renderer img {
max-width: 100%;
width: auto;
height: auto;
display: block;
margin: 1.25rem auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
object-fit: contain;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.markdown-renderer {
font-size: 0.875rem;
}
.markdown-renderer h1 {
font-size: 1.25rem;
}
.markdown-renderer h2 {
font-size: 1.125rem;
}
.markdown-renderer h3 {
font-size: 1rem;
}
.markdown-renderer h4,
.markdown-renderer h5,
.markdown-renderer h6 {
font-size: 0.9375rem;
}
.markdown-renderer pre {
padding: 0.9375rem;
font-size: 0.75rem;
}
.markdown-renderer table {
font-size: 0.8125rem;
}
.markdown-renderer th,
.markdown-renderer td {
padding: 0.5rem;
}
.markdown-renderer img {
margin: 0.9375rem auto;
border-radius: 6px;
}
}
/* Print styles */
@media print {
.markdown-renderer pre {
background: #f5f5f5;
border: 1px solid #ddd;
}
.markdown-renderer pre code {
color: #000;
}
.markdown-renderer a::after {
content: " (" attr(href) ")";
font-size: 0.85em;
color: #666;
}
}

View File

@ -2,26 +2,52 @@ import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import './MarkdownRenderer.css';
import { Typography, Empty } from 'antd';
/**
* 统一的Markdown渲染组件
*
* @param {string} content - Markdown内容
* @param {string} className - 自定义CSS类名可选
* @param {string} emptyMessage - 内容为空时显示的消息可选
*/
const MarkdownRenderer = ({ content, className = '', emptyMessage = '暂无内容' }) => {
if (!content || content.trim() === '') {
return <div className="markdown-empty">{emptyMessage}</div>;
const { Paragraph } = Typography;
const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内容" }) => {
if (!content) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />;
}
return (
<div className={`markdown-renderer ${className}`}>
<div className={`markdown-renderer-modern ${className}`} style={{ fontSize: '15px', lineHeight: 1.8, color: '#262626' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw]}
components={{
h1: ({node, ...props}) => <Typography.Title level={1} style={{ marginTop: 24 }} {...props} />,
h2: ({node, ...props}) => <Typography.Title level={2} style={{ marginTop: 20 }} {...props} />,
h3: ({node, ...props}) => <Typography.Title level={3} style={{ marginTop: 16 }} {...props} />,
h4: ({node, ...props}) => <Typography.Title level={4} style={{ marginTop: 12 }} {...props} />,
p: ({node, ...props}) => <Paragraph style={{ marginBottom: 16 }} {...props} />,
blockquote: ({node, ...props}) => (
<blockquote style={{
margin: '12px 0', padding: '8px 16px',
borderLeft: '4px solid #1677ff', background: '#f0f5ff',
borderRadius: '0 8px 8px 0', color: '#444',
}} {...props} />
),
li: ({node, ...props}) => <li style={{ marginBottom: 8 }} {...props} />,
ul: ({node, ...props}) => <ul style={{ paddingLeft: 24, marginBottom: 16 }} {...props} />,
ol: ({node, ...props}) => <ol style={{ paddingLeft: 24, marginBottom: 16 }} {...props} />,
hr: ({node, ...props}) => <hr style={{ border: 'none', borderTop: '1px solid #e5e7eb', margin: '20px 0' }} {...props} />,
strong: ({node, ...props}) => <strong style={{ fontWeight: 600 }} {...props} />,
table: ({node, ...props}) => <table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: 16 }} {...props} />,
th: ({node, ...props}) => <th style={{ border: '1px solid #d9d9d9', padding: '8px 12px', background: '#f5f5f5', fontWeight: 600 }} {...props} />,
td: ({node, ...props}) => <td style={{ border: '1px solid #d9d9d9', padding: '8px 12px' }} {...props} />,
code: ({node, inline, className, ...props}) => {
if (inline) {
return <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4, fontSize: '0.9em', color: '#d63384' }} {...props} />;
}
return (
<pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflowX: 'auto', marginBottom: 16 }}>
<code className={className} {...props} />
</pre>
);
},
}}
>
{content}
</ReactMarkdown>

View File

@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Drawer, Form, Input, Button, DatePicker, Select, Space, App } from 'antd';
import { SaveOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
const { TextArea } = Input;
const MeetingFormDrawer = ({ open, onClose, onSuccess, meetingId = null, user }) => {
const { message } = App.useApp();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [users, setUsers] = useState([]);
const [prompts, setPrompts] = useState([]);
const isEdit = Boolean(meetingId);
useEffect(() => {
if (!open) return;
fetchOptions();
if (isEdit) {
fetchMeeting();
} else {
form.resetFields();
form.setFieldsValue({ meeting_time: dayjs() });
}
}, [open, meetingId]);
const fetchOptions = 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 {}
};
const fetchMeeting = async () => {
setFetching(true);
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),
attendees: meeting.attendees?.map((a) => (typeof a === 'string' ? a : a.caption)) || [],
prompt_id: meeting.prompt_id,
tags: meeting.tags?.map((t) => t.name) || [],
description: meeting.description,
});
} catch {
message.error('加载会议数据失败');
} finally {
setFetching(false);
}
};
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'),
attendees: values.attendees?.join(',') || '',
tags: values.tags?.join(',') || '',
};
if (isEdit) {
await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meetingId)), payload);
message.success('会议更新成功');
} else {
payload.creator_id = user.user_id;
const res = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), payload);
if (res.code === '200') {
message.success('会议创建成功');
onSuccess?.(res.data.meeting_id);
onClose();
return;
}
}
onSuccess?.();
onClose();
} catch (error) {
if (!error?.errorFields) {
message.error(error?.response?.data?.message || '操作失败');
}
} finally {
setLoading(false);
}
};
return (
<Drawer
title={isEdit ? '编辑会议' : '新建会议'}
placement="right"
width={560}
open={open}
onClose={onClose}
destroyOnClose
extra={
<Space>
<Button type="primary" icon={<SaveOutlined />} loading={loading} onClick={handleSubmit}>
{isEdit ? '保存修改' : '创建会议'}
</Button>
</Space>
}
>
<Form form={form} layout="vertical" initialValues={{ meeting_time: dayjs() }}>
<Form.Item label="会议主题" name="title" rules={[{ required: true, message: '请输入会议主题' }]}>
<Input placeholder="请输入会议主题..." />
</Form.Item>
<Form.Item label="开始时间" name="meeting_time" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="使用总结模板" name="prompt_id">
<Select allowClear placeholder="选择总结模版">
{prompts.map((p) => (
<Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="参会人员" name="attendees" rules={[{ required: true, message: '请选择参会人员' }]}>
<Select mode="multiple" placeholder="选择参会人">
{users.map((u) => (
<Select.Option key={u.user_id} value={u.caption}>{u.caption}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="标签" name="tags">
<Select mode="tags" placeholder="输入标签按回车" />
</Form.Item>
<Form.Item label="会议备注" name="description">
<TextArea rows={3} placeholder="添加会议背景或说明..." />
</Form.Item>
</Form>
</Drawer>
);
};
export default MeetingFormDrawer;

View File

@ -1,629 +0,0 @@
/* Timeline Container */
.timeline-container {
position: relative;
padding: 2rem 0;
}
.timeline-line {
position: absolute;
left: 6rem; /* Centered timeline */
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, #e2e8f0, #94a3b8);
border-radius: 1px;
z-index: 1;
}
/* Date Section */
.timeline-date-section {
position: relative;
margin-bottom: 2rem;
}
/* This is the new circular node on the timeline */
.timeline-date-section::before {
content: '';
position: absolute;
left: 6rem;
top: 0.5rem; /* Align with date text */
width: 1rem;
height: 1rem;
background: white;
border: 3px solid #667eea;
border-radius: 50%;
transform: translateX(-50%);
z-index: 2;
}
.timeline-date-node {
position: absolute;
left: 0;
top: 0;
width: 5rem; /* Area for the date text */
text-align: right;
}
.date-text {
font-size: 1.1rem;
font-weight: 600;
color: #475569;
line-height: 1.8rem; /* Vertically center with the 1rem node */
}
.meetings-for-date {
padding-left: 8rem; /* Space for date and timeline */
display: flex;
flex-direction: column;
gap: 1.5rem; /* Use gap for consistent spacing */
}
/* Empty State */
.timeline-empty {
text-align: center;
padding: 4rem 2rem;
color: #64748b;
}
.timeline-empty h3 {
margin: 1rem 0 0.5rem 0;
color: #374151;
}
.timeline-empty p {
margin: 0;
}
.meeting-card-link {
text-decoration: none;
color: inherit;
display: block; /* Make the link fill the container */
}
/* Meeting Cards */
.meeting-card-wrapper {
position: relative;
padding: 0 5px;
}
.meeting-card {
position: relative;
opacity: 0;
animation: fadeInUp 0.6s ease forwards;
}
.meeting-title-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
/* Delete Modal */
.delete-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.delete-modal {
background: white;
border-radius: 12px;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.delete-modal h3 {
margin: 0 0 1rem 0;
color: #1e293b;
font-size: 1.25rem;
}
.delete-modal p {
margin: 0 0 2rem 0;
color: #64748b;
line-height: 1.6;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn-cancel, .btn-delete {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
border: none;
}
.btn-cancel {
background: #f1f5f9;
color: #475569;
}
.btn-cancel:hover {
background: #e2e8f0;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.meeting-card:last-child {
margin-bottom: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Dropdown Menu Styles */
.dropdown-trigger {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: #64748b;
}
.dropdown-trigger:hover {
background: #f1f5f9;
color: #334155;
}
.meeting-content {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-left: 4px solid;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.meeting-content:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.meeting-content:active {
transform: translateY(-2px) scale(1.01);
transition: all 0.2s ease;
}
/* Card color coding */
.created-by-me .meeting-content {
border-left-color: #667eea; /* Purple for created */
}
.attended-by-me .meeting-content {
border-left-color: #34d399; /* Green for attended */
}
/* Meeting Header */
.meeting-header {
margin-bottom: 1rem;
}
/* Meeting Title and Tags Layout */
.title-and-tags {
flex: 1;
}
.meeting-title {
margin: 0 0 0.75rem 0;
color: #1e293b;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.meeting-title .inline-tags {
margin: 0;
}
.meeting-title .inline-tags .tag-item {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
font-size: 10px;
padding: 2px 8px;
border-radius: 12px;
}
.meeting-meta {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: #64748b;
font-size: 0.9rem;
}
.meta-item svg {
color: #667eea;
}
/* Meeting Body */
.meeting-body {
margin-bottom: 1rem;
}
.attendees-section {
margin-bottom: 1rem;
}
.attendees-label {
color: #374151;
font-weight: 500;
font-size: 0.9rem;
margin-bottom: 0.5rem;
display: block;
}
.attendees-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.attendee-tag {
background: #f1f5f9;
color: #475569;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid #e2e8f0;
}
/* Summary Section */
.summary-section {
background: #f8fafc;
border-radius: 8px;
padding: 1rem;
border: 1px solid #e2e8f0;
}
.summary-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
color: #374151;
font-weight: 500;
font-size: 0.9rem;
}
.summary-header svg {
color: #667eea;
}
.summary-content {
color: #4b5563;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
.summary-more-hint {
margin-top: 0.5rem;
text-align: center;
}
.more-text {
color: #667eea;
font-size: 0.8rem;
font-weight: 500;
font-style: italic;
opacity: 0.8;
}
/* Markdown content styling */
.markdown-content {
font-size: 0.85rem;
line-height: 1.5;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin: 0.4rem 0 0.2rem 0;
font-weight: 600;
color: #334155;
}
.markdown-content h1:first-child,
.markdown-content h2:first-child,
.markdown-content h3:first-child,
.markdown-content h4:first-child,
.markdown-content h5:first-child,
.markdown-content h6:first-child {
margin-top: 0;
}
.markdown-content h1 { font-size: 1rem; }
.markdown-content h2 { font-size: 0.95rem; }
.markdown-content h3 { font-size: 0.9rem; }
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 { font-size: 0.85rem; }
.markdown-content p {
margin: 0.1rem 0;
}
.markdown-content p:first-child {
margin-top: 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content ul,
.markdown-content ol {
margin: 0.2rem 0;
padding-left: 1.2rem;
}
.markdown-content li {
margin: 0.05rem 0;
}
.markdown-content li p {
margin: 0.05rem 0;
}
.markdown-content strong {
font-weight: 600;
color: #1e293b;
}
.markdown-content em {
font-style: italic;
}
.markdown-content code {
background: rgba(0, 0, 0, 0.05);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.8rem;
}
.markdown-content blockquote {
border-left: 3px solid #e2e8f0;
padding-left: 0.75rem;
margin: 0.5rem 0;
font-style: italic;
color: #64748b;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.8rem;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #e2e8f0;
padding: 0.4rem;
text-align: left;
}
.markdown-content th {
background: #f8fafc;
font-weight: 600;
color: #334155;
}
.markdown-content hr {
border: none;
height: 1px;
background: #e2e8f0;
margin: 1rem 0;
}
/* Meeting Footer */
.meeting-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
.creator-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: #94a3b8;
font-size: 0.8rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.timeline-line {
left: 1rem;
}
.timeline-date-section::before {
left: 1rem;
}
.timeline-date-node {
position: static;
width: auto;
text-align: left;
padding: 0 0 1rem 2rem; /* Give space for the line and node */
}
.meetings-for-date {
padding-left: 2rem;
}
.meeting-card {
margin-bottom: 1.5rem;
}
.meeting-content {
padding: 1rem;
}
.meeting-title {
font-size: 1.1rem;
}
.meeting-meta {
gap: 1rem;
}
.meta-item {
font-size: 0.85rem;
}
.attendees-list {
gap: 0.25rem;
}
.attendee-tag {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
}
/* Timeline Footer - 加载更多/加载完毕 */
.timeline-footer {
margin-top: 2.5rem;
padding: 1.5rem 0 2rem 0;
text-align: center;
}
.load-more-btn {
position: relative;
padding: 0.7rem 2.5rem;
background: white;
color: #667eea;
border: 2px solid #e2e8f0;
border-radius: 50px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.load-more-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
opacity: 0;
transition: opacity 0.3s ease;
z-index: 0;
}
.load-more-btn span {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
}
.load-more-btn:hover:not(:disabled) {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.15);
}
.load-more-btn:hover:not(:disabled)::before {
opacity: 1;
}
.load-more-btn:hover:not(:disabled) span {
color: white;
}
.load-more-btn:active:not(:disabled) {
transform: translateY(0);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: #e2e8f0;
}
.all-loaded {
padding: 1rem;
color: #94a3b8;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.all-loaded::before,
.all-loaded::after {
content: '';
width: 60px;
height: 1px;
background: linear-gradient(to right, transparent, #cbd5e1, transparent);
}
.all-loaded::after {
background: linear-gradient(to left, transparent, #cbd5e1, transparent);
}

View File

@ -1,215 +1,173 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Clock, Users, FileText, User, Edit, Calendar , Trash2, MoreVertical } from 'lucide-react';
import TagDisplay from './TagDisplay';
import ConfirmDialog from './ConfirmDialog';
import Dropdown from './Dropdown';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
App,
Avatar,
Button,
Card,
Divider,
Dropdown,
Space,
Tag,
Timeline,
Typography,
} from 'antd';
import {
ArrowRightOutlined,
CalendarOutlined,
ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
MoreOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import MarkdownRenderer from './MarkdownRenderer';
import tools from '../utils/tools';
import './MeetingTimeline.css';
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false, filterType = 'all', searchQuery = '', selectedTags = [] }) => {
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const { Title, Text, Paragraph } = Typography;
const formatDateMeta = (date) => {
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) {
return { main: date, sub: '' };
}
return {
main: tools.formatDateLong(date),
sub: parsed.toLocaleDateString('zh-CN', { weekday: 'long' }),
};
};
const MeetingTimeline = ({
meetingsByDate,
currentUser,
onDeleteMeeting,
hasMore = false,
onLoadMore,
loadingMore = false,
filterType = 'all',
searchQuery = '',
selectedTags = [],
}) => {
const { modal } = App.useApp();
const navigate = useNavigate();
const shouldShowMoreButton = (summary, maxLines = 3, maxLength = 100) => {
if (!summary) return false;
const lines = summary.split('\n');
return lines.length > maxLines || summary.length > maxLength;
};
const handleEditClick = (meetingId) => {
const handleEditClick = (event, meetingId) => {
event.preventDefault();
event.stopPropagation();
navigate(`/meetings/edit/${meetingId}`);
};
const handleDeleteClick = (meeting) => {
setDeleteConfirmInfo({
id: meeting.meeting_id,
title: meeting.title
const handleDeleteClick = (event, meeting) => {
event.preventDefault();
event.stopPropagation();
modal.confirm({
title: '删除会议',
content: `确定要删除会议“${meeting.title}”吗?此操作无法撤销。`,
okText: '删除',
okType: 'danger',
onOk: () => onDeleteMeeting(meeting.meeting_id),
});
};
const handleConfirmDelete = async () => {
if (onDeleteMeeting && deleteConfirmInfo) {
await onDeleteMeeting(deleteConfirmInfo.id);
}
setDeleteConfirmInfo(null);
};
const sortedDates = Object.keys(meetingsByDate).sort((a, b) => new Date(b) - new Date(a));
if (sortedDates.length === 0) {
return (
<div className="timeline-empty">
<Calendar size={48} />
<h3>暂无会议记录</h3>
<p>您还没有参与任何会议</p>
</div>
);
}
const timelineItems = sortedDates.map((date) => {
const dateMeta = formatDateMeta(date);
return {
label: (
<div className="timeline-date-label">
<Text className="timeline-date-main">{dateMeta.main}</Text>
{dateMeta.sub ? <Text className="timeline-date-sub">{dateMeta.sub}</Text> : null}
</div>
),
children: (
<div className="timeline-date-group">
{meetingsByDate[date].map((meeting) => {
const isCreator = String(meeting.creator_id) === String(currentUser.user_id);
const menuItems = [
{ key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: ({ domEvent }) => handleEditClick(domEvent, meeting.meeting_id) },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: ({ domEvent }) => handleDeleteClick(domEvent, meeting) },
];
return (
<Card
key={meeting.meeting_id}
hoverable
className="timeline-meeting-card"
style={{ borderLeft: isCreator ? '4px solid #1677ff' : '4px solid #52c41a' }}
onClick={() => navigate(`/meetings/${meeting.meeting_id}`, {
state: { filterContext: { filterType, searchQuery, selectedTags } },
})}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 14, gap: 16 }}>
<Space direction="vertical" size={6} style={{ flex: 1 }}>
<Title level={4} style={{ margin: 0, fontSize: 20 }}>{meeting.title}</Title>
<Space size={12} split={<Divider type="vertical" />} wrap>
<Text type="secondary"><ClockCircleOutlined /> {tools.formatTime(meeting.meeting_time)}</Text>
<Text type="secondary"><TeamOutlined /> {meeting.attendees?.length || 0} </Text>
<Space size={[6, 6]} wrap>
{meeting.tags?.slice(0, 4).map((tag) => (
<Tag key={tag.id} color="blue" bordered={false} style={{ fontSize: 12, borderRadius: 999 }}>
{tag.name}
</Tag>
))}
</Space>
</Space>
</Space>
{isCreator ? (
<Dropdown menu={{ items: menuItems }} placement="bottomRight" arrow trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} className="timeline-action-trigger" onClick={(event) => event.stopPropagation()} />
</Dropdown>
) : null}
</div>
{meeting.summary ? (
<div className="timeline-summary-box">
<Space size={8} style={{ marginBottom: 8, display: 'flex' }}>
<FileTextOutlined style={{ color: '#1677ff' }} />
<Text strong>会议摘要</Text>
</Space>
<div className="timeline-summary-content">
<Paragraph ellipsis={{ rows: 2 }} type="secondary" style={{ margin: 0, fontSize: 13 }}>
<MarkdownRenderer content={tools.truncateSummary(meeting.summary)} />
</Paragraph>
</div>
</div>
) : null}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Space>
<Avatar size="small" src={meeting.creator_avatar_url} icon={<UserOutlined />} />
<Text type="secondary" style={{ fontSize: 12 }}>{meeting.creator_username}</Text>
</Space>
<Button type="text" size="small" icon={<ArrowRightOutlined />} className="timeline-footer-link">
查看详情
</Button>
</div>
</Card>
);
})}
</div>
),
};
});
return (
<div className="timeline-container">
<div className="timeline-line"></div>
{sortedDates.map(date => (
<div key={date} className="timeline-date-section">
<div className="timeline-date-node">
<span className="date-text">{tools.formatDateLong(date)}</span>
</div>
<div className="meetings-for-date">
{meetingsByDate[date].map(meeting => {
const isCreator = String(meeting.creator_id) === String(currentUser.user_id);
const cardClass = isCreator ? 'created-by-me' : 'attended-by-me';
return (
<div className="meeting-card-wrapper" key={meeting.meeting_id}>
<Link
to={`/meetings/${meeting.meeting_id}`}
state={{
filterContext: {
filterType,
searchQuery,
selectedTags
}
}}
>
<div className={`meeting-card ${cardClass} meeting-card-link`}>
<div className="meeting-content">
<div className="meeting-header">
<div className="meeting-title-section">
<div className="title-and-tags">
<h3 className="meeting-title">
{meeting.title}
{meeting.tags && meeting.tags.length > 0 && (
<TagDisplay
tags={meeting.tags.map(tag => tag.name)}
size="small"
maxDisplay={3}
showIcon={true}
className="inline-tags"
/>
)}
</h3>
</div>
{isCreator && (
<Dropdown
trigger={
<button className="dropdown-trigger">
<MoreVertical size={18} />
</button>
}
items={[
{
icon: <Edit size={16} />,
label: '编辑',
onClick: () => handleEditClick(meeting.meeting_id)
},
{
icon: <Trash2 size={16} />,
label: '删除',
onClick: () => handleDeleteClick(meeting),
danger: true
}
]}
align="right"
/>
)}
</div>
<div className="meeting-meta">
<div className="meta-item">
<Clock size={16} />
<span>{tools.formatTime(meeting.meeting_time)}</span>
</div>
<div className="meta-item">
<Users size={16} />
<span>{meeting.attendees.length} 人参会</span>
</div>
</div>
</div>
<div className="meeting-body">
{meeting.attendees && meeting.attendees.length > 0 && (
<div className="attendees-section">
<span className="attendees-label">参会人</span>
<div className="attendees-list">
{meeting.attendees.map((attendee, idx) => (
<span key={idx} className="attendee-tag">
{typeof attendee === 'string' ? attendee : attendee.caption}
</span>
))}
</div>
</div>
)}
{meeting.summary && (
<div className="summary-section">
<div className="summary-header">
<FileText size={16} />
<span>会议摘要</span>
</div>
<div className="summary-content">
<MarkdownRenderer
content={tools.truncateSummary(meeting.summary)}
className="markdown-content"
/>
{shouldShowMoreButton(meeting.summary) && (
<div className="summary-more-hint">
<span className="more-text">点击查看完整摘要</span>
</div>
)}
</div>
</div>
)}
</div>
<div className="meeting-footer">
<div className="creator-info">
<User size={14} />
<span>创建人: {meeting.creator_username}</span>
</div>
</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
</div>
))}
{/* 加载更多/加载完毕 UI */}
<div className="timeline-footer">
<div className="modern-timeline">
<Timeline mode="left" items={timelineItems} />
<div style={{ textAlign: 'center', marginTop: 28 }}>
{hasMore ? (
<button
className="load-more-btn"
onClick={onLoadMore}
disabled={loadingMore}
>
<span>{loadingMore ? '加载中...' : '加载更多'}</span>
</button>
<Button onClick={onLoadMore} loading={loadingMore} icon={<CalendarOutlined />}>
加载更多
</Button>
) : (
sortedDates.length > 0 && (
<div className="all-loaded">
<span>已加载全部会议</span>
</div>
)
<Divider plain><Text type="secondary">已加载全部会议</Text></Divider>
)}
</div>
{/* 删除会议确认对话框 */}
<ConfirmDialog
isOpen={!!deleteConfirmInfo}
onClose={() => setDeleteConfirmInfo(null)}
onConfirm={handleConfirmDelete}
title="删除会议"
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
confirmText="删除"
cancelText="取消"
type="danger"
/>
</div>
);
};
export default MeetingTimeline;
export default MeetingTimeline;

View File

@ -1,204 +1,62 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Transformer } from 'markmap-lib';
import { Markmap } from 'markmap-view';
import { Loader } from 'lucide-react';
import { Spin, Empty, Button, Space } from 'antd';
import { FullscreenOutlined, ZoomInOutlined, ZoomOutOutlined, SyncOutlined } from '@ant-design/icons';
/**
* MindMap - 纯展示组件用于渲染Markdown内容的思维导图
*
* 设计原则
* 1. 组件只负责渲染脑图不处理数据获取
* 2. 不包含导出功能导出由父组件处理
* 3. 通过props传入已准备好的content
*
* @param {Object} props
* @param {string} props.content - Markdown格式的内容必须由父组件准备好
* @param {string} props.title - 标题用于显示
* @param {number} props.initialScale - 初始缩放倍数默认为1.8
*/
const MindMap = ({ content, title, initialScale = 1.8 }) => {
const [markdown, setMarkdown] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const transformer = new Transformer();
const MindMap = ({ content, title }) => {
const svgRef = useRef(null);
const markmapRef = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (content) {
setMarkdown(content);
setLoading(false);
} else {
setMarkdown('# 暂无内容\n\n等待内容生成后查看思维导图。');
if (!content || !svgRef.current) return;
setLoading(true);
try {
const { root } = transformer.transform(content);
if (markmapRef.current) {
markmapRef.current.setData(root);
markmapRef.current.fit();
} else {
markmapRef.current = Markmap.create(svgRef.current, {
autoFit: true,
duration: 500,
}, root);
}
} catch (error) {
console.error('Markmap error:', error);
} finally {
setLoading(false);
}
}, [content]);
//
const extractKeyPhrases = (text) => {
// markdown
const cleanText = text.replace(/\*\*([^*]+)\*\*/g, '$1');
const handleFit = () => markmapRef.current?.fit();
const handleZoomIn = () => markmapRef.current?.rescale(1.2);
const handleZoomOut = () => markmapRef.current?.rescale(0.8);
//
const phrases = cleanText.split(/[,。;、:]/);
const keyPhrases = [];
phrases.forEach(phrase => {
const trimmed = phrase.trim();
//
if (trimmed.length > 4 && trimmed.length < 40) {
const hasKeywords = /(?:项目|收入|问题|产品|团队|开发|验收|成本|功能|市场|合作|资源|计划|目标|业务|投入|效率|协作|管理|分析|讨论|决策|优化|整合)/.test(trimmed);
if (hasKeywords) {
keyPhrases.push(trimmed);
}
}
});
//
if (keyPhrases.length === 0 && phrases.length > 0) {
const firstPhrase = phrases[0].trim();
if (firstPhrase.length > 0 && firstPhrase.length < 50) {
keyPhrases.push(firstPhrase);
}
}
// 3
return keyPhrases.slice(0, 3);
};
// markdown
const preprocessMarkdownForMindMap = (markdown, rootTitle) => {
if (!markdown || markdown.trim() === '') return '# 暂无内容';
let processed = markdown.trim();
// 线
processed = processed.replace(/^---+$/gm, '');
// rootTitle
const lines = processed.split('\n');
const firstLine = lines[0].trim();
if (firstLine.match(/^#\s+/)) {
// rootTitle
lines[0] = `# ${rootTitle || '内容总结'}`;
processed = lines.join('\n');
} else {
//
processed = `# ${rootTitle || '内容总结'}\n\n${processed}`;
}
const processedLines = [];
const contentLines = processed.split('\n');
let i = 0;
while (i < contentLines.length) {
const line = contentLines[i].trim();
if (line === '') {
i++;
continue;
}
// -
if (line.match(/^#+\s+/)) {
//
let cleanTitle = line.replace(/\*\*([^*]+)\*\*/g, '$1'); //
processedLines.push(cleanTitle);
//
const titleLevel = (line.match(/^#+/) || [''])[0].length;
}
// -
else if (line.match(/^\s*([-*+]|\d+\.)\s+/)) {
//
const cleanedLine = line.replace(/\*\*([^*]+)\*\*/g, '$1');
processedLines.push(cleanedLine);
}
//
else if (line.startsWith('>')) {
const content = line.replace(/^>+\s*/, '');
processedLines.push(`- ${content}`);
}
// markmap
else if (line.includes('|')) {
processedLines.push(line);
}
// -
else if (line.length > 0 && !line.match(/\*\*总字数:\d+字\*\*/)) {
processedLines.push(line);
}
i++;
}
//
const result = processedLines
.filter(line => line.trim().length > 0)
.join('\n');
return result;
};
useEffect(() => {
if (loading || !markdown || !svgRef.current) return;
try {
const processedMarkdown = preprocessMarkdownForMindMap(markdown, title);
const transformer = new Transformer();
const { root } = transformer.transform(processedMarkdown);
if (markmapRef.current) {
markmapRef.current.setData(root);
} else {
markmapRef.current = Markmap.create(svgRef.current, null, root);
}
markmapRef.current.fit();
// fit
setTimeout(() => {
if (markmapRef.current) {
markmapRef.current.fit();
//
try {
markmapRef.current.rescale(initialScale);
} catch (e) {
console.log('缩放调整失败:', e);
}
}
}, 500);
} catch (error) {
console.error('思维导图渲染失败:', error);
setError('思维导图渲染失败');
}
}, [markdown, loading, title, initialScale]);
if (loading) {
return (
<div className="mindmap-loading">
<Loader className="animate-spin" />
<p>正在加载思维导图...</p>
</div>
);
}
if (error) {
return (
<div className="mindmap-error">
<p>{error}</p>
</div>
);
}
if (!content) return <Empty description="暂无内容,无法生成思维导图" />;
return (
<div className="mindmap-container">
<div className="markmap-render-area">
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
<div className="mindmap-container" style={{ position: 'relative', width: '100%', height: '100%', minHeight: 500 }}>
{loading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10, background: 'rgba(255,255,255,0.8)' }}>
<Spin tip="生成导图中..." />
</div>
)}
<div style={{ position: 'absolute', right: 16, top: 16, zIndex: 20 }}>
<Space direction="vertical">
<Button icon={<FullscreenOutlined />} onClick={handleFit} title="自适应" />
<Button icon={<ZoomInOutlined />} onClick={handleZoomIn} title="放大" />
<Button icon={<ZoomOutOutlined />} onClick={handleZoomOut} title="缩小" />
</Space>
</div>
<svg ref={svgRef} style={{ width: '100%', height: '100%', minHeight: 500 }} />
</div>
);
};

View File

@ -1,34 +0,0 @@
/* 页面级加载组件样式 - 全屏加载 */
.page-loading-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #ffffff;
z-index: 9999;
color: #64748b;
}
.page-loading-spinner {
width: 40px;
height: 40px;
animation: page-loading-spin 1s linear infinite;
margin-bottom: 1rem;
color: #667eea;
}
@keyframes page-loading-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.page-loading-message {
margin: 0;
font-size: 0.95rem;
color: #64748b;
}

View File

@ -1,14 +0,0 @@
import React from 'react';
import { Loader } from 'lucide-react';
import './PageLoading.css';
const PageLoading = ({ message = '加载中...' }) => {
return (
<div className="page-loading-container">
<Loader className="page-loading-spinner" />
<p className="page-loading-message">{message}</p>
</div>
);
};
export default PageLoading;

View File

@ -1,134 +0,0 @@
/* QR Code Modal Styles */
.qr-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.qr-modal {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
max-width: 450px;
width: 90%;
overflow: hidden;
animation: modalFadeIn 0.3s ease-out;
}
@keyframes modalFadeIn {
0% {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.qr-modal .modal-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(135deg, #f8fafb 0%, #f1f5f9 100%);
display: flex;
justify-content: space-between;
align-items: center;
}
.qr-modal .modal-header h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: #1e293b;
font-size: 1.25rem;
font-weight: 600;
}
.qr-modal .close-btn {
background: none;
border: none;
color: #64748b;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.qr-modal .close-btn:hover {
background: #e2e8f0;
color: #1e293b;
}
.qr-modal-content {
padding: 32px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.qr-code-container {
background: white;
padding: 20px;
border-radius: 12px;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code-container svg {
display: block;
}
.qr-info {
text-align: center;
width: 100%;
}
.qr-description {
margin: 0 0 8px 0;
color: #64748b;
font-size: 14px;
font-weight: 500;
}
.qr-meeting-title {
margin: 0;
color: #1e293b;
font-size: 16px;
font-weight: 600;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
word-break: break-word;
}
/* 响应式设计 */
@media (max-width: 768px) {
.qr-modal {
max-width: 95%;
}
.qr-modal-content {
padding: 24px 16px;
}
.qr-code-container {
padding: 16px;
}
}

View File

@ -1,61 +1,50 @@
import React from 'react';
import { QrCode, X } from 'lucide-react';
import { Modal, Typography, Space, Button, App } from 'antd';
import { QrcodeOutlined, CopyOutlined, CheckOutlined } from '@ant-design/icons';
import { QRCodeSVG } from 'qrcode.react';
import './QRCodeModal.css';
/**
* QRCodeModal - 二维码分享模态框组件
*
* @param {Object} props
* @param {boolean} props.isOpen - 是否显示模态框
* @param {Function} props.onClose - 关闭模态框的回调函数
* @param {string} props.url - 二维码指向的URL
* @param {string} props.title - 显示的标题文本
* @param {string} props.description - 描述文本可选
* @param {number} props.size - 二维码尺寸可选默认256
*/
const QRCodeModal = ({
isOpen,
onClose,
url,
title,
description = '扫描二维码访问',
size = 256
}) => {
if (!isOpen) return null;
const { Text, Paragraph } = Typography;
const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) => {
const { message } = App.useApp();
const handleCopy = () => {
navigator.clipboard.writeText(url);
message.success('链接已复制到剪贴板');
};
return (
<div className="qr-modal-overlay" onClick={onClose}>
<div className="qr-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>
<QrCode size={20} /> 分享会议
</h3>
<button
className="close-btn"
onClick={onClose}
aria-label="关闭"
>
<X size={20} />
</button>
<Modal
title={<Space><QrcodeOutlined /> {title}</Space>}
open={open}
onCancel={onClose}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={handleCopy}>复制链接</Button>,
<Button key="close" type="primary" icon={<CheckOutlined />} className="btn-soft-green" onClick={onClose}>完成</Button>
]}
width={400}
centered
destroyOnHidden
>
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<div style={{
background: '#fff',
padding: 16,
borderRadius: 12,
display: 'inline-block',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
marginBottom: 20
}}>
<QRCodeSVG value={url} size={200} />
</div>
<div className="qr-modal-content">
<div className="qr-code-container">
<QRCodeSVG
value={url}
size={size}
level="H"
includeMargin={true}
/>
</div>
<div className="qr-info">
<p className="qr-description">{description}</p>
<p className="qr-meeting-title">{title}</p>
</div>
<Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 0 }}>
微信或浏览器扫码即可在移动端查看
</Paragraph>
<div style={{ marginTop: 12, background: '#f5f5f5', padding: '8px 12px', borderRadius: 6, textAlign: 'left' }}>
<Text code ellipsis style={{ width: '100%', display: 'block' }}>{url}</Text>
</div>
</div>
</div>
</Modal>
);
};

View File

@ -1,51 +0,0 @@
/* 回到顶部按钮 */
.scroll-to-top-btn {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
z-index: 999;
animation: fadeInUp 0.3s ease;
}
.scroll-to-top-btn:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
.scroll-to-top-btn:active {
transform: translateY(-2px);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 - 移动端调整按钮位置 */
@media (max-width: 768px) {
.scroll-to-top-btn {
bottom: 1.5rem;
right: 1.5rem;
width: 50px;
height: 50px;
}
}

View File

@ -1,43 +1,9 @@
import React, { useState, useEffect } from 'react';
import { ArrowUp } from 'lucide-react';
import './ScrollToTop.css';
import { FloatButton } from 'antd';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
const ScrollToTop = ({ showAfter = 300 }) => {
const [showButton, setShowButton] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (window.pageYOffset > showAfter) {
setShowButton(true);
} else {
setShowButton(false);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [showAfter]);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
if (!showButton) {
return null;
}
return (
<button
className="scroll-to-top-btn"
onClick={scrollToTop}
aria-label="回到顶部"
>
<ArrowUp size={28} />
</button>
);
const ScrollToTop = () => {
return <FloatButton.BackTop icon={<VerticalAlignTopOutlined />} visibilityHeight={400} />;
};
export default ScrollToTop;

View File

@ -1,64 +0,0 @@
/* 简洁搜索输入框样式 */
.simple-search-input {
position: relative;
width: 100%;
display: flex;
align-items: center;
}
.simple-search-input-field {
width: 100%;
padding: 0.5rem 0.75rem;
padding-right: 2.5rem; /* 为清除按钮留出空间 */
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.875rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
}
.simple-search-input-field:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.simple-search-input-field::placeholder {
color: #94a3b8;
}
.simple-search-clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.simple-search-clear-btn:hover {
background: #f1f5f9;
color: #475569;
}
.simple-search-clear-btn:active {
background: #e2e8f0;
}
/* 禁用状态 */
.simple-search-input-field:disabled {
background: #f8fafc;
color: #94a3b8;
cursor: not-allowed;
}
.simple-search-input-field:disabled + .simple-search-clear-btn {
display: none;
}

View File

@ -1,89 +1,18 @@
import React, { useRef, useEffect, useState } from 'react';
import { X } from 'lucide-react';
import './SimpleSearchInput.css';
const SimpleSearchInput = ({
value = '',
onChange,
placeholder = '搜索...',
className = '',
debounceDelay = 500,
realTimeSearch = true
}) => {
const debounceTimerRef = useRef(null);
const [localValue, setLocalValue] = useState(value);
// value
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleInputChange = (e) => {
const inputValue = e.target.value;
//
setLocalValue(inputValue);
//
if (onChange) {
if (realTimeSearch) {
// 使
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
onChange(inputValue);
}, debounceDelay);
} else {
// 使
onChange(inputValue);
}
}
};
const handleClear = () => {
//
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
//
setLocalValue('');
if (onChange) {
onChange('');
}
};
//
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
import React from 'react';
import { Input } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
const SimpleSearchInput = ({ value, onChange, placeholder = "搜索..." }) => {
return (
<div className={`simple-search-input ${className}`}>
<input
type="text"
placeholder={placeholder}
value={localValue}
onChange={handleInputChange}
className="simple-search-input-field"
/>
{localValue && (
<button
type="button"
className="simple-search-clear-btn"
onClick={handleClear}
aria-label="清除搜索"
>
<X size={16} />
</button>
)}
</div>
<Input
placeholder={placeholder}
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
value={value}
onChange={(e) => onChange(e.target.value)}
allowClear
size="large"
style={{ borderRadius: 8 }}
/>
);
};

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Tag } from 'antd';
import { CheckCircleFilled, PauseCircleFilled } from '@ant-design/icons';
const isTruthyStatus = (value) => value === true || value === 1 || value === '1';
const StatusTag = ({
active,
activeText = '启用',
inactiveText = '停用',
compact = false,
className = '',
}) => {
const enabled = isTruthyStatus(active);
const classes = [
'console-status-tag',
enabled ? 'console-status-tag-active' : 'console-status-tag-inactive',
compact ? 'console-status-tag-compact' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<Tag className={classes} bordered={false}>
<span className="console-status-tag-content">
{enabled ? <CheckCircleFilled /> : <PauseCircleFilled />}
<span>{enabled ? activeText : inactiveText}</span>
</span>
</Tag>
);
};
export default StatusTag;

View File

@ -1,81 +0,0 @@
.step-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 0;
margin: 0;
}
.step-item {
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #e2e8f0;
color: #64748b;
font-size: 12px;
font-weight: 600;
transition: all 0.3s ease;
}
.step-label {
font-size: 14px;
color: #64748b;
font-weight: 500;
transition: all 0.3s ease;
}
.step-item.active .step-number {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.step-item.active .step-label {
color: #667eea;
font-weight: 600;
}
.step-item.completed .step-number {
background-color: #10b981;
color: white;
}
.step-item.completed .step-label {
color: #10b981;
}
.step-arrow {
color: #cbd5e1;
font-size: 16px;
font-weight: 300;
}
/* 响应式设计 */
@media (max-width: 768px) {
.step-indicator {
gap: 8px;
}
.step-number {
width: 20px;
height: 20px;
font-size: 11px;
}
.step-label {
font-size: 12px;
}
.step-arrow {
font-size: 14px;
}
}

Some files were not shown because too many files have changed in this diff Show More