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.auth import get_current_admin_user, get_current_user
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
from app.core.database import get_db_connection
|
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
|
from typing import List
|
||||||
|
import time
|
||||||
|
|
||||||
router = APIRouter()
|
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 = """
|
query = """
|
||||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
||||||
parent_id, sort_order, is_active, description, created_at, updated_at
|
parent_id, sort_order, is_active, description, created_at, updated_at
|
||||||
FROM menus
|
FROM sys_menus
|
||||||
ORDER BY sort_order ASC, menu_id ASC
|
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)
|
cursor.execute(query)
|
||||||
menus = cursor.fetchall()
|
menus = cursor.fetchall()
|
||||||
|
|
@ -37,6 +135,171 @@ async def get_all_menus(current_user=Depends(get_current_admin_user)):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取菜单列表失败: {str(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")
|
@router.get("/admin/roles")
|
||||||
async def get_all_roles(current_user=Depends(get_current_admin_user)):
|
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 = """
|
query = """
|
||||||
SELECT r.role_id, r.role_name, r.created_at,
|
SELECT r.role_id, r.role_name, r.created_at,
|
||||||
COUNT(rmp.menu_id) as menu_count
|
COUNT(rmp.menu_id) as menu_count
|
||||||
FROM roles r
|
FROM sys_roles r
|
||||||
LEFT JOIN role_menu_permissions rmp ON r.role_id = rmp.role_id
|
LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id
|
||||||
GROUP BY r.role_id
|
GROUP BY r.role_id
|
||||||
ORDER BY r.role_id ASC
|
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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取角色列表失败: {str(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")
|
@router.get("/admin/roles/{role_id}/permissions")
|
||||||
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
|
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 = 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()
|
role = cursor.fetchone()
|
||||||
if not role:
|
if not role:
|
||||||
return create_api_response(code="404", message="角色不存在")
|
return create_api_response(code="404", message="角色不存在")
|
||||||
|
|
||||||
# 查询该角色的所有菜单权限
|
# 查询该角色的所有菜单权限
|
||||||
query = """
|
query = """
|
||||||
SELECT menu_id
|
SELECT rmp.menu_id
|
||||||
FROM role_menu_permissions
|
FROM sys_role_menu_permissions rmp
|
||||||
WHERE role_id = %s
|
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,))
|
cursor.execute(query, (role_id,))
|
||||||
permissions = cursor.fetchall()
|
permissions = cursor.fetchall()
|
||||||
|
|
@ -121,38 +526,45 @@ async def update_role_permissions(
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="角色不存在")
|
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是否有效
|
# 验证所有menu_id是否有效
|
||||||
if request.menu_ids:
|
invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set]
|
||||||
format_strings = ','.join(['%s'] * len(request.menu_ids))
|
if invalid_menu_ids:
|
||||||
cursor.execute(
|
return create_api_response(code="400", message="包含无效的菜单ID")
|
||||||
f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})",
|
|
||||||
tuple(request.menu_ids)
|
normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus)
|
||||||
)
|
|
||||||
valid_count = cursor.fetchone()['count']
|
|
||||||
if valid_count != len(request.menu_ids):
|
|
||||||
return create_api_response(code="400", message="包含无效的菜单ID")
|
|
||||||
|
|
||||||
# 删除该角色的所有现有权限
|
# 删除该角色的所有现有权限
|
||||||
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:
|
if normalized_menu_ids:
|
||||||
insert_values = [(role_id, menu_id) for menu_id in request.menu_ids]
|
insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids]
|
||||||
cursor.executemany(
|
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
|
insert_values
|
||||||
)
|
)
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
_invalidate_user_menu_cache(role_id)
|
||||||
|
|
||||||
return create_api_response(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="更新角色权限成功",
|
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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"更新角色权限失败: {str(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:
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
# 根据用户的role_id查询可访问的菜单
|
# 根据用户的role_id查询可访问的菜单
|
||||||
query = """
|
query = """
|
||||||
SELECT DISTINCT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
|
SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
|
||||||
m.menu_url, m.menu_type, m.sort_order
|
m.menu_url, m.menu_type, m.parent_id, m.sort_order
|
||||||
FROM menus m
|
FROM sys_menus m
|
||||||
JOIN role_menu_permissions rmp ON m.menu_id = rmp.menu_id
|
JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id
|
||||||
WHERE rmp.role_id = %s AND m.is_active = 1
|
WHERE rmp.role_id = %s
|
||||||
ORDER BY m.sort_order ASC
|
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()
|
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(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="获取用户菜单成功",
|
message="获取用户菜单成功",
|
||||||
|
|
@ -186,4 +645,3 @@ async def get_user_menus(current_user=Depends(get_current_user)):
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取用户菜单失败: {str(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:*")
|
token_keys = redis_client.keys("token:*")
|
||||||
user_ids = set()
|
user_ids = set()
|
||||||
for key in token_keys:
|
for key in token_keys:
|
||||||
|
if isinstance(key, bytes):
|
||||||
|
key = key.decode("utf-8", errors="ignore")
|
||||||
parts = key.split(':')
|
parts = key.split(':')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
user_ids.add(parts[1])
|
user_ids.add(parts[1])
|
||||||
|
|
@ -56,6 +58,18 @@ def _get_online_user_count(redis_client) -> int:
|
||||||
return 0
|
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]:
|
def _calculate_audio_storage() -> Dict[str, float]:
|
||||||
"""计算音频文件存储统计"""
|
"""计算音频文件存储统计"""
|
||||||
audio_files_count = 0
|
audio_files_count = 0
|
||||||
|
|
@ -90,42 +104,57 @@ async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
|
||||||
|
|
||||||
# 1. 用户统计
|
# 1. 用户统计
|
||||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
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")
|
if _table_exists(cursor, "sys_users"):
|
||||||
total_users = cursor.fetchone()['total']
|
cursor.execute("SELECT COUNT(*) as total FROM sys_users")
|
||||||
|
total_users = (cursor.fetchone() or {}).get("total", 0)
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT COUNT(*) as count FROM users WHERE created_at >= %s",
|
"SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s",
|
||||||
(today_start,)
|
(today_start,),
|
||||||
)
|
)
|
||||||
today_new_users = cursor.fetchone()['count']
|
today_new_users = (cursor.fetchone() or {}).get("count", 0)
|
||||||
|
|
||||||
online_users = _get_online_user_count(redis_client)
|
online_users = _get_online_user_count(redis_client)
|
||||||
|
|
||||||
# 2. 会议统计
|
# 2. 会议统计
|
||||||
cursor.execute("SELECT COUNT(*) as total FROM meetings")
|
total_meetings = 0
|
||||||
total_meetings = cursor.fetchone()['total']
|
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(
|
cursor.execute(
|
||||||
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
|
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
|
||||||
(today_start,)
|
(today_start,),
|
||||||
)
|
)
|
||||||
today_new_meetings = cursor.fetchone()['count']
|
today_new_meetings = (cursor.fetchone() or {}).get("count", 0)
|
||||||
|
|
||||||
# 3. 任务统计
|
# 3. 任务统计
|
||||||
task_stats_query = _get_task_stats_query()
|
task_stats_query = _get_task_stats_query()
|
||||||
|
|
||||||
# 转录任务
|
# 转录任务
|
||||||
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
|
if _table_exists(cursor, "transcript_tasks"):
|
||||||
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
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")
|
if _table_exists(cursor, "llm_tasks"):
|
||||||
summary_stats = cursor.fetchone() or {'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}
|
||||||
|
else:
|
||||||
|
summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||||
|
|
||||||
# 知识库任务
|
# 知识库任务
|
||||||
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
|
if _table_exists(cursor, "knowledge_base_tasks"):
|
||||||
kb_stats = cursor.fetchone() or {'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}
|
||||||
|
else:
|
||||||
|
kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||||
|
|
||||||
# 4. 音频存储统计
|
# 4. 音频存储统计
|
||||||
storage_stats = _calculate_audio_storage()
|
storage_stats = _calculate_audio_storage()
|
||||||
|
|
@ -180,6 +209,8 @@ async def get_online_users(current_user=Depends(get_current_admin_user)):
|
||||||
# 提取用户ID并去重
|
# 提取用户ID并去重
|
||||||
user_tokens = {}
|
user_tokens = {}
|
||||||
for key in token_keys:
|
for key in token_keys:
|
||||||
|
if isinstance(key, bytes):
|
||||||
|
key = key.decode("utf-8", errors="ignore")
|
||||||
parts = key.split(':')
|
parts = key.split(':')
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
user_id = int(parts[1])
|
user_id = int(parts[1])
|
||||||
|
|
@ -195,7 +226,7 @@ async def get_online_users(current_user=Depends(get_current_admin_user)):
|
||||||
online_users_list = []
|
online_users_list = []
|
||||||
for user_id, tokens in user_tokens.items():
|
for user_id, tokens in user_tokens.items():
|
||||||
cursor.execute(
|
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_id,)
|
||||||
)
|
)
|
||||||
user = cursor.fetchone()
|
user = cursor.fetchone()
|
||||||
|
|
@ -275,7 +306,7 @@ async def monitor_tasks(
|
||||||
u.username as creator_name
|
u.username as creator_name
|
||||||
FROM transcript_tasks t
|
FROM transcript_tasks t
|
||||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
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}
|
WHERE 1=1 {status_condition}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
|
|
@ -292,14 +323,14 @@ async def monitor_tasks(
|
||||||
t.meeting_id,
|
t.meeting_id,
|
||||||
m.title as meeting_title,
|
m.title as meeting_title,
|
||||||
t.status,
|
t.status,
|
||||||
NULL as progress,
|
t.progress,
|
||||||
t.error_message,
|
t.error_message,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
t.completed_at,
|
t.completed_at,
|
||||||
u.username as creator_name
|
u.username as creator_name
|
||||||
FROM llm_tasks t
|
FROM llm_tasks t
|
||||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
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}
|
WHERE 1=1 {status_condition}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
|
|
@ -323,7 +354,7 @@ async def monitor_tasks(
|
||||||
u.username as creator_name
|
u.username as creator_name
|
||||||
FROM knowledge_base_tasks t
|
FROM knowledge_base_tasks t
|
||||||
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
|
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}
|
WHERE 1=1 {status_condition}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT %s
|
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,
|
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
|
||||||
COUNT(DISTINCT m.meeting_id) as meeting_count,
|
COUNT(DISTINCT m.meeting_id) as meeting_count,
|
||||||
COALESCE(SUM(af.duration), 0) as total_duration_seconds
|
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
|
INNER JOIN meetings m ON u.user_id = m.user_id
|
||||||
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_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
|
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.auth import get_current_user
|
||||||
from app.core.database import get_db_connection
|
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.services.jwt_service import jwt_service
|
||||||
from app.core.response import create_api_response
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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,))
|
cursor.execute(query, (request_body.username,))
|
||||||
user = cursor.fetchone()
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
|
@ -67,19 +81,23 @@ def login(request_body: LoginRequest, request: Request):
|
||||||
print(f"Failed to log user login: {e}")
|
print(f"Failed to log user login: {e}")
|
||||||
|
|
||||||
login_response_data = LoginResponse(
|
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,
|
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(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="登录成功",
|
message="登录成功",
|
||||||
data=login_response_data.dict()
|
data=login_response_data.model_dump()
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/auth/logout")
|
@router.post("/auth/logout")
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ async def get_client_downloads(
|
||||||
platform_code: Optional[str] = None,
|
platform_code: Optional[str] = None,
|
||||||
is_active: Optional[bool] = None,
|
is_active: Optional[bool] = None,
|
||||||
page: int = 1,
|
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 = """
|
query = """
|
||||||
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
|
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
|
||||||
FROM client_downloads cd
|
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'
|
AND dd.dict_type = 'client_platform'
|
||||||
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
|
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
|
||||||
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code
|
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ async def get_dict_types():
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT DISTINCT dict_type
|
SELECT DISTINCT dict_type
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE status = 1
|
WHERE status = 1
|
||||||
ORDER BY dict_type
|
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,
|
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||||
label_cn, label_en, sort_order, extension_attr,
|
label_cn, label_en, sort_order, extension_attr,
|
||||||
is_default, status, create_time
|
is_default, status, create_time
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND status = 1
|
WHERE dict_type = %s AND status = 1
|
||||||
"""
|
"""
|
||||||
params = [dict_type]
|
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,
|
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||||
label_cn, label_en, sort_order, extension_attr,
|
label_cn, label_en, sort_order, extension_attr,
|
||||||
is_default, status, create_time, update_time
|
is_default, status, create_time, update_time
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND dict_code = %s
|
WHERE dict_type = %s AND dict_code = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
@ -246,7 +246,7 @@ async def create_dict_data(
|
||||||
|
|
||||||
# 检查是否已存在
|
# 检查是否已存在
|
||||||
cursor.execute(
|
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)
|
(request.dict_type, request.dict_code)
|
||||||
)
|
)
|
||||||
if cursor.fetchone():
|
if cursor.fetchone():
|
||||||
|
|
@ -258,7 +258,7 @@ async def create_dict_data(
|
||||||
|
|
||||||
# 插入数据
|
# 插入数据
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO dict_data (
|
INSERT INTO sys_dict_data (
|
||||||
dict_type, dict_code, parent_code, label_cn, label_en,
|
dict_type, dict_code, parent_code, label_cn, label_en,
|
||||||
sort_order, extension_attr, is_default, status
|
sort_order, extension_attr, is_default, status
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
) 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 = 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()
|
existing = cursor.fetchone()
|
||||||
if not existing:
|
if not existing:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -369,7 +369,7 @@ async def update_dict_data(
|
||||||
|
|
||||||
# 执行更新
|
# 执行更新
|
||||||
update_query = f"""
|
update_query = f"""
|
||||||
UPDATE dict_data
|
UPDATE sys_dict_data
|
||||||
SET {', '.join(update_fields)}
|
SET {', '.join(update_fields)}
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
|
|
@ -404,7 +404,7 @@ async def delete_dict_data(
|
||||||
cursor = conn.cursor(dictionary=True)
|
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()
|
existing = cursor.fetchone()
|
||||||
if not existing:
|
if not existing:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -415,7 +415,7 @@ async def delete_dict_data(
|
||||||
|
|
||||||
# 检查是否有子节点
|
# 检查是否有子节点
|
||||||
cursor.execute(
|
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'],)
|
(existing['dict_code'],)
|
||||||
)
|
)
|
||||||
if cursor.fetchone()['count'] > 0:
|
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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ async def get_external_apps(
|
||||||
list_query = f"""
|
list_query = f"""
|
||||||
SELECT ea.*, u.username as creator_username
|
SELECT ea.*, u.username as creator_username
|
||||||
FROM external_apps ea
|
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}
|
WHERE {where_clause}
|
||||||
ORDER BY ea.sort_order ASC, ea.created_at DESC
|
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 app.services.system_config_service import SystemConfigService
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
|
||||||
import dashscope
|
import dashscope
|
||||||
from dashscope.audio.asr import VocabularyService
|
from dashscope.audio.asr import VocabularyService
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -14,48 +13,68 @@ from http import HTTPStatus
|
||||||
|
|
||||||
router = APIRouter()
|
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
|
text: str
|
||||||
weight: int = 4
|
weight: int = 4
|
||||||
lang: str = "zh"
|
lang: str = "zh"
|
||||||
status: int = 1
|
status: int = 1
|
||||||
|
|
||||||
class UpdateHotWordRequest(BaseModel):
|
|
||||||
|
class UpdateItemRequest(BaseModel):
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
weight: Optional[int] = None
|
weight: Optional[int] = None
|
||||||
lang: Optional[str] = None
|
lang: Optional[str] = None
|
||||||
status: Optional[int] = 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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC")
|
cursor.execute("""
|
||||||
items = cursor.fetchall()
|
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()
|
cursor.close()
|
||||||
return create_api_response(code="200", message="获取成功", data=items)
|
return create_api_response(code="200", message="获取成功", data=groups)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取失败: {str(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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)"
|
cursor.execute(
|
||||||
cursor.execute(query, (request.text, request.weight, request.lang, request.status))
|
"INSERT INTO hot_word_group (name, description, status) VALUES (%s, %s, %s)",
|
||||||
|
(request.name, request.description, request.status),
|
||||||
|
)
|
||||||
new_id = cursor.lastrowid
|
new_id = cursor.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -63,111 +82,209 @@ async def create_hot_word(request: CreateHotWordRequest, current_user: dict = De
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"创建失败: {str(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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
update_fields = []
|
fields, params = [], []
|
||||||
params = []
|
if request.name is not None:
|
||||||
if request.text is not None:
|
fields.append("name = %s"); params.append(request.name)
|
||||||
update_fields.append("text = %s")
|
if request.description is not None:
|
||||||
params.append(request.text)
|
fields.append("description = %s"); params.append(request.description)
|
||||||
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)
|
|
||||||
if request.status is not None:
|
if request.status is not None:
|
||||||
update_fields.append("status = %s")
|
fields.append("status = %s"); params.append(request.status)
|
||||||
params.append(request.status)
|
if not fields:
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
return create_api_response(code="400", message="无更新内容")
|
return create_api_response(code="400", message="无更新内容")
|
||||||
|
|
||||||
query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s"
|
|
||||||
params.append(id)
|
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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
return create_api_response(code="200", message="更新成功")
|
return create_api_response(code="200", message="更新成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"更新失败: {str(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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
return create_api_response(code="200", message="删除成功")
|
return create_api_response(code="200", message="删除成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"删除失败: {str(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)):
|
@router.post("/admin/hot-word-groups/{id}/sync", response_model=dict)
|
||||||
"""同步热词到阿里云 DashScope"""
|
async def sync_group(id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||||
|
"""同步指定组到阿里云 DashScope"""
|
||||||
try:
|
try:
|
||||||
dashscope.api_key = QWEN_API_KEY
|
dashscope.api_key = QWEN_API_KEY
|
||||||
|
|
||||||
# 1. 获取所有启用的热词
|
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
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:
|
vocabulary_list = [{"text": it["text"], "weight": it["weight"], "lang": it["lang"]} for it in items]
|
||||||
return create_api_response(code="400", message="没有启用的热词可同步")
|
|
||||||
|
# ASR 模型名(同步时需要)
|
||||||
|
asr_model_name = SystemConfigService.get_config_attribute('audio_model', 'model', 'paraformer-v2')
|
||||||
|
existing_vocab_id = group.get("vocabulary_id")
|
||||||
|
|
||||||
# 3. 调用阿里云 API
|
|
||||||
service = VocabularyService()
|
service = VocabularyService()
|
||||||
vocab_id = existing_vocab_id
|
vocab_id = existing_vocab_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if existing_vocab_id:
|
if existing_vocab_id:
|
||||||
# 尝试更新现有的热词表
|
|
||||||
try:
|
try:
|
||||||
service.update_vocabulary(
|
service.update_vocabulary(
|
||||||
vocabulary_id=existing_vocab_id,
|
vocabulary_id=existing_vocab_id,
|
||||||
vocabulary=vocabulary_list
|
vocabulary=vocabulary_list,
|
||||||
)
|
)
|
||||||
# 更新成功,保持原有ID
|
except Exception:
|
||||||
except Exception as update_error:
|
existing_vocab_id = None # 更新失败,重建
|
||||||
# 如果更新失败(如资源不存在),尝试创建新的
|
|
||||||
print(f"Update vocabulary failed: {update_error}, trying to create new one.")
|
|
||||||
existing_vocab_id = None # 重置,触发创建逻辑
|
|
||||||
|
|
||||||
if not existing_vocab_id:
|
if not existing_vocab_id:
|
||||||
# 创建新的热词表
|
|
||||||
vocab_id = service.create_vocabulary(
|
vocab_id = service.create_vocabulary(
|
||||||
prefix='imeeting',
|
prefix="imeeting",
|
||||||
target_model='paraformer-v2',
|
target_model=asr_model_name,
|
||||||
vocabulary=vocabulary_list
|
vocabulary=vocabulary_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as api_error:
|
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
|
# 回写 vocabulary_id 到热词组
|
||||||
if vocab_id:
|
cursor.execute(
|
||||||
SystemConfigService.set_config(
|
"UPDATE hot_word_group SET vocabulary_id = %s, last_sync_time = NOW() WHERE id = %s",
|
||||||
SystemConfigService.ASR_VOCABULARY_ID,
|
(vocab_id, id),
|
||||||
vocab_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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"同步异常: {str(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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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 = []
|
where_clauses = []
|
||||||
params = []
|
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,
|
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at,
|
||||||
u.username as created_by_name
|
u.username as created_by_name
|
||||||
FROM knowledge_bases kb
|
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
|
WHERE kb.kb_id = %s
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (kb_id,))
|
cursor.execute(query, (kb_id,))
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ transcription_service = AsyncTranscriptionService()
|
||||||
class GenerateSummaryRequest(BaseModel):
|
class GenerateSummaryRequest(BaseModel):
|
||||||
user_prompt: Optional[str] = ""
|
user_prompt: Optional[str] = ""
|
||||||
prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版
|
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]:
|
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,
|
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
|
m.user_id as creator_id, u.caption as creator_username, MAX(af.file_path) as audio_file_path
|
||||||
FROM meetings m
|
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 audio_files af ON m.meeting_id = af.meeting_id
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
@ -238,16 +239,20 @@ def get_meetings(
|
||||||
|
|
||||||
meeting_list = []
|
meeting_list = []
|
||||||
for meeting in meetings:
|
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'],))
|
cursor.execute(attendees_query, (meeting['meeting_id'],))
|
||||||
attendees_data = cursor.fetchall()
|
attendees_data = cursor.fetchall()
|
||||||
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
||||||
tags_list = _process_tags(cursor, meeting.get('tags'))
|
tags_list = _process_tags(cursor, meeting.get('tags'))
|
||||||
|
progress_info = _get_meeting_overall_status(meeting['meeting_id'])
|
||||||
meeting_list.append(Meeting(
|
meeting_list.append(Meeting(
|
||||||
meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'],
|
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'],
|
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,
|
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={
|
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,
|
af.file_path as audio_file_path, af.duration as audio_duration,
|
||||||
p.name as prompt_name, m.access_password
|
p.name as prompt_name, m.access_password
|
||||||
FROM meetings m
|
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 audio_files af ON m.meeting_id = af.meeting_id
|
||||||
LEFT JOIN prompts p ON m.prompt_id = p.id
|
LEFT JOIN prompts p ON m.prompt_id = p.id
|
||||||
WHERE m.meeting_id = %s
|
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()
|
meeting = cursor.fetchone()
|
||||||
if not meeting:
|
if not meeting:
|
||||||
return create_api_response(code="404", message="Meeting not found")
|
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'],))
|
cursor.execute(attendees_query, (meeting['meeting_id'],))
|
||||||
attendees_data = cursor.fetchall()
|
attendees_data = cursor.fetchall()
|
||||||
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
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)'
|
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()))
|
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
|
meeting_id = cursor.lastrowid
|
||||||
for attendee_id in meeting_request.attendee_ids:
|
# 根据 caption 查找用户ID并插入参会人
|
||||||
cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_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()
|
connection.commit()
|
||||||
return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id})
|
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'
|
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(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,))
|
cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,))
|
||||||
for attendee_id in meeting_request.attendee_ids:
|
# 根据 caption 查找用户ID并插入参会人
|
||||||
cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_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()
|
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")
|
return create_api_response(code="200", message="Meeting updated successfully")
|
||||||
|
|
||||||
@router.delete("/meetings/{meeting_id}")
|
@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,
|
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.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path,
|
||||||
m.access_password
|
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
|
WHERE m.meeting_id = %s
|
||||||
'''
|
'''
|
||||||
cursor.execute(query, (meeting_id,))
|
cursor.execute(query, (meeting_id,))
|
||||||
meeting = cursor.fetchone()
|
meeting = cursor.fetchone()
|
||||||
if not meeting:
|
if not meeting:
|
||||||
return create_api_response(code="404", message="Meeting not found")
|
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'],))
|
cursor.execute(attendees_query, (meeting['meeting_id'],))
|
||||||
attendees_data = cursor.fetchall()
|
attendees_data = cursor.fetchall()
|
||||||
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
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,))
|
cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||||
if not cursor.fetchone():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="Meeting not found")
|
return create_api_response(code="404", message="Meeting not found")
|
||||||
# 传递 prompt_id 参数给服务层
|
# 传递 prompt_id 和 model_code 参数给服务层
|
||||||
task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id)
|
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)
|
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={
|
return create_api_response(code="200", message="Summary generation task has been accepted.", data={
|
||||||
"task_id": task_id, "status": "pending", "meeting_id": meeting_id
|
"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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(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")
|
@router.get("/meetings/{meeting_id}/navigation")
|
||||||
def get_meeting_navigation(
|
def get_meeting_navigation(
|
||||||
meeting_id: int,
|
meeting_id: int,
|
||||||
|
|
@ -946,7 +979,7 @@ def get_meeting_navigation(
|
||||||
query = '''
|
query = '''
|
||||||
SELECT m.meeting_id
|
SELECT m.meeting_id
|
||||||
FROM meetings m
|
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:
|
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,
|
m.user_id as creator_id, u.caption as creator_username,
|
||||||
p.name as prompt_name, m.access_password
|
p.name as prompt_name, m.access_password
|
||||||
FROM meetings m
|
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
|
LEFT JOIN prompts p ON m.prompt_id = p.id
|
||||||
WHERE m.meeting_id = %s
|
WHERE m.meeting_id = %s
|
||||||
'''
|
'''
|
||||||
|
|
@ -1079,7 +1112,7 @@ def get_meeting_preview_data(meeting_id: int):
|
||||||
# 获取参会人员信息
|
# 获取参会人员信息
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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,))
|
cursor.execute(attendees_query, (meeting_id,))
|
||||||
attendees_data = cursor.fetchall()
|
attendees_data = cursor.fetchall()
|
||||||
attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data]
|
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",
|
code="500",
|
||||||
message=f"验证密码失败: {str(e)}"
|
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 pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
|
from app.models.models import PromptCreate, PromptUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
class PromptConfigItem(BaseModel):
|
||||||
id: int
|
prompt_id: int
|
||||||
creator_id: int
|
is_enabled: bool = True
|
||||||
created_at: str
|
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")
|
@router.post("/prompts")
|
||||||
def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
def create_prompt(
|
||||||
"""Create a new 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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
try:
|
try:
|
||||||
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
|
is_admin = _is_admin(current_user)
|
||||||
if prompt.is_default:
|
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(
|
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(
|
cursor.execute(
|
||||||
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id)
|
"""
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
INSERT INTO prompts (name, task_type, content, `desc`, is_default, is_active, creator_id, is_system)
|
||||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
prompt.is_active, current_user["user_id"])
|
""",
|
||||||
|
(
|
||||||
|
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()
|
connection.commit()
|
||||||
new_id = cursor.lastrowid
|
return create_api_response(code="200", message="提示词模版创建成功", data={"id": prompt_id})
|
||||||
return create_api_response(
|
|
||||||
code="200",
|
|
||||||
message="提示词创建成功",
|
|
||||||
data={"id": new_id, **prompt.dict()}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "Duplicate entry" in str(e):
|
connection.rollback()
|
||||||
return create_api_response(code="400", message="提示词名称已存在")
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return create_api_response(code="500", message=f"创建提示词失败: {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")
|
@router.get("/prompts")
|
||||||
def get_prompts(
|
def get_prompts(
|
||||||
task_type: Optional[str] = None,
|
task_type: Optional[str] = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
size: int = 50,
|
size: int = 12,
|
||||||
current_user: dict = Depends(get_current_user)
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
# 构建 WHERE 条件
|
is_admin = _is_admin(current_user)
|
||||||
where_conditions = ["creator_id = %s"]
|
where_conditions = []
|
||||||
params = [current_user["user_id"]]
|
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:
|
if task_type:
|
||||||
where_conditions.append("task_type = %s")
|
where_conditions.append("p.task_type = %s")
|
||||||
params.append(task_type)
|
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)
|
||||||
|
|
||||||
# 获取总数
|
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||||
cursor.execute(
|
|
||||||
f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}",
|
|
||||||
tuple(params)
|
|
||||||
)
|
|
||||||
total = cursor.fetchone()['total']
|
|
||||||
|
|
||||||
# 获取分页数据
|
cursor.execute(f"SELECT COUNT(*) as total FROM prompts p WHERE {where_clause}", tuple(params))
|
||||||
offset = (page - 1) * size
|
total = (cursor.fetchone() or {}).get("total", 0)
|
||||||
|
|
||||||
|
offset = max(page - 1, 0) * size
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
f"""
|
||||||
FROM prompts
|
SELECT p.id, p.name, p.task_type, p.content, p.`desc`, p.is_default, p.is_active,
|
||||||
WHERE {where_clause}
|
p.creator_id, p.is_system, p.created_at,
|
||||||
ORDER BY created_at DESC
|
u.caption AS creator_name
|
||||||
LIMIT %s OFFSET %s""",
|
FROM prompts p
|
||||||
tuple(params + [size, offset])
|
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(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="获取提示词列表成功",
|
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)):
|
@router.get("/prompts/active/{task_type}")
|
||||||
"""Get a single prompt by its ID."""
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
"""
|
||||||
FROM prompts WHERE id = %s""",
|
SELECT p.id, p.name, p.`desc`, p.content, p.is_default, p.is_system, p.creator_id,
|
||||||
(prompt_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()
|
prompts = cursor.fetchall()
|
||||||
if not prompt:
|
|
||||||
return create_api_response(code="404", message="提示词不存在")
|
|
||||||
return create_api_response(code="200", message="获取提示词成功", data=prompt)
|
|
||||||
|
|
||||||
@router.put("/prompts/{prompt_id}")
|
enabled = [x for x in prompts if x.get("is_enabled") == 1]
|
||||||
def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
if enabled:
|
||||||
"""Update an existing prompt."""
|
result = enabled
|
||||||
print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}")
|
else:
|
||||||
print(f"[UPDATE PROMPT] user_id={current_user['user_id']}")
|
result = prompts
|
||||||
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}")
|
|
||||||
|
|
||||||
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
try:
|
try:
|
||||||
# 先检查记录是否存在
|
requested_ids = [int(item.prompt_id) for item in request.items if item.is_enabled]
|
||||||
cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,))
|
if requested_ids:
|
||||||
existing = cursor.fetchone()
|
placeholders = ",".join(["%s"] * len(requested_ids))
|
||||||
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}")
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s",
|
f"""
|
||||||
(prompt.task_type, prompt_id)
|
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(
|
cursor.execute(
|
||||||
"""UPDATE prompts
|
"DELETE FROM prompt_config WHERE user_id = %s AND task_type = %s",
|
||||||
SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s
|
(current_user["user_id"], task_type),
|
||||||
WHERE id = %s""",
|
|
||||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
|
||||||
prompt.is_active, prompt_id)
|
|
||||||
)
|
)
|
||||||
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()
|
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:
|
except Exception as e:
|
||||||
print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}")
|
connection.rollback()
|
||||||
if "Duplicate entry" in str(e):
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return create_api_response(code="400", message="提示词名称已存在")
|
|
||||||
return create_api_response(code="500", message=f"更新提示词失败: {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}")
|
@router.delete("/prompts/{prompt_id}")
|
||||||
def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
# 首先检查提示词是否存在以及是否属于当前用户
|
try:
|
||||||
cursor.execute(
|
cursor.execute("SELECT id, creator_id, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
|
||||||
"SELECT creator_id FROM prompts WHERE id = %s",
|
existing = cursor.fetchone()
|
||||||
(prompt_id,)
|
if not existing:
|
||||||
)
|
raise HTTPException(status_code=404, detail="模版不存在")
|
||||||
prompt = cursor.fetchone()
|
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:
|
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
|
||||||
return create_api_response(code="404", message="提示词不存在")
|
connection.commit()
|
||||||
|
return create_api_response(code="200", message="删除成功")
|
||||||
if prompt['creator_id'] != current_user["user_id"]:
|
except HTTPException:
|
||||||
return create_api_response(code="403", message="无权删除其他用户的提示词")
|
raise
|
||||||
|
except Exception as e:
|
||||||
# 检查是否有会议引用了该提示词
|
connection.rollback()
|
||||||
cursor.execute(
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
"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="提示词删除成功")
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File
|
from fastapi import APIRouter, Depends, UploadFile, File
|
||||||
from typing import Optional
|
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.database import get_db_connection
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
|
|
@ -13,6 +13,7 @@ import re
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -25,6 +26,59 @@ def validate_email(email: str) -> bool:
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
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")
|
@router.get("/roles")
|
||||||
def get_all_roles(current_user: dict = Depends(get_current_user)):
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
roles = cursor.fetchall()
|
||||||
return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles])
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if cursor.fetchone():
|
||||||
return create_api_response(code="400", message="用户名已存在")
|
return create_api_response(code="400", message="用户名已存在")
|
||||||
|
|
||||||
password = request.password if request.password else SystemConfigService.get_default_reset_password()
|
password = request.password if request.password else SystemConfigService.get_default_reset_password()
|
||||||
hashed_password = hash_password(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()
|
created_at = datetime.datetime.utcnow()
|
||||||
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
|
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
@ -74,13 +128,13 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
existing_user = cursor.fetchone()
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
return create_api_response(code="404", message="用户不存在")
|
return create_api_response(code="404", message="用户不存在")
|
||||||
|
|
||||||
if request.username and request.username != existing_user['username']:
|
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():
|
if cursor.fetchone():
|
||||||
return create_api_response(code="400", message="用户名已存在")
|
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
|
'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))
|
cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
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
|
FROM sys_users u
|
||||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
LEFT JOIN sys_roles r ON u.role_id = r.role_id
|
||||||
WHERE u.user_id = %s
|
WHERE u.user_id = %s
|
||||||
''', (user_id,))
|
''', (user_id,))
|
||||||
updated_user = cursor.fetchone()
|
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'],
|
avatar_url=updated_user['avatar_url'],
|
||||||
created_at=updated_user['created_at'],
|
created_at=updated_user['created_at'],
|
||||||
role_id=updated_user['role_id'],
|
role_id=updated_user['role_id'],
|
||||||
role_name=updated_user['role_name'],
|
role_name=updated_user['role_name'] or '普通用户'
|
||||||
meetings_created=0,
|
|
||||||
meetings_attended=0
|
|
||||||
)
|
)
|
||||||
return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict())
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="用户不存在")
|
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()
|
connection.commit()
|
||||||
|
|
||||||
return create_api_response(code="200", message="用户删除成功")
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="用户不存在")
|
return create_api_response(code="404", message="用户不存在")
|
||||||
|
|
||||||
hashed_password = hash_password(SystemConfigService.get_default_reset_password())
|
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))
|
cursor.execute(query, (hashed_password, user_id))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
|
|
@ -185,7 +237,7 @@ def get_all_users(
|
||||||
count_params.extend([search_pattern, search_pattern])
|
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:
|
if where_conditions:
|
||||||
count_query += " WHERE " + " AND ".join(where_conditions)
|
count_query += " WHERE " + " AND ".join(where_conditions)
|
||||||
|
|
||||||
|
|
@ -197,12 +249,16 @@ def get_all_users(
|
||||||
# 主查询
|
# 主查询
|
||||||
query = '''
|
query = '''
|
||||||
SELECT
|
SELECT
|
||||||
u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
u.user_id,
|
||||||
r.role_name,
|
u.username,
|
||||||
(SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created,
|
u.caption,
|
||||||
(SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended
|
u.email,
|
||||||
FROM users u
|
u.avatar_url,
|
||||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
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 = []
|
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)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
user_query = '''
|
user_query = '''
|
||||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
||||||
FROM users u
|
COALESCE(r.role_name, '普通用户') AS role_name
|
||||||
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
|
WHERE u.user_id = %s
|
||||||
'''
|
'''
|
||||||
cursor.execute(user_query, (user_id,))
|
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:
|
if not user:
|
||||||
return create_api_response(code="404", message="用户不存在")
|
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_info = UserInfo(
|
||||||
user_id=user['user_id'],
|
user_id=user['user_id'],
|
||||||
username=user['username'],
|
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'],
|
avatar_url=user['avatar_url'],
|
||||||
created_at=user['created_at'],
|
created_at=user['created_at'],
|
||||||
role_id=user['role_id'],
|
role_id=user['role_id'],
|
||||||
role_name=user['role_name'],
|
role_name=user['role_name']
|
||||||
meetings_created=meetings_created,
|
|
||||||
meetings_attended=meetings_attended
|
|
||||||
)
|
)
|
||||||
return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict())
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
user = cursor.fetchone()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -283,7 +330,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
|
||||||
return create_api_response(code="400", message="旧密码错误")
|
return create_api_response(code="400", message="旧密码错误")
|
||||||
|
|
||||||
new_password_hash = hash_password(request.new_password)
|
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()
|
connection.commit()
|
||||||
|
|
||||||
return create_api_response(code="200", message="密码修改成功")
|
return create_api_response(code="200", message="密码修改成功")
|
||||||
|
|
@ -305,7 +352,7 @@ def upload_user_avatar(
|
||||||
return create_api_response(code="400", message="不支持的文件类型")
|
return create_api_response(code="400", message="不支持的文件类型")
|
||||||
|
|
||||||
# Ensure upload directory exists: AVATAR_DIR / str(user_id)
|
# 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():
|
if not user_avatar_dir.exists():
|
||||||
os.makedirs(user_avatar_dir)
|
os.makedirs(user_avatar_dir)
|
||||||
|
|
||||||
|
|
@ -321,13 +368,57 @@ def upload_user_avatar(
|
||||||
# AVATAR_DIR is uploads/user/avatar
|
# AVATAR_DIR is uploads/user/avatar
|
||||||
# file path is uploads/user/avatar/{user_id}/{filename}
|
# file path is uploads/user/avatar/{user_id}/{filename}
|
||||||
# URL should be /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
|
# Update database
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
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:
|
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)
|
user_voiceprint_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 生成文件名:时间戳.wav
|
# 生成文件名:时间戳.wav
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
cursor.execute(
|
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_id,)
|
||||||
)
|
)
|
||||||
user = cursor.fetchone()
|
user = cursor.fetchone()
|
||||||
|
|
@ -67,7 +67,7 @@ def get_optional_current_user(request: Request) -> Optional[dict]:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
cursor.execute(
|
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,)
|
(user_id,)
|
||||||
)
|
)
|
||||||
return cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,22 @@ MARKDOWN_DIR = UPLOAD_DIR / "markdown"
|
||||||
CLIENT_DIR = UPLOAD_DIR / "clients"
|
CLIENT_DIR = UPLOAD_DIR / "clients"
|
||||||
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
|
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
|
||||||
USER_DIR = UPLOAD_DIR / "user"
|
USER_DIR = UPLOAD_DIR / "user"
|
||||||
VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
LEGACY_VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
||||||
AVATAR_DIR = USER_DIR / "avatar"
|
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"}
|
ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"}
|
||||||
|
|
@ -35,8 +49,8 @@ MARKDOWN_DIR.mkdir(exist_ok=True)
|
||||||
CLIENT_DIR.mkdir(exist_ok=True)
|
CLIENT_DIR.mkdir(exist_ok=True)
|
||||||
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
|
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
|
||||||
USER_DIR.mkdir(exist_ok=True)
|
USER_DIR.mkdir(exist_ok=True)
|
||||||
VOICEPRINT_DIR.mkdir(exist_ok=True)
|
LEGACY_VOICEPRINT_DIR.mkdir(exist_ok=True)
|
||||||
AVATAR_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.staticfiles import StaticFiles
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
from app.core.middleware import TerminalCheckMiddleware
|
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
|
from app.core.config import UPLOAD_DIR, API_CONFIG
|
||||||
|
|
||||||
app = FastAPI(
|
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(tags.router, prefix="/api", tags=["Tags"])
|
||||||
app.include_router(admin.router, prefix="/api", tags=["Admin"])
|
app.include_router(admin.router, prefix="/api", tags=["Admin"])
|
||||||
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
|
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(tasks.router, prefix="/api", tags=["Tasks"])
|
||||||
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
|
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
|
||||||
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
|
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
from typing import Optional, Union, List
|
from typing import List, Optional, Any, Dict, Union
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
# 认证相关模型
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: 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):
|
class RoleInfo(BaseModel):
|
||||||
role_id: int
|
role_id: int
|
||||||
role_name: str
|
role_name: str
|
||||||
|
|
@ -23,102 +15,105 @@ class UserInfo(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
username: str
|
username: str
|
||||||
caption: str
|
caption: str
|
||||||
email: EmailStr
|
email: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
|
||||||
created_at: datetime.datetime
|
|
||||||
meetings_created: int
|
|
||||||
meetings_attended: int
|
|
||||||
role_id: int
|
role_id: int
|
||||||
role_name: str
|
role_name: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserInfo
|
||||||
|
|
||||||
class UserListResponse(BaseModel):
|
class UserListResponse(BaseModel):
|
||||||
users: list[UserInfo]
|
users: List[UserInfo]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
class CreateUserRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
caption: str
|
caption: str
|
||||||
email: EmailStr
|
email: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
role_id: int = 2
|
||||||
role_id: int
|
|
||||||
|
|
||||||
class UpdateUserRequest(BaseModel):
|
class UpdateUserRequest(BaseModel):
|
||||||
username: Optional[str] = None
|
|
||||||
caption: Optional[str] = None
|
caption: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
class UserLog(BaseModel):
|
class UserLog(BaseModel):
|
||||||
log_id: int
|
log_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
action_type: str
|
username: str
|
||||||
|
action: str
|
||||||
|
details: Optional[str] = None
|
||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
user_agent: Optional[str] = None
|
|
||||||
metadata: Optional[dict] = None
|
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
# 会议相关模型
|
||||||
class AttendeeInfo(BaseModel):
|
class AttendeeInfo(BaseModel):
|
||||||
user_id: int
|
user_id: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
caption: str
|
caption: str
|
||||||
|
|
||||||
class Tag(BaseModel):
|
class Tag(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
color: str
|
|
||||||
|
|
||||||
class TranscriptionTaskStatus(BaseModel):
|
class TranscriptionTaskStatus(BaseModel):
|
||||||
task_id: str
|
task_id: str
|
||||||
status: str # 'pending', 'processing', 'completed', 'failed'
|
status: str
|
||||||
progress: int # 0-100
|
progress: int
|
||||||
meeting_id: int
|
message: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
|
||||||
updated_at: Optional[str] = None
|
|
||||||
completed_at: Optional[str] = None
|
|
||||||
error_message: Optional[str] = None
|
|
||||||
|
|
||||||
class Meeting(BaseModel):
|
class Meeting(BaseModel):
|
||||||
meeting_id: int
|
meeting_id: int
|
||||||
title: str
|
title: str
|
||||||
meeting_time: Optional[datetime.datetime]
|
meeting_time: datetime.datetime
|
||||||
summary: Optional[str]
|
description: Optional[str] = None
|
||||||
created_at: datetime.datetime
|
|
||||||
attendees: Union[List[str], List[AttendeeInfo]] # Support both formats
|
|
||||||
creator_id: int
|
creator_id: int
|
||||||
creator_username: str
|
creator_username: str
|
||||||
|
created_at: datetime.datetime
|
||||||
|
attendees: List[AttendeeInfo]
|
||||||
|
tags: List[Tag]
|
||||||
audio_file_path: Optional[str] = None
|
audio_file_path: Optional[str] = None
|
||||||
audio_duration: Optional[float] = None
|
audio_duration: Optional[float] = None
|
||||||
prompt_name: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
transcription_status: Optional[TranscriptionTaskStatus] = None
|
transcription_status: Optional[TranscriptionTaskStatus] = None
|
||||||
tags: Optional[List[Tag]] = []
|
prompt_id: Optional[int] = None
|
||||||
access_password: Optional[str] = None
|
prompt_name: Optional[str] = None
|
||||||
|
overall_status: Optional[str] = None
|
||||||
|
overall_progress: Optional[int] = None
|
||||||
|
current_stage: Optional[str] = None
|
||||||
|
|
||||||
class TranscriptSegment(BaseModel):
|
class TranscriptSegment(BaseModel):
|
||||||
segment_id: int
|
segment_id: int
|
||||||
meeting_id: int
|
speaker_id: int
|
||||||
speaker_id: Optional[int] = None # AI解析的原始结果
|
|
||||||
speaker_tag: str
|
speaker_tag: str
|
||||||
start_time_ms: int
|
start_time_ms: int
|
||||||
end_time_ms: int
|
end_time_ms: int
|
||||||
text_content: str
|
text_content: str
|
||||||
|
|
||||||
class CreateMeetingRequest(BaseModel):
|
class CreateMeetingRequest(BaseModel):
|
||||||
user_id: int
|
|
||||||
title: str
|
title: str
|
||||||
meeting_time: Optional[datetime.datetime]
|
meeting_time: datetime.datetime
|
||||||
attendee_ids: list[int]
|
attendees: str # 逗号分隔的姓名
|
||||||
tags: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
tags: Optional[str] = None # 逗号分隔
|
||||||
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class UpdateMeetingRequest(BaseModel):
|
class UpdateMeetingRequest(BaseModel):
|
||||||
title: str
|
title: Optional[str] = None
|
||||||
meeting_time: Optional[datetime.datetime]
|
meeting_time: Optional[datetime.datetime] = None
|
||||||
summary: Optional[str]
|
attendees: Optional[str] = None
|
||||||
attendee_ids: list[int]
|
description: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
|
summary: Optional[str] = None
|
||||||
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class SpeakerTagUpdateRequest(BaseModel):
|
class SpeakerTagUpdateRequest(BaseModel):
|
||||||
speaker_id: int # 使用原始speaker_id(整数)
|
speaker_id: int
|
||||||
new_tag: str
|
new_tag: str
|
||||||
|
|
||||||
class BatchSpeakerTagUpdateRequest(BaseModel):
|
class BatchSpeakerTagUpdateRequest(BaseModel):
|
||||||
|
|
@ -126,7 +121,7 @@ class BatchSpeakerTagUpdateRequest(BaseModel):
|
||||||
|
|
||||||
class TranscriptUpdateRequest(BaseModel):
|
class TranscriptUpdateRequest(BaseModel):
|
||||||
segment_id: int
|
segment_id: int
|
||||||
text_content: str
|
new_text: str
|
||||||
|
|
||||||
class BatchTranscriptUpdateRequest(BaseModel):
|
class BatchTranscriptUpdateRequest(BaseModel):
|
||||||
updates: List[TranscriptUpdateRequest]
|
updates: List[TranscriptUpdateRequest]
|
||||||
|
|
@ -135,45 +130,66 @@ class PasswordChangeRequest(BaseModel):
|
||||||
old_password: str
|
old_password: str
|
||||||
new_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):
|
class KnowledgeBase(BaseModel):
|
||||||
kb_id: int
|
kb_id: int
|
||||||
title: str
|
title: str
|
||||||
content: Optional[str] = None
|
content: str
|
||||||
creator_id: int
|
creator_id: int
|
||||||
creator_caption: str # To show in the UI
|
created_by_name: str
|
||||||
is_shared: bool
|
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
|
created_at: datetime.datetime
|
||||||
updated_at: datetime.datetime
|
updated_at: datetime.datetime
|
||||||
source_meeting_count: Optional[int] = 0
|
source_meeting_count: int
|
||||||
created_by_name: Optional[str] = None
|
source_meetings: Optional[List[Meeting]] = None
|
||||||
|
user_prompt: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class KnowledgeBaseTask(BaseModel):
|
class KnowledgeBaseTask(BaseModel):
|
||||||
task_id: str
|
task_id: str
|
||||||
user_id: int
|
|
||||||
kb_id: int
|
|
||||||
user_prompt: Optional[str] = None
|
|
||||||
status: str
|
status: str
|
||||||
progress: int
|
progress: int
|
||||||
error_message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
created_at: datetime.datetime
|
result: Optional[str] = None
|
||||||
updated_at: datetime.datetime
|
|
||||||
completed_at: Optional[datetime.datetime] = None
|
|
||||||
|
|
||||||
class CreateKnowledgeBaseRequest(BaseModel):
|
class CreateKnowledgeBaseRequest(BaseModel):
|
||||||
title: Optional[str] = None # 改为可选,后台自动生成
|
|
||||||
is_shared: bool
|
|
||||||
user_prompt: Optional[str] = None
|
user_prompt: Optional[str] = None
|
||||||
source_meeting_ids: Optional[str] = None
|
source_meeting_ids: str # 逗号分隔
|
||||||
tags: Optional[str] = None
|
is_shared: bool = False
|
||||||
prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class UpdateKnowledgeBaseRequest(BaseModel):
|
class UpdateKnowledgeBaseRequest(BaseModel):
|
||||||
title: str
|
title: Optional[str] = None
|
||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
is_shared: Optional[bool] = None
|
||||||
|
|
||||||
class KnowledgeBaseListResponse(BaseModel):
|
class KnowledgeBaseListResponse(BaseModel):
|
||||||
kbs: List[KnowledgeBase]
|
kbs: List[KnowledgeBase]
|
||||||
|
|
@ -182,73 +198,63 @@ class KnowledgeBaseListResponse(BaseModel):
|
||||||
# 客户端下载相关模型
|
# 客户端下载相关模型
|
||||||
class ClientDownload(BaseModel):
|
class ClientDownload(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal'
|
platform_code: str
|
||||||
platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux'
|
platform_type: str # mobile, desktop, terminal
|
||||||
platform_code: str # 新版平台编码,关联 dict_data.dict_code
|
platform_name: str
|
||||||
version: str
|
version: str
|
||||||
version_code: int
|
version_code: int
|
||||||
download_url: str
|
download_url: str
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
|
min_system_version: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_latest: bool
|
is_latest: bool
|
||||||
min_system_version: Optional[str] = None
|
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
updated_at: datetime.datetime
|
updated_at: datetime.datetime
|
||||||
created_by: Optional[int] = None
|
|
||||||
|
|
||||||
class CreateClientDownloadRequest(BaseModel):
|
class CreateClientDownloadRequest(BaseModel):
|
||||||
platform_type: Optional[str] = None # 兼容旧版
|
platform_code: str
|
||||||
platform_name: Optional[str] = None # 兼容旧版
|
platform_type: Optional[str] = None
|
||||||
platform_code: str # 必填,关联 dict_data
|
platform_name: Optional[str] = None
|
||||||
version: str
|
version: str
|
||||||
version_code: int
|
version_code: int
|
||||||
download_url: str
|
download_url: str
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
|
min_system_version: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_latest: bool = False
|
is_latest: bool = False
|
||||||
min_system_version: Optional[str] = None
|
|
||||||
|
|
||||||
class UpdateClientDownloadRequest(BaseModel):
|
class UpdateClientDownloadRequest(BaseModel):
|
||||||
|
platform_code: Optional[str] = None
|
||||||
platform_type: Optional[str] = None
|
platform_type: Optional[str] = None
|
||||||
platform_name: Optional[str] = None
|
platform_name: Optional[str] = None
|
||||||
platform_code: Optional[str] = None
|
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
version_code: Optional[int] = None
|
version_code: Optional[int] = None
|
||||||
download_url: Optional[str] = None
|
download_url: Optional[str] = None
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
|
min_system_version: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
is_latest: Optional[bool] = None
|
is_latest: Optional[bool] = None
|
||||||
min_system_version: Optional[str] = None
|
|
||||||
|
|
||||||
class ClientDownloadListResponse(BaseModel):
|
class ClientDownloadListResponse(BaseModel):
|
||||||
clients: List[ClientDownload]
|
clients: List[ClientDownload]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
# 声纹采集相关模型
|
# 声纹相关模型
|
||||||
class VoiceprintInfo(BaseModel):
|
class VoiceprintInfo(BaseModel):
|
||||||
vp_id: int
|
|
||||||
user_id: int
|
user_id: int
|
||||||
file_path: str
|
voiceprint_data: Any
|
||||||
file_size: Optional[int] = None
|
created_at: datetime.datetime
|
||||||
duration_seconds: Optional[float] = None
|
|
||||||
collected_at: datetime.datetime
|
|
||||||
updated_at: datetime.datetime
|
|
||||||
|
|
||||||
class VoiceprintStatus(BaseModel):
|
class VoiceprintStatus(BaseModel):
|
||||||
has_voiceprint: bool
|
has_voiceprint: bool
|
||||||
vp_id: Optional[int] = None
|
updated_at: Optional[datetime.datetime] = None
|
||||||
file_path: Optional[str] = None
|
|
||||||
duration_seconds: Optional[float] = None
|
|
||||||
collected_at: Optional[datetime.datetime] = None
|
|
||||||
|
|
||||||
class VoiceprintTemplate(BaseModel):
|
class VoiceprintTemplate(BaseModel):
|
||||||
template_text: str
|
content: str
|
||||||
duration_seconds: int
|
duration_seconds: int
|
||||||
sample_rate: int
|
|
||||||
channels: int
|
|
||||||
|
|
||||||
# 菜单权限相关模型
|
# 菜单权限相关模型
|
||||||
class MenuInfo(BaseModel):
|
class MenuInfo(BaseModel):
|
||||||
|
|
@ -277,13 +283,51 @@ class RolePermissionInfo(BaseModel):
|
||||||
class UpdateRolePermissionsRequest(BaseModel):
|
class UpdateRolePermissionsRequest(BaseModel):
|
||||||
menu_ids: List[int]
|
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):
|
class Terminal(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
imei: str
|
imei: str
|
||||||
terminal_name: Optional[str] = None
|
terminal_name: Optional[str] = None
|
||||||
terminal_type: str
|
terminal_type: str
|
||||||
terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取)
|
terminal_type_name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: int # 1: 启用, 0: 停用
|
status: int # 1: 启用, 0: 停用
|
||||||
is_activated: int # 1: 已激活, 0: 未激活
|
is_activated: int # 1: 已激活, 0: 未激活
|
||||||
|
|
@ -296,18 +340,23 @@ class Terminal(BaseModel):
|
||||||
updated_at: datetime.datetime
|
updated_at: datetime.datetime
|
||||||
created_by: Optional[int] = None
|
created_by: Optional[int] = None
|
||||||
creator_username: Optional[str] = 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):
|
class CreateTerminalRequest(BaseModel):
|
||||||
imei: str
|
imei: str
|
||||||
terminal_name: Optional[str] = None
|
terminal_name: Optional[str] = None
|
||||||
terminal_type: str
|
terminal_type: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
firmware_version: Optional[str] = None
|
||||||
|
mac_address: Optional[str] = None
|
||||||
status: int = 1
|
status: int = 1
|
||||||
|
|
||||||
class UpdateTerminalRequest(BaseModel):
|
class UpdateTerminalRequest(BaseModel):
|
||||||
terminal_name: Optional[str] = None
|
terminal_name: Optional[str] = None
|
||||||
terminal_type: Optional[str] = None
|
terminal_type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[int] = None
|
|
||||||
firmware_version: Optional[str] = None
|
firmware_version: Optional[str] = None
|
||||||
mac_address: Optional[str] = None
|
mac_address: Optional[str] = None
|
||||||
|
status: Optional[int] = None
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import redis
|
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.core.database import get_db_connection
|
||||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||||
from app.services.llm_service import LLMService
|
from app.services.llm_service import LLMService
|
||||||
|
|
@ -23,7 +25,7 @@ class AsyncMeetingService:
|
||||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||||
self.llm_service = LLMService() # 复用现有的同步LLM服务
|
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)触发。
|
创建异步总结任务,任务的执行将由外部(如API层的BackgroundTasks)触发。
|
||||||
|
|
||||||
|
|
@ -31,6 +33,7 @@ class AsyncMeetingService:
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
user_prompt: 用户额外提示词
|
user_prompt: 用户额外提示词
|
||||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||||
|
model_code: 可选的LLM模型编码,如果不指定则使用默认模型
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 任务ID
|
str: 任务ID
|
||||||
|
|
@ -49,6 +52,7 @@ class AsyncMeetingService:
|
||||||
'meeting_id': str(meeting_id),
|
'meeting_id': str(meeting_id),
|
||||||
'user_prompt': user_prompt,
|
'user_prompt': user_prompt,
|
||||||
'prompt_id': str(prompt_id) if prompt_id else '',
|
'prompt_id': str(prompt_id) if prompt_id else '',
|
||||||
|
'model_code': model_code or '',
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'progress': '0',
|
'progress': '0',
|
||||||
'created_at': current_time,
|
'created_at': current_time,
|
||||||
|
|
@ -79,6 +83,7 @@ class AsyncMeetingService:
|
||||||
user_prompt = task_data.get('user_prompt', '')
|
user_prompt = task_data.get('user_prompt', '')
|
||||||
prompt_id_str = task_data.get('prompt_id', '')
|
prompt_id_str = task_data.get('prompt_id', '')
|
||||||
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
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
|
# 1. 更新状态为processing
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
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提示词...")
|
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
|
||||||
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id)
|
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正在分析会议内容...")
|
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:
|
if not summary_content:
|
||||||
raise Exception("LLM API调用失败或返回空内容")
|
raise Exception("LLM API调用失败或返回空内容")
|
||||||
|
|
||||||
# 5. 保存结果到主表
|
# 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)
|
self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id)
|
||||||
|
|
||||||
# 6. 任务完成
|
# 6. 导出MD文件到音频同目录
|
||||||
self._update_task_in_db(task_id, 'completed', 100, result=summary_content)
|
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
|
||||||
self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content)
|
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")
|
print(f"Task {task_id} completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
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:
|
def _get_meeting_transcript(self, meeting_id: int) -> str:
|
||||||
"""从数据库获取会议转录内容"""
|
"""从数据库获取会议转录内容"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -417,14 +509,14 @@ class AsyncMeetingService:
|
||||||
try:
|
try:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
params = [status, progress, error_message, task_id]
|
|
||||||
if status == 'completed':
|
if status == 'completed':
|
||||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s"
|
query = "UPDATE llm_tasks SET status = %s, progress = %s, result = %s, error_message = NULL, completed_at = NOW() WHERE task_id = %s"
|
||||||
params.insert(2, result)
|
params = (status, progress, result, task_id)
|
||||||
else:
|
else:
|
||||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s"
|
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()
|
connection.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating task in database: {e}")
|
print(f"Error updating task in database: {e}")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import redis
|
import redis
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -21,6 +22,83 @@ class AsyncTranscriptionService:
|
||||||
dashscope.api_key = QWEN_API_KEY
|
dashscope.api_key = QWEN_API_KEY
|
||||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||||
self.base_url = APP_CONFIG['base_url']
|
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:
|
def start_transcription(self, meeting_id: int, audio_file_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -59,24 +137,31 @@ class AsyncTranscriptionService:
|
||||||
# 2. 构造完整的文件URL
|
# 2. 构造完整的文件URL
|
||||||
file_url = f"{self.base_url}{audio_file_path}"
|
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
|
# 3. 调用Paraformer异步API
|
||||||
call_params = {
|
session = self._create_requests_session()
|
||||||
'model': 'paraformer-v2',
|
try:
|
||||||
'file_urls': [file_url],
|
if base_address:
|
||||||
'language_hints': ['zh', 'en'],
|
task_response = Transcription.async_call(base_address=base_address, session=session, **call_params)
|
||||||
'disfluency_removal_enabled': True,
|
else:
|
||||||
'diarization_enabled': True,
|
task_response = Transcription.async_call(session=session, **call_params)
|
||||||
'speaker_count': 10
|
finally:
|
||||||
}
|
session.close()
|
||||||
if vocabulary_id:
|
|
||||||
call_params['vocabulary_id'] = vocabulary_id
|
|
||||||
|
|
||||||
task_response = Transcription.async_call(**call_params)
|
|
||||||
|
|
||||||
if task_response.status_code != HTTPStatus.OK:
|
if task_response.status_code != HTTPStatus.OK:
|
||||||
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
|
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
|
||||||
|
|
@ -134,7 +219,11 @@ class AsyncTranscriptionService:
|
||||||
|
|
||||||
# 2. 查询外部API获取状态
|
# 2. 查询外部API获取状态
|
||||||
try:
|
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:
|
if paraformer_response.status_code != HTTPStatus.OK:
|
||||||
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
|
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']
|
transcription_url = paraformer_output['results'][0]['transcription_url']
|
||||||
print(f"Fetching transcription from URL: {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()
|
response.raise_for_status()
|
||||||
transcription_data = response.json()
|
transcription_data = response.json()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import json
|
import json
|
||||||
import dashscope
|
import os
|
||||||
from http import HTTPStatus
|
from typing import Optional, Dict, Generator, Any
|
||||||
from typing import Optional, Dict, List, Generator, Any
|
|
||||||
|
import requests
|
||||||
|
|
||||||
import app.core.config as config_module
|
import app.core.config as config_module
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.services.system_config_service import SystemConfigService
|
from app.services.system_config_service import SystemConfigService
|
||||||
|
|
@ -10,23 +12,104 @@ from app.services.system_config_service import SystemConfigService
|
||||||
class LLMService:
|
class LLMService:
|
||||||
"""LLM服务 - 专注于大模型API调用和提示词管理"""
|
"""LLM服务 - 专注于大模型API调用和提示词管理"""
|
||||||
|
|
||||||
def __init__(self):
|
@staticmethod
|
||||||
# 设置dashscope API key
|
def _create_requests_session() -> requests.Session:
|
||||||
dashscope.api_key = config_module.QWEN_API_KEY
|
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]:
|
def _get_llm_call_params(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取 dashscope.Generation.call() 所需的参数字典
|
获取 OpenAI 兼容接口调用参数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: 包含 model、timeout、temperature、top_p 的参数字典
|
Dict: 包含 endpoint_url、api_key、model、timeout、temperature、top_p、max_tokens 的参数字典
|
||||||
"""
|
"""
|
||||||
return {
|
return self.build_call_params_from_config()
|
||||||
'model': SystemConfigService.get_llm_model_name(),
|
|
||||||
'timeout': SystemConfigService.get_llm_timeout(),
|
@staticmethod
|
||||||
'temperature': SystemConfigService.get_llm_temperature(),
|
def _build_chat_url(endpoint_url: str) -> str:
|
||||||
'top_p': SystemConfigService.get_llm_top_p(),
|
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:
|
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:
|
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 = {
|
default_prompts = {
|
||||||
'MEETING_TASK': system_prompt,
|
'MEETING_TASK': system_prompt,
|
||||||
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
|
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
|
||||||
|
|
@ -87,50 +170,98 @@ class LLMService:
|
||||||
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
|
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
|
||||||
|
|
||||||
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
|
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
|
||||||
"""流式调用阿里Qwen大模型API"""
|
"""流式调用 OpenAI 兼容大模型API"""
|
||||||
try:
|
params = self._get_llm_call_params()
|
||||||
responses = dashscope.Generation.call(
|
if not params["api_key"]:
|
||||||
**self._get_llm_call_params(),
|
yield "error: 缺少API Key"
|
||||||
prompt=prompt,
|
return
|
||||||
stream=True,
|
|
||||||
incremental_output=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for response in responses:
|
try:
|
||||||
if response.status_code == HTTPStatus.OK:
|
session = self._create_requests_session()
|
||||||
# 增量输出内容
|
try:
|
||||||
new_content = response.output.get('text', '')
|
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:
|
if new_content:
|
||||||
yield new_content
|
yield new_content
|
||||||
else:
|
finally:
|
||||||
error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}"
|
session.close()
|
||||||
print(error_msg)
|
|
||||||
yield f"error: {error_msg}"
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"流式调用大模型API错误: {e}"
|
error_msg = f"流式调用大模型API错误: {e}"
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
yield f"error: {error_msg}"
|
yield f"error: {error_msg}"
|
||||||
|
|
||||||
def _call_llm_api(self, prompt: str) -> Optional[str]:
|
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:
|
try:
|
||||||
response = dashscope.Generation.call(
|
session = self._create_requests_session()
|
||||||
**self._get_llm_call_params(),
|
try:
|
||||||
prompt=prompt
|
response = session.post(
|
||||||
)
|
self._build_chat_url(params["endpoint_url"]),
|
||||||
|
headers=self._build_headers(params["api_key"]),
|
||||||
if response.status_code == HTTPStatus.OK:
|
json=self._build_payload(prompt, params=params),
|
||||||
return response.output.get('text', '')
|
timeout=params["timeout"],
|
||||||
else:
|
)
|
||||||
print(f"API调用失败: {response.status_code}, {response.message}")
|
response.raise_for_status()
|
||||||
return None
|
content = self._extract_response_text(response.json())
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
if content:
|
||||||
|
return content
|
||||||
|
print("API调用失败: 返回内容为空")
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"调用大模型API错误: {e}")
|
print(f"调用大模型API错误: {e}")
|
||||||
return None
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ from app.core.database import get_db_connection
|
||||||
|
|
||||||
|
|
||||||
class SystemConfigService:
|
class SystemConfigService:
|
||||||
"""系统配置服务 - 从 dict_data 表中读取和保存 system_config 类型的配置"""
|
"""系统配置服务 - 优先从新配置表读取,兼容 dict_data(system_config) 回退"""
|
||||||
|
|
||||||
DICT_TYPE = 'system_config'
|
DICT_TYPE = 'system_config'
|
||||||
|
DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||||
|
|
||||||
# 配置键常量
|
# 配置键常量
|
||||||
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
|
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
|
||||||
|
|
@ -27,6 +28,219 @@ class SystemConfigService:
|
||||||
LLM_TEMPERATURE = 'llm_temperature'
|
LLM_TEMPERATURE = 'llm_temperature'
|
||||||
LLM_TOP_P = 'llm_top_p'
|
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
|
@classmethod
|
||||||
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
|
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
|
||||||
"""
|
"""
|
||||||
|
|
@ -39,12 +253,18 @@ class SystemConfigService:
|
||||||
Returns:
|
Returns:
|
||||||
配置项的值
|
配置项的值
|
||||||
"""
|
"""
|
||||||
|
# 1) 新参数表
|
||||||
|
value = cls._get_parameter_value(dict_code)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# 2) 兼容旧 sys_dict_data
|
||||||
try:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
query = """
|
||||||
SELECT extension_attr
|
SELECT extension_attr
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
@ -80,12 +300,18 @@ class SystemConfigService:
|
||||||
Returns:
|
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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
query = """
|
||||||
SELECT extension_attr
|
SELECT extension_attr
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
@ -119,13 +345,74 @@ class SystemConfigService:
|
||||||
Returns:
|
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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
# 检查配置是否存在
|
# 检查配置是否存在
|
||||||
cursor.execute(
|
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)
|
(cls.DICT_TYPE, dict_code)
|
||||||
)
|
)
|
||||||
existing = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
|
|
@ -135,7 +422,7 @@ class SystemConfigService:
|
||||||
if existing:
|
if existing:
|
||||||
# 更新现有配置
|
# 更新现有配置
|
||||||
update_query = """
|
update_query = """
|
||||||
UPDATE dict_data
|
UPDATE sys_dict_data
|
||||||
SET extension_attr = %s, update_time = NOW()
|
SET extension_attr = %s, update_time = NOW()
|
||||||
WHERE dict_type = %s AND dict_code = %s
|
WHERE dict_type = %s AND dict_code = %s
|
||||||
"""
|
"""
|
||||||
|
|
@ -146,7 +433,7 @@ class SystemConfigService:
|
||||||
label_cn = dict_code
|
label_cn = dict_code
|
||||||
|
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO dict_data (
|
INSERT INTO sys_dict_data (
|
||||||
dict_type, dict_code, parent_code, label_cn,
|
dict_type, dict_code, parent_code, label_cn,
|
||||||
extension_attr, status, sort_order
|
extension_attr, status, sort_order
|
||||||
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
|
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
|
||||||
|
|
@ -169,12 +456,32 @@ class SystemConfigService:
|
||||||
Returns:
|
Returns:
|
||||||
配置字典 {dict_code: value}
|
配置字典 {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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
query = """
|
||||||
SELECT dict_code, label_cn, extension_attr
|
SELECT dict_code, label_cn, extension_attr
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND status = 1
|
WHERE dict_type = %s AND status = 1
|
||||||
ORDER BY sort_order
|
ORDER BY sort_order
|
||||||
"""
|
"""
|
||||||
|
|
@ -219,19 +526,28 @@ class SystemConfigService:
|
||||||
# 便捷方法:获取特定配置
|
# 便捷方法:获取特定配置
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_asr_vocabulary_id(cls) -> Optional[str]:
|
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)
|
return cls.get_config(cls.ASR_VOCABULARY_ID)
|
||||||
|
|
||||||
# 声纹配置获取方法(直接使用通用方法)
|
# 声纹配置获取方法(直接使用通用方法)
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str:
|
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
|
@classmethod
|
||||||
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
|
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
|
||||||
"""获取声纹文件大小限制 (bytes), 默认5MB"""
|
"""获取声纹文件大小限制 (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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -240,7 +556,7 @@ class SystemConfigService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_duration(cls, default: int = 12) -> int:
|
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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -249,7 +565,7 @@ class SystemConfigService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_sample_rate(cls, default: int = 16000) -> int:
|
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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -258,7 +574,7 @@ class SystemConfigService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_channels(cls, default: int = 1) -> int:
|
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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -319,3 +635,33 @@ class SystemConfigService:
|
||||||
return float(value)
|
return float(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
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,
|
cu.caption as current_user_caption,
|
||||||
dd.label_cn as terminal_type_name
|
dd.label_cn as terminal_type_name
|
||||||
FROM terminals t
|
FROM terminals t
|
||||||
LEFT JOIN users u ON t.created_by = u.user_id
|
LEFT JOIN sys_users u ON t.created_by = u.user_id
|
||||||
LEFT JOIN users cu ON t.current_user_id = cu.user_id
|
LEFT JOIN sys_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_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
|
|
@ -75,8 +75,8 @@ class TerminalService:
|
||||||
u.username as creator_username,
|
u.username as creator_username,
|
||||||
dd.label_cn as terminal_type_name
|
dd.label_cn as terminal_type_name
|
||||||
FROM terminals t
|
FROM terminals t
|
||||||
LEFT JOIN users u ON t.created_by = u.user_id
|
LEFT JOIN sys_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_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (terminal_id,))
|
cursor.execute(query, (terminal_id,))
|
||||||
|
|
@ -105,14 +105,17 @@ class TerminalService:
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO terminals (
|
INSERT INTO terminals (
|
||||||
imei, terminal_name, terminal_type, description, status, created_by
|
imei, terminal_name, terminal_type, description,
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s)
|
firmware_version, mac_address, status, created_by
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (
|
cursor.execute(query, (
|
||||||
terminal_data.imei,
|
terminal_data.imei,
|
||||||
terminal_data.terminal_name,
|
terminal_data.terminal_name,
|
||||||
terminal_data.terminal_type,
|
terminal_data.terminal_type,
|
||||||
terminal_data.description,
|
terminal_data.description,
|
||||||
|
terminal_data.firmware_version,
|
||||||
|
terminal_data.mac_address,
|
||||||
terminal_data.status,
|
terminal_data.status,
|
||||||
user_id
|
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
|
-- 创建日期: 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_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-分隔符',
|
`menu_type` enum('action','link','divider') COLLATE utf8mb4_unicode_ci DEFAULT 'action' COMMENT '菜单类型: action-操作/link-链接/divider-分隔符',
|
||||||
`parent_id` int(11) DEFAULT NULL COMMENT '父菜单ID(用于层级菜单)',
|
`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 '排序顺序',
|
`sort_order` int(11) DEFAULT 0 COMMENT '排序顺序',
|
||||||
`is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用: 1-启用, 0-禁用',
|
`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 '菜单描述',
|
`description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单描述',
|
||||||
`created_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 '更新时间',
|
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
PRIMARY KEY (`menu_id`),
|
PRIMARY KEY (`menu_id`),
|
||||||
UNIQUE KEY `uk_menu_code` (`menu_code`),
|
UNIQUE KEY `uk_menu_code` (`menu_code`),
|
||||||
KEY `idx_parent_id` (`parent_id`),
|
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='系统菜单表';
|
) 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',
|
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '权限ID',
|
||||||
`role_id` int(11) NOT NULL COMMENT '角色ID',
|
`role_id` int(11) NOT NULL COMMENT '角色ID',
|
||||||
`menu_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 '创建时间',
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_role_menu` (`role_id`,`menu_id`),
|
UNIQUE KEY `uk_role_menu` (`role_id`,`menu_id`),
|
||||||
KEY `idx_role_id` (`role_id`),
|
KEY `idx_role_id` (`role_id`),
|
||||||
KEY `idx_menu_id` (`menu_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_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
|
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='角色菜单权限映射表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表';
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- 初始化菜单数据(基于现有系统的下拉菜单)
|
-- 初始化菜单数据
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
BEGIN;
|
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
|
VALUES
|
||||||
('change_password', '修改密码', 'KeyRound', NULL, 'action', 1, 1, '用户修改自己的密码'),
|
('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', NULL, 1, NULL, 1, 1, 1, '管理个人账户信息'),
|
||||||
('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', 2, 1, '管理AI提示词模版'),
|
('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', NULL, 1, NULL, 2, 1, 1, '管理AI提示词模版'),
|
||||||
('platform_admin', '平台管理', 'Shield', '/admin/management', 'link', 3, 1, '平台管理员后台'),
|
('platform_admin', '平台管理', 'Shield', '/admin/management/user-management', 'link', NULL, 1, NULL, 3, 1, 1, '平台管理员后台'),
|
||||||
('logout', '退出登录', 'LogOut', NULL, 'action', 99, 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;
|
COMMIT;
|
||||||
|
|
||||||
|
|
@ -70,30 +114,19 @@ BEGIN;
|
||||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||||
SELECT 1, menu_id FROM `menus` WHERE is_active = 1;
|
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`)
|
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;
|
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`),
|
UNIQUE KEY `uk_menu_code` (`menu_code`),
|
||||||
KEY `idx_parent_id` (`parent_id`),
|
KEY `idx_parent_id` (`parent_id`),
|
||||||
KEY `idx_is_active` (`is_active`)
|
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
|
-- Records of menus
|
||||||
|
|
@ -552,8 +552,15 @@ CREATE TABLE `menus` (
|
||||||
BEGIN;
|
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 (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 (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 (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;
|
COMMIT;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|
@ -607,7 +614,7 @@ CREATE TABLE `role_menu_permissions` (
|
||||||
KEY `idx_menu_id` (`menu_id`),
|
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_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
|
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
|
-- 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 (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 (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 (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 (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, 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 (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;
|
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",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uiw/react-md-editor": "^4.0.8",
|
"@uiw/react-md-editor": "^4.0.8",
|
||||||
"antd": "^5.27.3",
|
"antd": "^5.27.3",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
"canvg": "^4.0.3",
|
"canvg": "^4.0.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^3.0.2",
|
"jspdf": "^3.0.2",
|
||||||
"lucide-react": "^0.294.0",
|
|
||||||
"markmap-common": "^0.18.9",
|
"markmap-common": "^0.18.9",
|
||||||
"markmap-lib": "^0.18.12",
|
"markmap-lib": "^0.18.12",
|
||||||
"markmap-view": "^0.18.12",
|
"markmap-view": "^0.18.12",
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,10 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'MiSans', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #f8fafc;
|
background-color: transparent;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +20,228 @@ body {
|
||||||
width: 100%;
|
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 {
|
.app-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -147,4 +367,4 @@ body {
|
||||||
.text-gray-500 { color: #64748b; }
|
.text-gray-500 { color: #64748b; }
|
||||||
.text-gray-600 { color: #475569; }
|
.text-gray-600 { color: #475569; }
|
||||||
.text-gray-700 { color: #334155; }
|
.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 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 apiClient from './utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from './config/api';
|
import { buildApiUrl, API_ENDPOINTS } from './config/api';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
|
|
@ -7,15 +9,75 @@ import Dashboard from './pages/Dashboard';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import MeetingDetails from './pages/MeetingDetails';
|
import MeetingDetails from './pages/MeetingDetails';
|
||||||
import MeetingPreview from './pages/MeetingPreview';
|
import MeetingPreview from './pages/MeetingPreview';
|
||||||
import CreateMeeting from './pages/CreateMeeting';
|
|
||||||
import EditMeeting from './pages/EditMeeting';
|
|
||||||
import AdminManagement from './pages/AdminManagement';
|
import AdminManagement from './pages/AdminManagement';
|
||||||
import PromptManagementPage from './pages/PromptManagementPage';
|
import PromptManagementPage from './pages/PromptManagementPage';
|
||||||
|
import PromptConfigPage from './pages/PromptConfigPage';
|
||||||
import KnowledgeBasePage from './pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './pages/KnowledgeBasePage';
|
||||||
import EditKnowledgeBase from './pages/EditKnowledgeBase';
|
import EditKnowledgeBase from './pages/EditKnowledgeBase';
|
||||||
import ClientDownloadPage from './pages/ClientDownloadPage';
|
import ClientDownloadPage from './pages/ClientDownloadPage';
|
||||||
import AccountSettings from './pages/AccountSettings';
|
import AccountSettings from './pages/AccountSettings';
|
||||||
|
import MeetingCenterPage from './pages/MeetingCenterPage';
|
||||||
|
import MainLayout from './components/MainLayout';
|
||||||
|
import menuService from './services/menuService';
|
||||||
import './App.css';
|
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() {
|
function App() {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
|
@ -23,13 +85,19 @@ function App() {
|
||||||
|
|
||||||
// Load user from localStorage on app start
|
// Load user from localStorage on app start
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedUser = localStorage.getItem('iMeetingUser');
|
const savedAuth = localStorage.getItem('iMeetingUser');
|
||||||
console.log('Saved user from localStorage:', savedUser);
|
if (savedAuth && savedAuth !== "undefined" && savedAuth !== "null") {
|
||||||
if (savedUser) {
|
|
||||||
try {
|
try {
|
||||||
const parsedUser = JSON.parse(savedUser);
|
const authData = JSON.parse(savedAuth);
|
||||||
console.log('Parsed user:', parsedUser);
|
// 如果数据包含 user 字段,则提取 user 字段(适应新结构)
|
||||||
setUser(parsedUser);
|
// 否则使用整个对象(兼容旧结构)
|
||||||
|
const userData = authData.user || authData;
|
||||||
|
|
||||||
|
if (userData && typeof userData === 'object' && (userData.user_id || userData.id)) {
|
||||||
|
setUser(userData);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('iMeetingUser');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing saved user:', error);
|
console.error('Error parsing saved user:', error);
|
||||||
localStorage.removeItem('iMeetingUser');
|
localStorage.removeItem('iMeetingUser');
|
||||||
|
|
@ -38,22 +106,27 @@ function App() {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogin = (userData) => {
|
const handleLogin = (authData) => {
|
||||||
setUser(userData);
|
if (authData) {
|
||||||
localStorage.setItem('iMeetingUser', JSON.stringify(userData));
|
menuService.clearCache();
|
||||||
|
// 提取用户信息用于 UI 展示
|
||||||
|
const userData = authData.user || authData;
|
||||||
|
setUser(userData);
|
||||||
|
// 存入完整 auth 数据(包含 token)供拦截器使用
|
||||||
|
localStorage.setItem('iMeetingUser', JSON.stringify(authData));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
// 调用后端登出API撤销token
|
|
||||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGOUT));
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGOUT));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout API error:', error);
|
console.error('Logout API error:', error);
|
||||||
// 即使API调用失败也继续登出流程
|
|
||||||
} finally {
|
} finally {
|
||||||
// 清除本地状态和存储
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem('iMeetingUser');
|
localStorage.removeItem('iMeetingUser');
|
||||||
|
menuService.clearCache();
|
||||||
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -67,49 +140,119 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<ConfigProvider
|
||||||
<div className="app">
|
locale={zhCN}
|
||||||
<Routes>
|
theme={{
|
||||||
<Route path="/" element={
|
token: {
|
||||||
user ? <Navigate to="/dashboard" /> : <HomePage onLogin={handleLogin} />
|
colorPrimary: '#1d4ed8',
|
||||||
} />
|
colorSuccess: '#0f766e',
|
||||||
<Route path="/dashboard" element={
|
colorWarning: '#d97706',
|
||||||
user ? (
|
colorError: '#c2410c',
|
||||||
user.role_id === 1
|
borderRadius: 12,
|
||||||
? <AdminDashboard user={user} onLogout={handleLogout} />
|
borderRadiusLG: 16,
|
||||||
: <Dashboard user={user} onLogout={handleLogout} />
|
wireframe: false,
|
||||||
) : <Navigate to="/" />
|
fontFamily: '"MiSans", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif',
|
||||||
} />
|
fontSize: 14,
|
||||||
<Route path="/meetings/:meeting_id" element={
|
colorTextBase: '#112b4e',
|
||||||
user ? <MeetingDetails user={user} /> : <Navigate to="/" />
|
colorBgLayout: 'transparent',
|
||||||
} />
|
},
|
||||||
<Route path="/meetings/create" element={
|
algorithm: theme.defaultAlgorithm,
|
||||||
user ? <CreateMeeting user={user} /> : <Navigate to="/" />
|
components: {
|
||||||
} />
|
Layout: {
|
||||||
<Route path="/meetings/edit/:meeting_id" element={
|
bodyBg: 'transparent',
|
||||||
user ? <EditMeeting user={user} /> : <Navigate to="/" />
|
siderBg: 'rgba(255,255,255,0.82)',
|
||||||
} />
|
headerBg: 'transparent',
|
||||||
<Route path="/admin/management" element={
|
},
|
||||||
user && user.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" />
|
Card: {
|
||||||
} />
|
paddingLG: 18,
|
||||||
<Route path="/prompt-management" element={
|
},
|
||||||
user ? <PromptManagementPage user={user} /> : <Navigate to="/" />
|
Table: {
|
||||||
} />
|
headerBorderRadius: 14,
|
||||||
<Route path="/knowledge-base" element={
|
},
|
||||||
user ? <KnowledgeBasePage user={user} /> : <Navigate to="/" />
|
Button: {
|
||||||
} />
|
controlHeight: 40,
|
||||||
<Route path="/knowledge-base/edit/:kb_id" element={
|
controlHeightLG: 46,
|
||||||
user ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
|
borderRadius: 12,
|
||||||
} />
|
fontWeight: 600,
|
||||||
<Route path="/account-settings" element={
|
paddingInline: 18,
|
||||||
user ? <AccountSettings user={user} onUpdateUser={handleLogin} /> : <Navigate to="/" />
|
defaultBorderColor: 'rgba(148, 163, 184, 0.24)',
|
||||||
} />
|
defaultColor: '#274365',
|
||||||
<Route path="/downloads" element={<ClientDownloadPage />} />
|
defaultBg: 'rgba(255,255,255,0.92)',
|
||||||
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
|
defaultHoverBg: '#ffffff',
|
||||||
</Routes>
|
defaultHoverBorderColor: 'rgba(59, 130, 246, 0.3)',
|
||||||
</div>
|
defaultHoverColor: '#1d4ed8',
|
||||||
</Router>
|
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 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 apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import './ClientDownloads.css';
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const ClientDownloads = () => {
|
const ClientDownloads = () => {
|
||||||
const [clients, setClients] = useState({
|
const [clients, setClients] = useState([]);
|
||||||
mobile: [],
|
|
||||||
desktop: [],
|
|
||||||
terminal: []
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLatestClients();
|
fetchClients();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchLatestClients = async () => {
|
const fetchClients = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST));
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.PUBLIC_LIST));
|
||||||
console.log('Latest clients response:', response);
|
setClients(response.data.clients || []);
|
||||||
setClients(response.data || { mobile: [], desktop: [], terminal: [] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取客户端下载失败:', error);
|
console.error('获取下载列表失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlatformIcon = (platformCode) => {
|
const getPlatformIcon = (platformCode) => {
|
||||||
const code = (platformCode || '').toUpperCase();
|
const code = platformCode.toLowerCase();
|
||||||
|
if (code.includes('win')) return <WindowsOutlined />;
|
||||||
// 根据 platform_code 判断图标
|
if (code.includes('mac') || code.includes('ios')) return <AppleOutlined />;
|
||||||
if (code.includes('IOS') || code.includes('MAC')) {
|
if (code.includes('android')) return <RobotOutlined />;
|
||||||
return <Apple size={32} />;
|
return <DesktopOutlined />;
|
||||||
} 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 getPlatformLabel = (client) => {
|
if (loading) return <Skeleton active />;
|
||||||
// 优先使用 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-downloads-section">
|
<div className="client-downloads-modern">
|
||||||
<div className="section-header">
|
<Title level={4} style={{ marginBottom: 24 }}>
|
||||||
<h2>下载客户端</h2>
|
<Space><CloudOutlined /> 客户端下载</Space>
|
||||||
<p>选择适合您设备的版本</p>
|
</Title>
|
||||||
</div>
|
|
||||||
|
{clients.length === 0 ? (
|
||||||
<div className="downloads-container">
|
<Empty description="暂无可用下载版本" />
|
||||||
{/* 移动端 */}
|
) : (
|
||||||
{clients.mobile && clients.mobile.length > 0 && (
|
<List
|
||||||
<div className="platform-group">
|
grid={{ gutter: 16, xs: 1, sm: 2, md: 3 }}
|
||||||
<div className="group-header">
|
dataSource={clients}
|
||||||
<Smartphone size={24} />
|
renderItem={client => (
|
||||||
<h3>移动端</h3>
|
<List.Item>
|
||||||
</div>
|
<Card hoverable style={{ borderRadius: 12 }}>
|
||||||
<div className="clients-list">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
{clients.mobile.map(client => (
|
<Space>
|
||||||
<a
|
<div style={{
|
||||||
key={client.id}
|
width: 40, height: 40, background: '#f0f7ff',
|
||||||
href={client.download_url}
|
borderRadius: 8, display: 'flex', alignItems: 'center',
|
||||||
target="_blank"
|
justifyContent: 'center', color: '#1677ff', fontSize: 20
|
||||||
rel="noopener noreferrer"
|
}}>
|
||||||
className="client-download-card"
|
{getPlatformIcon(client.platform_code)}
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
{client.min_system_version && (
|
<div>
|
||||||
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
|
<Text strong>{client.platform_name_cn || client.platform_code}</Text>
|
||||||
)}
|
{client.is_latest && <Badge status="success" text="最新" style={{ marginLeft: 8 }} />}
|
||||||
</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>
|
</div>
|
||||||
{client.min_system_version && (
|
</Space>
|
||||||
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div className="download-icon">
|
<Text type="secondary" size="small">版本: </Text>
|
||||||
<Download size={20} />
|
<Text strong>{client.version}</Text>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 专用终端 */}
|
<Button
|
||||||
{clients.terminal && clients.terminal.length > 0 && (
|
type="primary"
|
||||||
<div className="platform-group">
|
block
|
||||||
<div className="group-header">
|
icon={<CloudOutlined />}
|
||||||
<Cpu size={24} />
|
href={client.download_url}
|
||||||
<h3>专用终端</h3>
|
|
||||||
</div>
|
|
||||||
<div className="clients-list">
|
|
||||||
{clients.terminal.map(client => (
|
|
||||||
<a
|
|
||||||
key={client.id}
|
|
||||||
href={client.download_url}
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="client-download-card"
|
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
立即下载
|
||||||
{getPlatformIcon(client.platform_code)}
|
</Button>
|
||||||
</div>
|
</Card>
|
||||||
<div className="card-info">
|
</List.Item>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 React, { useState } from 'react';
|
||||||
import { Tabs } from 'antd';
|
import { Card, Tabs, Typography, Space, Button, Empty } from 'antd';
|
||||||
import { FileText, Brain } from 'lucide-react';
|
import {
|
||||||
import MindMap from './MindMap';
|
FileTextOutlined,
|
||||||
|
PartitionOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
BulbOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
import MarkdownRenderer from './MarkdownRenderer';
|
import MarkdownRenderer from './MarkdownRenderer';
|
||||||
import './ContentViewer.css';
|
import MindMap from './MindMap';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
/**
|
const ContentViewer = ({
|
||||||
* ContentViewer - 纯展示组件,用于显示Markdown内容和脑图
|
content,
|
||||||
*
|
title,
|
||||||
* 设计原则:
|
emptyMessage = "暂无内容",
|
||||||
* 1. 组件只负责纯展示,不处理数据获取
|
summaryActions = null,
|
||||||
* 2. 父组件负责数据准备和导出功能
|
mindmapActions = null
|
||||||
* 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
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
<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>
|
|
||||||
|
|
||||||
<TabPane
|
if (!content) {
|
||||||
tab={
|
return (
|
||||||
<span>
|
<Card bordered={false} style={{ borderRadius: 12 }}>
|
||||||
<Brain size={16} /> 脑图
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />
|
||||||
</span>
|
</Card>
|
||||||
}
|
);
|
||||||
key="mindmap"
|
}
|
||||||
>
|
|
||||||
<div className="tab-header">
|
const items = [
|
||||||
<h2><Brain size={18} /> 思维导图</h2>
|
{
|
||||||
{mindmapActions && <div className="tab-actions">{mindmapActions}</div>}
|
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>
|
</div>
|
||||||
{content ? (
|
<MarkdownRenderer content={content} />
|
||||||
<MindMap
|
</div>
|
||||||
content={content}
|
)
|
||||||
title={title}
|
},
|
||||||
initialScale={1.8}
|
{
|
||||||
/>
|
key: 'mindmap',
|
||||||
) : (
|
label: <Space><PartitionOutlined />思维导图</Space>,
|
||||||
<div className="empty-content">等待内容生成后查看脑图</div>
|
children: (
|
||||||
)}
|
<div style={{ padding: '8px 0' }}>
|
||||||
</TabPane>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||||
</Tabs>
|
<Space>{mindmapActions}</Space>
|
||||||
</div>
|
</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 React from 'react';
|
||||||
import { Calendar, Clock } from 'lucide-react';
|
import { DatePicker } from 'antd';
|
||||||
import './DateTimePicker.css';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
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?.('');
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const DateTimePicker = ({ value, onChange, placeholder = "选择日期时间" }) => {
|
||||||
return (
|
return (
|
||||||
<div className="datetime-picker">
|
<DatePicker
|
||||||
<div className="datetime-display" onClick={(e) => {
|
showTime
|
||||||
e.stopPropagation();
|
placeholder={placeholder}
|
||||||
setShowQuickSelect(!showQuickSelect);
|
value={value ? dayjs(value) : null}
|
||||||
}}>
|
onChange={(date) => onChange(date ? date.format('YYYY-MM-DD HH:mm:ss') : null)}
|
||||||
<Calendar size={18} />
|
style={{ width: '100%' }}
|
||||||
<span className={`display-text ${(!date && !time) ? 'placeholder' : ''}`}>
|
size="large"
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { Input } from 'antd';
|
||||||
import { Search } from 'lucide-react';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const ExpandSearchBox = ({ onSearch, placeholder = "搜索会议..." }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<Input.Search
|
||||||
className={`expand-search-box ${isExpanded ? 'expanded' : ''}`}
|
placeholder={placeholder}
|
||||||
onClick={() => !isExpanded && setIsExpanded(true)}
|
onSearch={onSearch}
|
||||||
>
|
style={{ width: 300 }}
|
||||||
{showIcon && <Search size={18} className="search-icon" />}
|
allowClear
|
||||||
{isExpanded ? (
|
enterButton
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 (
|
return (
|
||||||
<header className="app-header">
|
<AntdHeader style={{
|
||||||
<h1>iMeeting (慧会议)</h1>
|
background: '#fff',
|
||||||
</header>
|
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 CodeMirror from '@uiw/react-codemirror';
|
||||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
import { EditorView } from '@codemirror/view';
|
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 MarkdownRenderer from './MarkdownRenderer';
|
||||||
import './MarkdownEditor.css';
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
const MarkdownEditor = ({
|
const MarkdownEditor = ({
|
||||||
value,
|
value,
|
||||||
|
|
@ -16,45 +28,37 @@ const MarkdownEditor = ({
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const imageInputRef = useRef(null);
|
const imageInputRef = useRef(null);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [showHeadingMenu, setShowHeadingMenu] = useState(false);
|
|
||||||
|
|
||||||
// CodeMirror extensions
|
|
||||||
const editorExtensions = useMemo(() => [
|
const editorExtensions = useMemo(() => [
|
||||||
markdown({ base: markdownLanguage }),
|
markdown({ base: markdownLanguage }),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
"&": {
|
"&": {
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
border: "2px solid #e2e8f0",
|
border: "1px solid #d9d9d9",
|
||||||
borderRadius: "0 0 8px 8px",
|
borderRadius: "0 0 8px 8px",
|
||||||
borderTop: "none",
|
borderTop: "none",
|
||||||
},
|
},
|
||||||
".cm-content": {
|
".cm-content": {
|
||||||
fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace",
|
fontFamily: "var(--ant-font-family-code), monospace",
|
||||||
padding: "1rem",
|
padding: "16px",
|
||||||
minHeight: `${height}px`,
|
minHeight: `${height}px`,
|
||||||
},
|
},
|
||||||
".cm-scroller": {
|
|
||||||
fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace",
|
|
||||||
},
|
|
||||||
"&.cm-focused": {
|
"&.cm-focused": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
borderColor: "#667eea",
|
borderColor: "#1677ff",
|
||||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.1)",
|
boxShadow: "0 0 0 2px rgba(22, 119, 255, 0.1)",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
], [height]);
|
], [height]);
|
||||||
|
|
||||||
// Markdown 插入函数
|
|
||||||
const insertMarkdown = (before, after = '', placeholder = '') => {
|
const insertMarkdown = (before, after = '', placeholder = '') => {
|
||||||
if (!editorRef.current?.view) return;
|
if (!editorRef.current?.view) return;
|
||||||
|
|
||||||
const view = editorRef.current.view;
|
const view = editorRef.current.view;
|
||||||
const selection = view.state.selection.main;
|
const selection = view.state.selection.main;
|
||||||
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
||||||
const text = selectedText || placeholder;
|
const text = selectedText || placeholder;
|
||||||
const newText = `${before}${text}${after}`;
|
const newText = `${before}${text}${after}`;
|
||||||
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: selection.from, to: selection.to, insert: newText },
|
changes: { from: selection.from, to: selection.to, insert: newText },
|
||||||
selection: { anchor: selection.from + before.length, head: selection.from + before.length + text.length }
|
selection: { anchor: selection.from + before.length, head: selection.from + before.length + text.length }
|
||||||
|
|
@ -62,139 +66,75 @@ const MarkdownEditor = ({
|
||||||
view.focus();
|
view.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 工具栏操作
|
|
||||||
const toolbarActions = {
|
const toolbarActions = {
|
||||||
bold: () => insertMarkdown('**', '**', '粗体文字'),
|
bold: () => insertMarkdown('**', '**', '粗体'),
|
||||||
italic: () => insertMarkdown('*', '*', '斜体文字'),
|
italic: () => insertMarkdown('*', '*', '斜体'),
|
||||||
heading: (level) => {
|
heading: (level) => insertMarkdown('#'.repeat(level) + ' ', '', '标题'),
|
||||||
setShowHeadingMenu(false);
|
quote: () => insertMarkdown('> ', '', '引用'),
|
||||||
insertMarkdown('#'.repeat(level) + ' ', '', '标题');
|
|
||||||
},
|
|
||||||
quote: () => insertMarkdown('> ', '', '引用内容'),
|
|
||||||
code: () => insertMarkdown('`', '`', '代码'),
|
code: () => insertMarkdown('`', '`', '代码'),
|
||||||
codeBlock: () => insertMarkdown('```\n', '\n```', '代码块'),
|
link: () => insertMarkdown('[', '](url)', '链接'),
|
||||||
link: () => insertMarkdown('[', '](url)', '链接文字'),
|
|
||||||
unorderedList: () => insertMarkdown('- ', '', '列表项'),
|
unorderedList: () => insertMarkdown('- ', '', '列表项'),
|
||||||
orderedList: () => insertMarkdown('1. ', '', '列表项'),
|
orderedList: () => insertMarkdown('1. ', '', '列表项'),
|
||||||
table: () => {
|
table: () => insertMarkdown('\n| 列1 | 列2 |\n| --- | --- |\n| 单元格 | 单元格 |\n', '', ''),
|
||||||
const tableTemplate = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 单元格 | 单元格 | 单元格 |\n| 单元格 | 单元格 | 单元格 |\n';
|
|
||||||
insertMarkdown(tableTemplate, '', '');
|
|
||||||
},
|
|
||||||
hr: () => insertMarkdown('\n---\n', '', ''),
|
hr: () => insertMarkdown('\n---\n', '', ''),
|
||||||
image: () => imageInputRef.current?.click(),
|
image: () => imageInputRef.current?.click(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 图片上传处理
|
const headingMenu = {
|
||||||
const handleImageSelect = async (event) => {
|
items: [1, 2, 3, 4, 5, 6].map(level => ({
|
||||||
const file = event.target.files[0];
|
key: level,
|
||||||
if (file && onImageUpload) {
|
label: `标题 ${level}`,
|
||||||
const imageUrl = await onImageUpload(file);
|
onClick: () => toolbarActions.heading(level)
|
||||||
if (imageUrl) {
|
}))
|
||||||
insertMarkdown(``, '', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reset file input
|
|
||||||
if (imageInputRef.current) {
|
|
||||||
imageInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="markdown-editor-wrapper">
|
<div className="markdown-editor-modern">
|
||||||
<div className="editor-toolbar">
|
<Card
|
||||||
<button type="button" className="toolbar-btn" onClick={toolbarActions.bold} title="粗体 (Ctrl+B)">
|
size="small"
|
||||||
<strong>B</strong>
|
bodyStyle={{ padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' }}
|
||||||
</button>
|
bordered={false}
|
||||||
<button type="button" className="toolbar-btn" onClick={toolbarActions.italic} title="斜体 (Ctrl+I)">
|
>
|
||||||
<em>I</em>
|
<Space split={<Divider type="vertical" />} size={4}>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
{/* 多级标题下拉菜单 */}
|
<Space size={2}>
|
||||||
<div className="toolbar-dropdown">
|
<Tooltip title="无序列表"><Button type="text" size="small" icon={<UnorderedListOutlined />} onClick={toolbarActions.unorderedList} /></Tooltip>
|
||||||
<button
|
<Tooltip title="有序列表"><Button type="text" size="small" icon={<OrderedListOutlined />} onClick={toolbarActions.orderedList} /></Tooltip>
|
||||||
type="button"
|
<Tooltip title="分隔线"><Button type="text" size="small" icon={<LineOutlined />} onClick={toolbarActions.hr} /></Tooltip>
|
||||||
className="toolbar-btn"
|
</Space>
|
||||||
onClick={() => setShowHeadingMenu(!showHeadingMenu)}
|
|
||||||
title="标题"
|
<Button
|
||||||
|
type={showPreview ? "primary" : "text"}
|
||||||
|
size="small"
|
||||||
|
icon={showPreview ? <EditOutlined /> : <EyeOutlined />}
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
>
|
>
|
||||||
H ▾
|
{showPreview ? "编辑" : "预览"}
|
||||||
</button>
|
</Button>
|
||||||
{showHeadingMenu && (
|
</Space>
|
||||||
<div className="dropdown-menu">
|
</Card>
|
||||||
<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 ? (
|
{showPreview ? (
|
||||||
<MarkdownRenderer
|
<Card bordered bodyStyle={{ padding: 16, minHeight: height, overflowY: 'auto' }} style={{ borderRadius: '0 0 8px 8px' }}>
|
||||||
content={value}
|
<MarkdownRenderer content={value} />
|
||||||
className="markdown-preview"
|
</Card>
|
||||||
emptyMessage="*暂无内容*"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|
@ -202,24 +142,17 @@ const MarkdownEditor = ({
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
extensions={editorExtensions}
|
extensions={editorExtensions}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
basicSetup={{
|
basicSetup={{ lineNumbers: false, foldGutter: false }}
|
||||||
lineNumbers: false,
|
|
||||||
foldGutter: false,
|
|
||||||
highlightActiveLineGutter: false,
|
|
||||||
highlightActiveLine: false,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showImageUpload && (
|
<input ref={imageInputRef} type="file" accept="image/*" onChange={(e) => {
|
||||||
<input
|
const file = e.target.files[0];
|
||||||
ref={imageInputRef}
|
if (file && onImageUpload) {
|
||||||
type="file"
|
onImageUpload(file).then(url => url && insertMarkdown(``, '', ''));
|
||||||
accept="image/*"
|
}
|
||||||
onChange={handleImageSelect}
|
e.target.value = '';
|
||||||
style={{ display: 'none' }}
|
}} style={{ display: 'none' }} />
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import { Typography, Empty } from 'antd';
|
||||||
import './MarkdownRenderer.css';
|
|
||||||
|
|
||||||
/**
|
const { Paragraph } = Typography;
|
||||||
* 统一的Markdown渲染组件
|
|
||||||
*
|
const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内容" }) => {
|
||||||
* @param {string} content - Markdown内容
|
if (!content) {
|
||||||
* @param {string} className - 自定义CSS类名(可选)
|
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />;
|
||||||
* @param {string} emptyMessage - 内容为空时显示的消息(可选)
|
|
||||||
*/
|
|
||||||
const MarkdownRenderer = ({ content, className = '', emptyMessage = '暂无内容' }) => {
|
|
||||||
if (!content || content.trim() === '') {
|
|
||||||
return <div className="markdown-empty">{emptyMessage}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`markdown-renderer ${className}`}>
|
<div className={`markdown-renderer-modern ${className}`} style={{ fontSize: '15px', lineHeight: 1.8, color: '#262626' }}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
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}
|
{content}
|
||||||
</ReactMarkdown>
|
</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 React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Clock, Users, FileText, User, Edit, Calendar , Trash2, MoreVertical } from 'lucide-react';
|
import {
|
||||||
import TagDisplay from './TagDisplay';
|
App,
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
Avatar,
|
||||||
import Dropdown from './Dropdown';
|
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 MarkdownRenderer from './MarkdownRenderer';
|
||||||
import tools from '../utils/tools';
|
import tools from '../utils/tools';
|
||||||
import './MeetingTimeline.css';
|
|
||||||
|
|
||||||
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false, filterType = 'all', searchQuery = '', selectedTags = [] }) => {
|
const { Title, Text, Paragraph } = Typography;
|
||||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
|
||||||
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const shouldShowMoreButton = (summary, maxLines = 3, maxLength = 100) => {
|
const handleEditClick = (event, meetingId) => {
|
||||||
if (!summary) return false;
|
event.preventDefault();
|
||||||
const lines = summary.split('\n');
|
event.stopPropagation();
|
||||||
return lines.length > maxLines || summary.length > maxLength;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditClick = (meetingId) => {
|
|
||||||
navigate(`/meetings/edit/${meetingId}`);
|
navigate(`/meetings/edit/${meetingId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (meeting) => {
|
const handleDeleteClick = (event, meeting) => {
|
||||||
setDeleteConfirmInfo({
|
event.preventDefault();
|
||||||
id: meeting.meeting_id,
|
event.stopPropagation();
|
||||||
title: meeting.title
|
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));
|
const sortedDates = Object.keys(meetingsByDate).sort((a, b) => new Date(b) - new Date(a));
|
||||||
|
|
||||||
if (sortedDates.length === 0) {
|
const timelineItems = sortedDates.map((date) => {
|
||||||
return (
|
const dateMeta = formatDateMeta(date);
|
||||||
<div className="timeline-empty">
|
return {
|
||||||
<Calendar size={48} />
|
label: (
|
||||||
<h3>暂无会议记录</h3>
|
<div className="timeline-date-label">
|
||||||
<p>您还没有参与任何会议</p>
|
<Text className="timeline-date-main">{dateMeta.main}</Text>
|
||||||
</div>
|
{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 (
|
return (
|
||||||
<div className="timeline-container">
|
<div className="modern-timeline">
|
||||||
<div className="timeline-line"></div>
|
<Timeline mode="left" items={timelineItems} />
|
||||||
{sortedDates.map(date => (
|
<div style={{ textAlign: 'center', marginTop: 28 }}>
|
||||||
<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">
|
|
||||||
{hasMore ? (
|
{hasMore ? (
|
||||||
<button
|
<Button onClick={onLoadMore} loading={loadingMore} icon={<CalendarOutlined />}>
|
||||||
className="load-more-btn"
|
加载更多
|
||||||
onClick={onLoadMore}
|
</Button>
|
||||||
disabled={loadingMore}
|
|
||||||
>
|
|
||||||
<span>{loadingMore ? '加载中...' : '加载更多'}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
sortedDates.length > 0 && (
|
<Divider plain><Text type="secondary">已加载全部会议</Text></Divider>
|
||||||
<div className="all-loaded">
|
|
||||||
<span>已加载全部会议</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 删除会议确认对话框 */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={!!deleteConfirmInfo}
|
|
||||||
onClose={() => setDeleteConfirmInfo(null)}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
title="删除会议"
|
|
||||||
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
|
||||||
confirmText="删除"
|
|
||||||
cancelText="取消"
|
|
||||||
type="danger"
|
|
||||||
/>
|
|
||||||
</div>
|
</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 { Transformer } from 'markmap-lib';
|
||||||
import { Markmap } from 'markmap-view';
|
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';
|
||||||
|
|
||||||
/**
|
const transformer = new Transformer();
|
||||||
* 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 MindMap = ({ content, title }) => {
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const markmapRef = useRef(null);
|
const markmapRef = useRef(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (content) {
|
if (!content || !svgRef.current) return;
|
||||||
setMarkdown(content);
|
|
||||||
setLoading(false);
|
setLoading(true);
|
||||||
} else {
|
try {
|
||||||
setMarkdown('# 暂无内容\n\n等待内容生成后查看思维导图。');
|
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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
// 提取关键短语的函数
|
const handleFit = () => markmapRef.current?.fit();
|
||||||
const extractKeyPhrases = (text) => {
|
const handleZoomIn = () => markmapRef.current?.rescale(1.2);
|
||||||
// 移除markdown格式
|
const handleZoomOut = () => markmapRef.current?.rescale(0.8);
|
||||||
const cleanText = text.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|
||||||
|
|
||||||
// 按标点符号分割
|
if (!content) return <Empty description="暂无内容,无法生成思维导图" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mindmap-container">
|
<div className="mindmap-container" style={{ position: 'relative', width: '100%', height: '100%', minHeight: 500 }}>
|
||||||
<div className="markmap-render-area">
|
{loading && (
|
||||||
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<svg ref={svgRef} style={{ width: '100%', height: '100%', minHeight: 500 }} />
|
||||||
</div>
|
</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 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 { QRCodeSVG } from 'qrcode.react';
|
||||||
import './QRCodeModal.css';
|
|
||||||
|
|
||||||
/**
|
const { Text, Paragraph } = Typography;
|
||||||
* QRCodeModal - 二维码分享模态框组件
|
|
||||||
*
|
const QRCodeModal = ({ open, onClose, url, title = "扫描二维码分享" }) => {
|
||||||
* @param {Object} props
|
const { message } = App.useApp();
|
||||||
* @param {boolean} props.isOpen - 是否显示模态框
|
|
||||||
* @param {Function} props.onClose - 关闭模态框的回调函数
|
const handleCopy = () => {
|
||||||
* @param {string} props.url - 二维码指向的URL
|
navigator.clipboard.writeText(url);
|
||||||
* @param {string} props.title - 显示的标题文本
|
message.success('链接已复制到剪贴板');
|
||||||
* @param {string} props.description - 描述文本(可选)
|
};
|
||||||
* @param {number} props.size - 二维码尺寸(可选,默认256)
|
|
||||||
*/
|
|
||||||
const QRCodeModal = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
description = '扫描二维码访问',
|
|
||||||
size = 256
|
|
||||||
}) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="qr-modal-overlay" onClick={onClose}>
|
<Modal
|
||||||
<div className="qr-modal" onClick={(e) => e.stopPropagation()}>
|
title={<Space><QrcodeOutlined /> {title}</Space>}
|
||||||
<div className="modal-header">
|
open={open}
|
||||||
<h3>
|
onCancel={onClose}
|
||||||
<QrCode size={20} /> 分享会议
|
footer={[
|
||||||
</h3>
|
<Button key="copy" icon={<CopyOutlined />} onClick={handleCopy}>复制链接</Button>,
|
||||||
<button
|
<Button key="close" type="primary" icon={<CheckOutlined />} className="btn-soft-green" onClick={onClose}>完成</Button>
|
||||||
className="close-btn"
|
]}
|
||||||
onClick={onClose}
|
width={400}
|
||||||
aria-label="关闭"
|
centered
|
||||||
>
|
destroyOnHidden
|
||||||
<X size={20} />
|
>
|
||||||
</button>
|
<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>
|
||||||
|
<Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 0 }}>
|
||||||
<div className="qr-modal-content">
|
微信或浏览器扫码,即可在移动端查看
|
||||||
<div className="qr-code-container">
|
</Paragraph>
|
||||||
<QRCodeSVG
|
<div style={{ marginTop: 12, background: '#f5f5f5', padding: '8px 12px', borderRadius: 6, textAlign: 'left' }}>
|
||||||
value={url}
|
<Text code ellipsis style={{ width: '100%', display: 'block' }}>{url}</Text>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</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 React, { useState, useEffect } from 'react';
|
||||||
import { ArrowUp } from 'lucide-react';
|
import { FloatButton } from 'antd';
|
||||||
import './ScrollToTop.css';
|
import { VerticalAlignTopOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
const ScrollToTop = ({ showAfter = 300 }) => {
|
const ScrollToTop = () => {
|
||||||
const [showButton, setShowButton] = useState(false);
|
return <FloatButton.BackTop icon={<VerticalAlignTopOutlined />} visibilityHeight={400} />;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ScrollToTop;
|
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 React from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { Input } from 'antd';
|
||||||
import './SimpleSearchInput.css';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
const SimpleSearchInput = ({ value, onChange, placeholder = "搜索..." }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`simple-search-input ${className}`}>
|
<Input
|
||||||
<input
|
placeholder={placeholder}
|
||||||
type="text"
|
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
placeholder={placeholder}
|
value={value}
|
||||||
value={localValue}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onChange={handleInputChange}
|
allowClear
|
||||||
className="simple-search-input-field"
|
size="large"
|
||||||
/>
|
style={{ borderRadius: 8 }}
|
||||||
{localValue && (
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="simple-search-clear-btn"
|
|
||||||
onClick={handleClear}
|
|
||||||
aria-label="清除搜索"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||