增加了终端管理模块
parent
f99f96e65f
commit
56a7d33cb8
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -82,10 +82,11 @@ async def get_dict_types():
|
|||
|
||||
|
||||
@router.get("/dict/{dict_type}", response_model=dict)
|
||||
async def get_dict_data_by_type(dict_type: str):
|
||||
async def get_dict_data_by_type(dict_type: str, parent_code: Optional[str] = None):
|
||||
"""
|
||||
获取指定类型的所有码表数据(公开接口)
|
||||
支持树形结构
|
||||
可选参数:parent_code 筛选特定父节点的子项(此时不返回树结构,只返回平铺列表)
|
||||
|
||||
参数:
|
||||
dict_type: 字典类型,如 'client_platform'
|
||||
|
|
@ -100,9 +101,16 @@ async def get_dict_data_by_type(dict_type: str):
|
|||
is_default, status, create_time
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND status = 1
|
||||
ORDER BY parent_code, sort_order, dict_code
|
||||
"""
|
||||
cursor.execute(query, (dict_type,))
|
||||
params = [dict_type]
|
||||
|
||||
if parent_code:
|
||||
query += " AND parent_code = %s"
|
||||
params.append(parent_code)
|
||||
|
||||
query += " ORDER BY parent_code, sort_order, dict_code"
|
||||
|
||||
cursor.execute(query, params)
|
||||
items = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
|
|
@ -114,6 +122,17 @@ async def get_dict_data_by_type(dict_type: str):
|
|||
except:
|
||||
item['extension_attr'] = {}
|
||||
|
||||
# 如果指定了parent_code,只返回平铺列表
|
||||
if parent_code:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"items": items,
|
||||
"tree": []
|
||||
}
|
||||
)
|
||||
|
||||
# 构建树形结构
|
||||
tree_data = []
|
||||
nodes_map = {}
|
||||
|
|
@ -128,12 +147,12 @@ async def get_dict_data_by_type(dict_type: str):
|
|||
# 第二遍:构建树形关系
|
||||
for item in items:
|
||||
node = nodes_map[item['dict_code']]
|
||||
parent_code = item['parent_code']
|
||||
parent_code_val = item['parent_code']
|
||||
|
||||
if parent_code == 'ROOT':
|
||||
if parent_code_val == 'ROOT':
|
||||
tree_data.append(node)
|
||||
elif parent_code in nodes_map:
|
||||
nodes_map[parent_code]['children'].append(node)
|
||||
elif parent_code_val in nodes_map:
|
||||
nodes_map[parent_code_val]['children'].append(node)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_user, get_current_admin_user
|
||||
from app.core.auth import get_optional_current_user, get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.config import BASE_DIR, EXTERNAL_APPS_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE
|
||||
from app.utils.apk_parser import parse_apk_with_androguard
|
||||
|
|
@ -44,7 +44,7 @@ class UpdateExternalAppRequest(BaseModel):
|
|||
async def get_external_apps(
|
||||
app_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取外部应用列表(管理后台接口)
|
||||
|
|
@ -107,10 +107,19 @@ async def get_external_apps(
|
|||
|
||||
|
||||
@router.get("/external-apps/active", response_model=dict)
|
||||
async def get_active_external_apps():
|
||||
async def get_active_external_apps(current_user: Optional[dict] = Depends(get_optional_current_user)):
|
||||
"""
|
||||
获取所有启用的外部应用(公开接口,供客户端调用)
|
||||
未登录返回空列表
|
||||
"""
|
||||
# 如果未登录,返回空列表
|
||||
if not current_user:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="未登录",
|
||||
data=[]
|
||||
)
|
||||
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
|
@ -134,18 +143,10 @@ async def get_active_external_apps():
|
|||
|
||||
cursor.close()
|
||||
|
||||
# 按类型分组
|
||||
native_apps = [app for app in apps if app['app_type'] == 'native']
|
||||
web_apps = [app for app in apps if app['app_type'] == 'web']
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"native": native_apps,
|
||||
"web": web_apps,
|
||||
"all": apps
|
||||
}
|
||||
data=apps
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import Optional
|
||||
import traceback
|
||||
from app.core.auth import get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.models.models import CreateTerminalRequest, UpdateTerminalRequest
|
||||
from app.services.terminal_service import terminal_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/terminals", response_model=dict)
|
||||
async def get_terminals(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=10000),
|
||||
keyword: Optional[str] = None,
|
||||
terminal_type: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取终端设备列表(分页)
|
||||
"""
|
||||
try:
|
||||
terminals, total = terminal_service.get_terminals(
|
||||
page=page,
|
||||
size=size,
|
||||
keyword=keyword,
|
||||
terminal_type=terminal_type,
|
||||
status=status
|
||||
)
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"items": terminals,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取终端列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/terminals", response_model=dict)
|
||||
async def create_terminal(
|
||||
request: CreateTerminalRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
创建新终端设备
|
||||
"""
|
||||
try:
|
||||
# 检查IMEI是否存在
|
||||
existing = terminal_service.get_terminal_by_imei(request.imei)
|
||||
if existing:
|
||||
return create_api_response(code="400", message=f"IMEI {request.imei} 已存在")
|
||||
|
||||
terminal_id = terminal_service.create_terminal(request, current_user['user_id'])
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="创建成功",
|
||||
data={"id": terminal_id}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"创建终端失败: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/terminals/{terminal_id}", response_model=dict)
|
||||
async def get_terminal_detail(
|
||||
terminal_id: int,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取终端设备详情
|
||||
"""
|
||||
try:
|
||||
terminal = terminal_service.get_terminal_by_id(terminal_id)
|
||||
if not terminal:
|
||||
return create_api_response(code="404", message="终端不存在")
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data=terminal
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取终端详情失败: {str(e)}"
|
||||
)
|
||||
|
||||
@router.put("/terminals/{terminal_id}", response_model=dict)
|
||||
async def update_terminal(
|
||||
terminal_id: int,
|
||||
request: UpdateTerminalRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
更新终端设备信息
|
||||
"""
|
||||
try:
|
||||
# 检查是否存在
|
||||
existing = terminal_service.get_terminal_by_id(terminal_id)
|
||||
if not existing:
|
||||
return create_api_response(code="404", message="终端不存在")
|
||||
|
||||
success = terminal_service.update_terminal(terminal_id, request)
|
||||
if success:
|
||||
return create_api_response(code="200", message="更新成功")
|
||||
else:
|
||||
return create_api_response(code="400", message="没有需要更新的字段")
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"更新终端失败: {str(e)}"
|
||||
)
|
||||
|
||||
@router.delete("/terminals/{terminal_id}", response_model=dict)
|
||||
async def delete_terminal(
|
||||
terminal_id: int,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
删除终端设备
|
||||
"""
|
||||
try:
|
||||
success = terminal_service.delete_terminal(terminal_id)
|
||||
if success:
|
||||
return create_api_response(code="200", message="删除成功")
|
||||
else:
|
||||
return create_api_response(code="404", message="终端不存在")
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"删除终端失败: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/terminals/{terminal_id}/status", response_model=dict)
|
||||
async def toggle_terminal_status(
|
||||
terminal_id: int,
|
||||
status: int = Query(..., description="1:启用, 0:停用"),
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
切换终端启用/停用状态
|
||||
"""
|
||||
try:
|
||||
if status not in [0, 1]:
|
||||
return create_api_response(code="400", message="状态值无效")
|
||||
|
||||
success = terminal_service.set_terminal_status(terminal_id, status)
|
||||
if success:
|
||||
return create_api_response(code="200", message="状态更新成功")
|
||||
else:
|
||||
return create_api_response(code="404", message="终端不存在")
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"状态更新失败: {str(e)}"
|
||||
)
|
||||
|
|
@ -14,7 +14,7 @@ from fastapi import FastAPI, Request, HTTPException
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps
|
||||
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.core.config import UPLOAD_DIR, API_CONFIG
|
||||
|
||||
app = FastAPI(
|
||||
|
|
@ -54,6 +54,7 @@ app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
|
|||
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
|
||||
app.include_router(audio.router, prefix="/api", tags=["Audio"])
|
||||
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
|
||||
app.include_router(terminals.router, prefix="/api", tags=["Terminals"])
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def custom_swagger_ui_html():
|
||||
|
|
|
|||
|
|
@ -274,3 +274,38 @@ class RolePermissionInfo(BaseModel):
|
|||
|
||||
class UpdateRolePermissionsRequest(BaseModel):
|
||||
menu_ids: List[int]
|
||||
|
||||
# 专用终端设备模型
|
||||
class Terminal(BaseModel):
|
||||
id: int
|
||||
imei: str
|
||||
terminal_name: Optional[str] = None
|
||||
terminal_type: str
|
||||
terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取)
|
||||
description: Optional[str] = None
|
||||
status: int # 1: 启用, 0: 停用
|
||||
is_activated: int # 1: 已激活, 0: 未激活
|
||||
activated_at: Optional[datetime.datetime] = None
|
||||
firmware_version: Optional[str] = None
|
||||
last_online_at: Optional[datetime.datetime] = None
|
||||
ip_address: Optional[str] = None
|
||||
mac_address: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
created_by: Optional[int] = None
|
||||
creator_username: Optional[str] = None
|
||||
|
||||
class CreateTerminalRequest(BaseModel):
|
||||
imei: str
|
||||
terminal_name: Optional[str] = None
|
||||
terminal_type: str
|
||||
description: Optional[str] = None
|
||||
status: int = 1
|
||||
|
||||
class UpdateTerminalRequest(BaseModel):
|
||||
terminal_name: Optional[str] = None
|
||||
terminal_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
firmware_version: Optional[str] = None
|
||||
mac_address: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
from typing import List, Optional, Tuple, Dict, Any
|
||||
from app.core.database import get_db_connection
|
||||
from app.models.models import Terminal, CreateTerminalRequest, UpdateTerminalRequest
|
||||
import datetime
|
||||
|
||||
class TerminalService:
|
||||
def get_terminals(self,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
keyword: Optional[str] = None,
|
||||
terminal_type: Optional[str] = None,
|
||||
status: Optional[int] = None) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""
|
||||
获取终端列表,支持分页和筛选
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if keyword:
|
||||
where_clauses.append("(t.imei LIKE %s OR t.terminal_name LIKE %s)")
|
||||
keyword_param = f"%{keyword}%"
|
||||
params.extend([keyword_param, keyword_param])
|
||||
|
||||
if terminal_type:
|
||||
where_clauses.append("t.terminal_type = %s")
|
||||
params.append(terminal_type)
|
||||
|
||||
if status is not None:
|
||||
where_clauses.append("t.status = %s")
|
||||
params.append(status)
|
||||
|
||||
where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
# 计算总数
|
||||
count_query = f"SELECT COUNT(*) as total FROM terminals t WHERE {where_clause}"
|
||||
cursor.execute(count_query, params)
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# 查询列表
|
||||
offset = (page - 1) * size
|
||||
list_query = f"""
|
||||
SELECT
|
||||
t.*,
|
||||
u.username as creator_username,
|
||||
dd.label_cn as terminal_type_name
|
||||
FROM terminals t
|
||||
LEFT JOIN users u ON t.created_by = u.user_id
|
||||
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||
WHERE {where_clause}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cursor.execute(list_query, params + [size, offset])
|
||||
terminals = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
return terminals, total
|
||||
|
||||
def get_terminal_by_id(self, terminal_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据ID获取终端详情
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
t.*,
|
||||
u.username as creator_username,
|
||||
dd.label_cn as terminal_type_name
|
||||
FROM terminals t
|
||||
LEFT JOIN users u ON t.created_by = u.user_id
|
||||
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||
WHERE t.id = %s
|
||||
"""
|
||||
cursor.execute(query, (terminal_id,))
|
||||
terminal = cursor.fetchone()
|
||||
|
||||
cursor.close()
|
||||
return terminal
|
||||
|
||||
def get_terminal_by_imei(self, imei: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据IMEI获取终端详情
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM terminals WHERE imei = %s", (imei,))
|
||||
terminal = cursor.fetchone()
|
||||
cursor.close()
|
||||
return terminal
|
||||
|
||||
def create_terminal(self, terminal_data: CreateTerminalRequest, user_id: int) -> int:
|
||||
"""
|
||||
创建新终端
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
INSERT INTO terminals (
|
||||
imei, terminal_name, terminal_type, description, status, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(query, (
|
||||
terminal_data.imei,
|
||||
terminal_data.terminal_name,
|
||||
terminal_data.terminal_type,
|
||||
terminal_data.description,
|
||||
terminal_data.status,
|
||||
user_id
|
||||
))
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return new_id
|
||||
|
||||
def update_terminal(self, terminal_id: int, terminal_data: UpdateTerminalRequest) -> bool:
|
||||
"""
|
||||
更新终端信息
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
if terminal_data.terminal_name is not None:
|
||||
update_fields.append("terminal_name = %s")
|
||||
params.append(terminal_data.terminal_name)
|
||||
|
||||
if terminal_data.terminal_type is not None:
|
||||
update_fields.append("terminal_type = %s")
|
||||
params.append(terminal_data.terminal_type)
|
||||
|
||||
if terminal_data.description is not None:
|
||||
update_fields.append("description = %s")
|
||||
params.append(terminal_data.description)
|
||||
|
||||
if terminal_data.status is not None:
|
||||
update_fields.append("status = %s")
|
||||
params.append(terminal_data.status)
|
||||
|
||||
if terminal_data.firmware_version is not None:
|
||||
update_fields.append("firmware_version = %s")
|
||||
params.append(terminal_data.firmware_version)
|
||||
|
||||
if terminal_data.mac_address is not None:
|
||||
update_fields.append("mac_address = %s")
|
||||
params.append(terminal_data.mac_address)
|
||||
|
||||
if not update_fields:
|
||||
return False
|
||||
|
||||
query = f"UPDATE terminals SET {', '.join(update_fields)} WHERE id = %s"
|
||||
params.append(terminal_id)
|
||||
|
||||
cursor.execute(query, params)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
|
||||
def delete_terminal(self, terminal_id: int) -> bool:
|
||||
"""
|
||||
删除终端
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM terminals WHERE id = %s", (terminal_id,))
|
||||
deleted = cursor.rowcount > 0
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return deleted
|
||||
|
||||
def set_terminal_status(self, terminal_id: int, status: int) -> bool:
|
||||
"""
|
||||
设置终端启用/停用状态
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE terminals SET status = %s WHERE id = %s", (status, terminal_id))
|
||||
updated = cursor.rowcount > 0
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return updated
|
||||
|
||||
terminal_service = TerminalService()
|
||||
|
|
@ -1,59 +1,29 @@
|
|||
-- 添加专用终端类型支持
|
||||
-- 修改 platform_type 枚举,添加 'terminal' 类型
|
||||
|
||||
ALTER TABLE client_downloads
|
||||
MODIFY COLUMN platform_type ENUM('mobile', 'desktop', 'terminal') NOT NULL
|
||||
COMMENT '平台类型:mobile-移动端, desktop-桌面端, terminal-专用终端';
|
||||
|
||||
-- 插入专用终端示例数据
|
||||
|
||||
-- Android 专用终端
|
||||
INSERT INTO client_downloads (
|
||||
platform_type,
|
||||
platform_name,
|
||||
version,
|
||||
version_code,
|
||||
download_url,
|
||||
file_size,
|
||||
release_notes,
|
||||
is_active,
|
||||
is_latest,
|
||||
min_system_version,
|
||||
created_by
|
||||
) VALUES
|
||||
(
|
||||
'terminal',
|
||||
'android',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/terminals/android/iMeeting-1.0.0-Terminal.apk',
|
||||
25165824, -- 24MB
|
||||
'专用终端初始版本
|
||||
- 支持专用硬件集成
|
||||
- 优化的录音功能
|
||||
- 低功耗模式
|
||||
- 自动上传同步',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Android 5.0',
|
||||
1
|
||||
),
|
||||
|
||||
-- 单片机(MCU)专用终端
|
||||
(
|
||||
'terminal',
|
||||
'mcu',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/terminals/mcu/iMeeting-1.0.0-MCU.bin',
|
||||
2097152, -- 2MB
|
||||
'单片机固件初始版本
|
||||
- 嵌入式录音系统
|
||||
- 低功耗设计
|
||||
- 支持WiFi/4G上传
|
||||
- 硬件级音频处理',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'ESP32 / STM32',
|
||||
1
|
||||
);
|
||||
-- 专用终端设备表
|
||||
CREATE TABLE IF NOT EXISTS `terminals` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`imei` varchar(64) NOT NULL COMMENT 'IMEI号(设备唯一标识)',
|
||||
`terminal_name` varchar(100) DEFAULT NULL COMMENT '终端名称/设备别名',
|
||||
`terminal_type` varchar(50) NOT NULL COMMENT '终端类型(关联dict_data.dict_code)',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '终端说明/备注',
|
||||
|
||||
-- 状态管理
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '启用状态: 1-启用, 0-停用',
|
||||
`is_activated` tinyint(1) NOT NULL DEFAULT '0' COMMENT '激活状态: 1-已激活, 0-未激活',
|
||||
`activated_at` datetime DEFAULT NULL COMMENT '激活时间',
|
||||
|
||||
-- 运维监控字段
|
||||
`firmware_version` varchar(50) DEFAULT NULL COMMENT '当前固件版本',
|
||||
`last_online_at` datetime DEFAULT NULL COMMENT '最后在线/心跳时间',
|
||||
`ip_address` varchar(50) DEFAULT NULL COMMENT '最近一次连接IP',
|
||||
`mac_address` varchar(64) DEFAULT NULL COMMENT 'MAC地址',
|
||||
|
||||
-- 审计字段
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` int(11) DEFAULT NULL COMMENT '录入人ID',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_imei` (`imei`),
|
||||
KEY `idx_terminal_type` (`terminal_type`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专用终端设备表';
|
||||
42
database.md
42
database.md
|
|
@ -20,6 +20,7 @@
|
|||
- **knowledge_bases_task**: 知识库生成任务表
|
||||
- **dict_data**: 字典/码表数据表
|
||||
- **client_downloads**: 客户端下载管理表
|
||||
- **terminals**: 专用终端设备表
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -309,14 +310,29 @@
|
|||
- `platform_code` 关联 `dict_data` 表的 `dict_code` 字段(client_platform类型)
|
||||
- 业务逻辑需确保:设置新最新版本时,自动取消同平台其他版本的最新状态
|
||||
|
||||
**相关API接口:**
|
||||
- `GET /api/clients/latest/by-platform` - 获取最新版本客户端
|
||||
- 支持两种调用方式(兼容新旧版本,返回数据结构一致):
|
||||
1. 旧版:传 `platform_type` 和 `platform_name` 参数
|
||||
2. 新版:传 `platform_code` 参数(推荐)
|
||||
- `POST /api/clients/upload` - 上传客户端安装包(管理员)
|
||||
- 自动解析APK文件的版本信息
|
||||
- 自动读取文件大小并生成下载URL
|
||||
### 2.18. `terminals` - 专用终端设备表
|
||||
|
||||
存储专用终端设备信息(如录音笔、会议平板等),用于设备激活管理和状态监控。
|
||||
|
||||
| 字段名 | 类型 | 约束 | 描述 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `id` | INT | PRIMARY KEY, AUTO_INCREMENT | 主键ID |
|
||||
| `imei` | VARCHAR(64) | NOT NULL, UNIQUE | IMEI号(设备唯一标识) |
|
||||
| `terminal_name` | VARCHAR(100) | NULL | 终端名称/设备别名 |
|
||||
| `terminal_type` | VARCHAR(50) | NOT NULL | 终端类型(关联 `dict_data.dict_code`) |
|
||||
| `description` | VARCHAR(500) | NULL | 终端说明/备注 |
|
||||
| `status` | TINYINT(1) | NOT NULL, DEFAULT 1 | 启用状态: 1-启用, 0-停用 |
|
||||
| `is_activated` | TINYINT(1) | NOT NULL, DEFAULT 0 | 激活状态: 1-已激活, 0-未激活 |
|
||||
| `activated_at` | DATETIME | NULL | 激活时间 |
|
||||
| `firmware_version` | VARCHAR(50) | NULL | 当前固件版本 |
|
||||
| `last_online_at` | DATETIME | NULL | 最后在线/心跳时间 |
|
||||
| `ip_address` | VARCHAR(50) | NULL | 最近一次连接IP |
|
||||
| `mac_address` | VARCHAR(64) | NULL | MAC地址 |
|
||||
| `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 录入时间 |
|
||||
| `updated_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE | 更新时间 |
|
||||
| `created_by` | INT | NULL, FK | 录入人ID(关联 `users` 表) |
|
||||
| | | KEY `idx_terminal_type` | 终端类型索引 |
|
||||
| | | KEY `idx_status` | 状态索引 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -399,6 +415,14 @@ erDiagram
|
|||
int progress
|
||||
}
|
||||
|
||||
dedicated_terminals {
|
||||
int id PK
|
||||
varchar(64) imei
|
||||
varchar(50) terminal_type
|
||||
tinyint status
|
||||
tinyint is_activated
|
||||
}
|
||||
|
||||
users ||--o{ meetings : "creates"
|
||||
users ||--o{ attendees : "attends"
|
||||
users }|..|| roles : "has role"
|
||||
|
|
@ -409,4 +433,4 @@ erDiagram
|
|||
meetings ||--|{ meeting_summaries : "has"
|
||||
meetings ||--|{ llm_tasks : "has"
|
||||
|
||||
```
|
||||
```
|
||||
|
|
@ -1,26 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#667eea"/>
|
||||
<stop offset="100%" stop-color="#764ba2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 背景圆 -->
|
||||
<circle cx="64" cy="64" r="60" fill="url(#bg)"/>
|
||||
|
||||
<!-- 三个人物圆圈 - 极简设计 -->
|
||||
<circle cx="42" cy="54" r="11" fill="#ffffff" opacity="0.95"/>
|
||||
<circle cx="64" cy="48" r="13" fill="#ffffff"/>
|
||||
<circle cx="86" cy="54" r="11" fill="#ffffff" opacity="0.95"/>
|
||||
|
||||
<!-- AI对话气泡 -->
|
||||
<g>
|
||||
<!-- 气泡主体 -->
|
||||
<rect x="36" y="74" width="56" height="26" rx="13" fill="#ffffff"/>
|
||||
<!-- 气泡尾巴 -->
|
||||
<path d="M 58 100 L 52 106 L 54 100 Z" fill="#ffffff"/>
|
||||
<!-- AI文字 -->
|
||||
<text x="52" y="91" fill="#667eea" font-size="16" font-weight="bold" font-family="system-ui, -apple-system, sans-serif">AI</text>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1768977569243" class="icon" viewBox="0 0 1089 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6145" xmlns:xlink="http://www.w3.org/1999/xlink" width="212.6953125" height="200"><path d="M893.180907 631.687443c12.784566 0 24.367382 7.849723 29.072103 19.73937l36.589427 92.918224c3.221711 8.130984 9.690701 14.523267 17.847254 17.642701l92.790379 36.589428a31.373325 31.373325 0 0 1 0 58.169774l-93.071639 36.563859c-8.003138 3.298418-14.318714 9.767408-17.438148 17.847253l-36.563858 92.815948a31.373325 31.373325 0 0 1-58.29762 0l-36.589428-92.994931a31.373325 31.373325 0 0 0-17.642701-17.642701l-93.071639-36.589428a31.245479 31.245479 0 0 1 0-58.169774l93.071639-36.563859c8.105415-3.24728 14.497698-9.71627 17.642701-17.872823l36.563859-92.713671a31.296617 31.296617 0 0 1 29.097671-19.73937zM882.058335 133.472913c30.146006 0 55.817414 10.585621 77.116501 31.884707 21.222379 21.222379 31.884707 46.919357 31.884707 76.963086v285.888461a43.314109 43.314109 0 0 1-74.303896 30.708528 43.314109 43.314109 0 0 1-12.733428-30.682958l-0.127845-260.31933c0-25.569132-23.191202-47.328463-47.328463-47.328463H301.587908c-47.302894 0-52.749119 13.602778-60.087459 20.966688-5.446225 5.420656-12.784566 25.569132-12.784566 51.905338v501.615225c0 14.523267 7.159357 21.759331 21.733762 21.759331h68.090597a43.467524 43.467524 0 0 1 43.59537 43.595369v37.382071l124.521671-74.764141c6.77582-4.091061 14.523267-6.238868 22.424128-6.213299h88.545903a43.314109 43.314109 0 0 1 30.708528 74.303896c-8.130984 8.182122-19.176849 12.758997-30.682958 12.733428l-76.451704 0.076707-180.262378 108.157427a43.467524 43.467524 0 0 1-65.96836-37.330932v-70.826495h-24.520797c-30.04373 0-55.740707-10.585621-76.963087-31.884707a104.935716 104.935716 0 0 1-31.884707-76.963086V242.295137c0-30.069299 10.585621-55.740707 31.884707-76.963086a104.935716 104.935716 0 0 1 76.963087-31.910276H882.135042h-0.076707z m-212.01924 418.7201a35.873492 35.873492 0 0 1 34.288206 46.868218l-2.147807 5.139396a134.493633 134.493633 0 0 1-53.950868 58.425466c-22.37299 12.733428-48.709196 19.15128-79.085324 19.151279-30.452836 0-56.84018-6.392283-79.187601-19.151279-23.088926-13.270379-41.115164-32.67735-53.976437-58.425466a35.541093 35.541093 0 0 1 2.301222-36.052476 35.873492 35.873492 0 0 1 62.056282 3.912077c5.420656 11.685093 14.242006 21.478071 25.287872 28.100476 11.301556 6.545698 25.824823 9.71627 43.467523 9.71627s32.217106-3.24728 43.493093-9.639563c8.693505-5.113826 16.006276-12.298752 21.22238-20.915549l4.142199-7.261634a35.745646 35.745646 0 0 1 32.08926-19.867215zM697.014529 0.027615a43.314109 43.314109 0 0 1 30.708527 74.278327c-8.130984 8.182122-19.176849 12.784566-30.682958 12.758997l-522.760897 0.051138c-24.111691 0-44.643704 8.514521-61.621607 25.492424a84.148012 84.148012 0 0 0-25.492424 61.672746V566.358312a43.314109 43.314109 0 0 1-74.303897 30.682958 43.314109 43.314109 0 0 1-12.733427-30.682958L0 174.281247c0-48.146675 16.977903-89.185131 51.035987-123.243215A167.938057 167.938057 0 0 1 174.279201 0.053184H697.014529zM435.646865 392.206956c24.034984-0.076707 43.518662 19.43254 43.467524 43.467524l0.051138 18.997865a43.314109 43.314109 0 0 1-74.303896 30.708527 43.314109 43.314109 0 0 1-12.68229-30.682958l-0.127845-19.023434a43.314109 43.314109 0 0 1 43.595369-43.467524z m266.78832 0c24.034984-0.076707 43.544231 19.43254 43.467524 43.467524l0.051138 18.997865a43.314109 43.314109 0 0 1-74.278327 30.708527 43.314109 43.314109 0 0 1-12.68229-30.682958l-0.127845-19.023434a43.314109 43.314109 0 0 1 43.5698-43.467524z" p-id="6146" fill="#6f42c1"></path></svg>
|
||||
|
Before Width: | Height: | Size: 951 B After Width: | Height: | Size: 3.6 KiB |
|
|
@ -104,6 +104,14 @@ const API_CONFIG = {
|
|||
TEMPLATE: '/api/voiceprint/template',
|
||||
UPLOAD: (userId) => `/api/voiceprint/${userId}`,
|
||||
DELETE: (userId) => `/api/voiceprint/${userId}`
|
||||
},
|
||||
TERMINALS: {
|
||||
LIST: '/api/terminals',
|
||||
CREATE: '/api/terminals',
|
||||
DETAIL: (id) => `/api/terminals/${id}`,
|
||||
UPDATE: (id) => `/api/terminals/${id}`,
|
||||
DELETE: (id) => `/api/terminals/${id}`,
|
||||
STATUS: (id) => `/api/terminals/${id}/status`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Settings, Users, Smartphone, Shield, BookText, Type, Package } from 'lucide-react';
|
||||
import { Settings, Users, Smartphone, Shield, BookText, Type, Package, Monitor } from 'lucide-react';
|
||||
import { Tabs } from 'antd';
|
||||
import UserManagement from './admin/UserManagement';
|
||||
import ClientManagement from './ClientManagement';
|
||||
import ExternalAppManagement from './admin/ExternalAppManagement';
|
||||
import TerminalManagement from './admin/TerminalManagement';
|
||||
import PermissionManagement from './admin/PermissionManagement';
|
||||
import DictManagement from './admin/DictManagement';
|
||||
import HotWordManagement from './admin/HotWordManagement';
|
||||
|
|
@ -44,6 +45,11 @@ const AdminManagement = () => {
|
|||
label: <span><Package size={16} /> 外部应用管理</span>,
|
||||
children: <ExternalAppManagement />,
|
||||
},
|
||||
{
|
||||
key: 'terminalManagement',
|
||||
label: <span><Monitor size={16} /> 终端管理</span>,
|
||||
children: <TerminalManagement />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@
|
|||
.search-box svg:first-child {
|
||||
color: #94a3b8;
|
||||
margin-right: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
.client-management .search-box {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
|
|
@ -98,19 +98,20 @@
|
|||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.search-box svg:first-child {
|
||||
.client-management .search-box svg:first-child {
|
||||
color: #94a3b8;
|
||||
margin-right: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
.client-management .search-box input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
.client-management .clear-search {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
|
|
@ -121,7 +122,7 @@
|
|||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-search:hover {
|
||||
.client-management .clear-search:hover {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,433 @@
|
|||
.terminal-management {
|
||||
padding: 1.5rem;
|
||||
background-color: #f8fafc;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 8px -1px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-group, .filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-management .search-box {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.terminal-management .search-box svg {
|
||||
color: #94a3b8;
|
||||
margin-right: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.terminal-management .search-box input {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-management .search-box input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.terminal-management .clear-search {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: static;
|
||||
transform: none;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-management .clear-search:hover {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clear-search:hover {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.625rem 2rem 0.625rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* Status Toggle */
|
||||
.status-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.toggle-switch.off {
|
||||
background-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Status Dot */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background-color: #10b981;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
color: #3b82f6;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
color: #ef4444;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem !important;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-controls button {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #475569;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-controls button:not(:disabled):hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal Form */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group select:disabled {
|
||||
background-color: #f1f5f9;
|
||||
cursor: not-allowed;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Power,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import Toast from '../../components/Toast';
|
||||
import PageLoading from '../../components/PageLoading';
|
||||
import FormModal from '../../components/FormModal';
|
||||
import './TerminalManagement.css';
|
||||
|
||||
const TerminalManagement = () => {
|
||||
const [terminals, setTerminals] = useState([]); // All terminals
|
||||
const [terminalTypes, setTerminalTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Local state for filtering/pagination
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedTerminal, setSelectedTerminal] = useState(null);
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
imei: '',
|
||||
terminal_name: '',
|
||||
terminal_type: '',
|
||||
description: '',
|
||||
status: 1
|
||||
});
|
||||
|
||||
// Toast helper
|
||||
const showToast = (message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
};
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTerminalTypes();
|
||||
fetchTerminals();
|
||||
}, []);
|
||||
|
||||
const fetchTerminalTypes = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
buildApiUrl(API_ENDPOINTS.DICT_DATA.BY_TYPE('client_platform')),
|
||||
{ params: { parent_code: 'TERMINAL' } }
|
||||
);
|
||||
if (response.code === '200') {
|
||||
setTerminalTypes(response.data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch terminal types:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTerminals = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch all terminals (using a large size)
|
||||
const params = {
|
||||
page: 1,
|
||||
size: 10000
|
||||
};
|
||||
|
||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TERMINALS.LIST), { params });
|
||||
if (response.code === '200') {
|
||||
setTerminals(response.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch terminals:', error);
|
||||
showToast('获取终端列表失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Local Filtering
|
||||
const filteredTerminals = terminals.filter(terminal => {
|
||||
const matchesKeyword = !keyword ||
|
||||
terminal.imei.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
(terminal.terminal_name && terminal.terminal_name.toLowerCase().includes(keyword.toLowerCase()));
|
||||
|
||||
const matchesType = !filterType || terminal.terminal_type === filterType;
|
||||
|
||||
const matchesStatus = filterStatus === '' || terminal.status === parseInt(filterStatus);
|
||||
|
||||
return matchesKeyword && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
// Local Pagination
|
||||
const total = filteredTerminals.length;
|
||||
const paginatedTerminals = filteredTerminals.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const handleReset = () => {
|
||||
setKeyword('');
|
||||
setFilterType('');
|
||||
setFilterStatus('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedTerminal(null);
|
||||
setFormData({
|
||||
imei: '',
|
||||
terminal_name: '',
|
||||
terminal_type: terminalTypes.length > 0 ? terminalTypes[0].dict_code : '',
|
||||
description: '',
|
||||
status: 1
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (terminal) => {
|
||||
setIsEditing(true);
|
||||
setSelectedTerminal(terminal);
|
||||
setFormData({
|
||||
imei: terminal.imei,
|
||||
terminal_name: terminal.terminal_name || '',
|
||||
terminal_type: terminal.terminal_type,
|
||||
description: terminal.description || '',
|
||||
status: terminal.status
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.imei) {
|
||||
showToast('请输入IMEI号', 'error');
|
||||
return;
|
||||
}
|
||||
if (!formData.terminal_type) {
|
||||
showToast('请选择终端类型', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
await apiClient.put(
|
||||
buildApiUrl(API_ENDPOINTS.TERMINALS.UPDATE(selectedTerminal.id)),
|
||||
formData
|
||||
);
|
||||
showToast('更新成功', 'success');
|
||||
} else {
|
||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.TERMINALS.CREATE), formData);
|
||||
showToast('创建成功', 'success');
|
||||
}
|
||||
setShowModal(false);
|
||||
fetchTerminals();
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.message || '操作失败';
|
||||
showToast(msg, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirmInfo) return;
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.TERMINALS.DELETE(deleteConfirmInfo.id)));
|
||||
showToast('删除成功', 'success');
|
||||
fetchTerminals();
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.message || '删除失败';
|
||||
showToast(msg, 'error');
|
||||
} finally {
|
||||
setDeleteConfirmInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (terminal) => {
|
||||
try {
|
||||
const newStatus = terminal.status === 1 ? 0 : 1;
|
||||
await apiClient.post(
|
||||
buildApiUrl(API_ENDPOINTS.TERMINALS.STATUS(terminal.id)),
|
||||
null,
|
||||
{ params: { status: newStatus } }
|
||||
);
|
||||
showToast(`已${newStatus === 1 ? '启用' : '停用'}终端`, 'success');
|
||||
// Update local state directly for better UX
|
||||
setTerminals(prev => prev.map(t =>
|
||||
t.id === terminal.id ? { ...t, status: newStatus } : t
|
||||
));
|
||||
} catch (error) {
|
||||
showToast('状态更新失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getTerminalTypeLabel = (code) => {
|
||||
const type = terminalTypes.find(t => t.dict_code === code);
|
||||
return type ? type.label_cn : code;
|
||||
};
|
||||
|
||||
if (loading && terminals.length === 0) {
|
||||
return <PageLoading message="加载终端数据..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="terminal-management">
|
||||
<div className="page-header">
|
||||
<div className="header-left">
|
||||
<h1>终端管理</h1>
|
||||
<p className="subtitle">管理专用终端设备的接入与状态</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus size={20} />
|
||||
添加终端
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<div className="search-group">
|
||||
<div className="search-box">
|
||||
<Search size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索IMEI或终端名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
{keyword && (
|
||||
<button className="clear-search" onClick={() => setKeyword('')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => { setFilterType(e.target.value); setPage(1); }}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">所有类型</option>
|
||||
{terminalTypes.map(t => (
|
||||
<option key={t.dict_code} value={t.dict_code}>{t.label_cn}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
|
||||
className="filter-select"
|
||||
>
|
||||
<option value="">所有状态</option>
|
||||
<option value="1">启用</option>
|
||||
<option value="0">停用</option>
|
||||
</select>
|
||||
|
||||
<button className="btn-icon" onClick={handleReset} title="重置筛选">
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IMEI</th>
|
||||
<th>终端名称</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>激活状态</th>
|
||||
<th>最后在线</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTerminals.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="empty-state">暂无数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedTerminals.map(terminal => (
|
||||
<tr key={terminal.id}>
|
||||
<td className="font-mono">{terminal.imei}</td>
|
||||
<td>{terminal.terminal_name || '-'}</td>
|
||||
<td>
|
||||
<span className="type-badge">
|
||||
{getTerminalTypeLabel(terminal.terminal_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="status-toggle" onClick={() => handleToggleStatus(terminal)}>
|
||||
<div className={`toggle-switch ${terminal.status === 1 ? 'on' : 'off'}`}>
|
||||
<div className="toggle-slider"></div>
|
||||
</div>
|
||||
<span className="status-text">{terminal.status === 1 ? '启用' : '停用'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-dot ${terminal.is_activated === 1 ? 'active' : 'inactive'}`}></span>
|
||||
{terminal.is_activated === 1 ? '已激活' : '未激活'}
|
||||
</td>
|
||||
<td>{terminal.last_online_at ? new Date(terminal.last_online_at).toLocaleString() : '-'}</td>
|
||||
<td>{new Date(terminal.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
className="btn-icon btn-edit"
|
||||
onClick={() => handleOpenEdit(terminal)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon btn-delete"
|
||||
onClick={() => setDeleteConfirmInfo({ id: terminal.id, name: terminal.imei })}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{total > 0 && (
|
||||
<div className="pagination">
|
||||
<div className="pagination-info">
|
||||
共 {total} 条记录
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="page-number">{page}</span>
|
||||
<button
|
||||
disabled={page * pageSize >= total}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 弹窗 */}
|
||||
<FormModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title={isEditing ? '编辑终端' : '添加终端'}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowModal(false)}>取消</button>
|
||||
<button type="button" className="btn-primary" onClick={handleSubmit}>保存</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>IMEI *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imei}
|
||||
onChange={e => setFormData({...formData, imei: e.target.value})}
|
||||
placeholder="请输入设备IMEI号"
|
||||
disabled={isEditing} // IMEI通常不可修改
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>终端名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.terminal_name}
|
||||
onChange={e => setFormData({...formData, terminal_name: e.target.value})}
|
||||
placeholder="给设备起个名字"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>终端类型 *</label>
|
||||
<select
|
||||
value={formData.terminal_type}
|
||||
onChange={e => setFormData({...formData, terminal_type: e.target.value})}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>请选择类型</option>
|
||||
{terminalTypes.map(t => (
|
||||
<option key={t.dict_code} value={t.dict_code}>{t.label_cn}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({...formData, description: e.target.value})}
|
||||
placeholder="设备说明..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.status === 1}
|
||||
onChange={e => setFormData({...formData, status: e.target.checked ? 1 : 0})}
|
||||
/>
|
||||
立即启用
|
||||
</label>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
{/* 删除确认 */}
|
||||
{deleteConfirmInfo && (
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
title="删除终端"
|
||||
message={`确定要删除IMEI为 ${deleteConfirmInfo.name} 的终端吗?`}
|
||||
onConfirm={handleDelete}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
type="danger"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toasts */}
|
||||
{toasts.map(t => (
|
||||
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalManagement;
|
||||
|
|
@ -19,24 +19,59 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-management .search-input {
|
||||
padding: 8px 16px;
|
||||
.user-management .search-box {
|
||||
position: relative;
|
||||
min-width: 240px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.user-management .search-input:focus {
|
||||
outline: none;
|
||||
.user-management .search-box:focus-within {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.user-management .search-input::placeholder {
|
||||
.user-management .search-box svg {
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-management .search-box input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-management .search-box input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.user-management .clear-search {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.user-management .clear-search:hover {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||
import { Plus, Edit, Trash2, KeyRound, User, Mail, Shield } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, KeyRound, User, Mail, Shield, Search, X } from 'lucide-react';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import FormModal from '../../components/FormModal';
|
||||
import Toast from '../../components/Toast';
|
||||
|
|
@ -175,16 +175,23 @@ const UserManagement = () => {
|
|||
<div className="toolbar">
|
||||
<h2>用户列表</h2>
|
||||
<div className="toolbar-actions">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名或姓名..."
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
setPage(1); // 重置到第一页
|
||||
}}
|
||||
className="search-input"
|
||||
/>
|
||||
<div className="search-box">
|
||||
<Search size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名或姓名..."
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
setPage(1); // 重置到第一页
|
||||
}}
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="clear-search" onClick={() => setSearchText('')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue