Compare commits
2 Commits
2eda8f9fc3
...
4715cd4a86
| Author | SHA1 | Date |
|---|---|---|
|
|
4715cd4a86 | |
|
|
bbcc5466f0 |
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
|
@ -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
|
||||
|
|
@ -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)}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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,))
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# 数据库配置
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 包含 model、timeout、temperature、top_p 的参数字典
|
||||
Dict: 包含 endpoint_url、api_key、model、timeout、temperature、top_p、max_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__':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
-- ----------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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接入凭证';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
START TRANSACTION;
|
||||
|
||||
UPDATE sys_menus
|
||||
SET is_visible = 1,
|
||||
is_active = 1
|
||||
WHERE menu_code IN ('dashboard', 'desktop');
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 != '';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// 调用后端登出API撤销token
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(``, '', '');
|
||||
}
|
||||
}
|
||||
// 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(``, '', ''));
|
||||
}
|
||||
e.target.value = '';
|
||||
}} style={{ display: 'none' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||