增加了终端管理模块

main
mula.liu 2026-01-21 15:21:17 +08:00
parent f99f96e65f
commit 56a7d33cb8
21 changed files with 1469 additions and 139 deletions

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

View File

@ -82,10 +82,11 @@ async def get_dict_types():
@router.get("/dict/{dict_type}", response_model=dict) @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' dict_type: 字典类型 'client_platform'
@ -100,9 +101,16 @@ async def get_dict_data_by_type(dict_type: str):
is_default, status, create_time is_default, status, create_time
FROM dict_data FROM dict_data
WHERE dict_type = %s AND status = 1 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() items = cursor.fetchall()
cursor.close() cursor.close()
@ -114,6 +122,17 @@ async def get_dict_data_by_type(dict_type: str):
except: except:
item['extension_attr'] = {} item['extension_attr'] = {}
# 如果指定了parent_code只返回平铺列表
if parent_code:
return create_api_response(
code="200",
message="获取成功",
data={
"items": items,
"tree": []
}
)
# 构建树形结构 # 构建树形结构
tree_data = [] tree_data = []
nodes_map = {} nodes_map = {}
@ -128,12 +147,12 @@ async def get_dict_data_by_type(dict_type: str):
# 第二遍:构建树形关系 # 第二遍:构建树形关系
for item in items: for item in items:
node = nodes_map[item['dict_code']] 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) tree_data.append(node)
elif parent_code in nodes_map: elif parent_code_val in nodes_map:
nodes_map[parent_code]['children'].append(node) nodes_map[parent_code_val]['children'].append(node)
return create_api_response( return create_api_response(
code="200", code="200",

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from app.core.database import get_db_connection 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.response import create_api_response
from app.core.config import BASE_DIR, EXTERNAL_APPS_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE 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 from app.utils.apk_parser import parse_apk_with_androguard
@ -44,7 +44,7 @@ class UpdateExternalAppRequest(BaseModel):
async def get_external_apps( async def get_external_apps(
app_type: Optional[str] = None, app_type: Optional[str] = None,
is_active: Optional[bool] = 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) @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: try:
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@ -134,18 +143,10 @@ async def get_active_external_apps():
cursor.close() 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( return create_api_response(
code="200", code="200",
message="获取成功", message="获取成功",
data={ data=apps
"native": native_apps,
"web": web_apps,
"all": apps
}
) )
except Exception as e: except Exception as e:

View File

@ -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)}"
)

View File

@ -14,7 +14,7 @@ from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware 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.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 from app.core.config import UPLOAD_DIR, API_CONFIG
app = FastAPI( 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(voiceprint.router, prefix="/api", tags=["Voiceprint"])
app.include_router(audio.router, prefix="/api", tags=["Audio"]) app.include_router(audio.router, prefix="/api", tags=["Audio"])
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"]) 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) @app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html(): async def custom_swagger_ui_html():

View File

@ -274,3 +274,38 @@ class RolePermissionInfo(BaseModel):
class UpdateRolePermissionsRequest(BaseModel): class UpdateRolePermissionsRequest(BaseModel):
menu_ids: List[int] 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

View File

@ -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()

View File

@ -1,59 +1,29 @@
-- 添加专用终端类型支持 -- 专用终端设备表
-- 修改 platform_type 枚举,添加 'terminal' 类型 CREATE TABLE IF NOT EXISTS `terminals` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
ALTER TABLE client_downloads `imei` varchar(64) NOT NULL COMMENT 'IMEI号(设备唯一标识)',
MODIFY COLUMN platform_type ENUM('mobile', 'desktop', 'terminal') NOT NULL `terminal_name` varchar(100) DEFAULT NULL COMMENT '终端名称/设备别名',
COMMENT '平台类型mobile-移动端, desktop-桌面端, terminal-专用终端'; `terminal_type` varchar(50) NOT NULL COMMENT '终端类型(关联dict_data.dict_code)',
`description` varchar(500) DEFAULT NULL COMMENT '终端说明/备注',
-- 插入专用终端示例数据
-- 状态管理
-- Android 专用终端 `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '启用状态: 1-启用, 0-停用',
INSERT INTO client_downloads ( `is_activated` tinyint(1) NOT NULL DEFAULT '0' COMMENT '激活状态: 1-已激活, 0-未激活',
platform_type, `activated_at` datetime DEFAULT NULL COMMENT '激活时间',
platform_name,
version, -- 运维监控字段
version_code, `firmware_version` varchar(50) DEFAULT NULL COMMENT '当前固件版本',
download_url, `last_online_at` datetime DEFAULT NULL COMMENT '最后在线/心跳时间',
file_size, `ip_address` varchar(50) DEFAULT NULL COMMENT '最近一次连接IP',
release_notes, `mac_address` varchar(64) DEFAULT NULL COMMENT 'MAC地址',
is_active,
is_latest, -- 审计字段
min_system_version, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间',
created_by `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
) VALUES `created_by` int(11) DEFAULT NULL COMMENT '录入人ID',
(
'terminal', PRIMARY KEY (`id`),
'android', UNIQUE KEY `uk_imei` (`imei`),
'1.0.0', KEY `idx_terminal_type` (`terminal_type`),
1000, KEY `idx_status` (`status`)
'https://download.imeeting.com/terminals/android/iMeeting-1.0.0-Terminal.apk', ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专用终端设备表';
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
);

View File

@ -20,6 +20,7 @@
- **knowledge_bases_task**: 知识库生成任务表 - **knowledge_bases_task**: 知识库生成任务表
- **dict_data**: 字典/码表数据表 - **dict_data**: 字典/码表数据表
- **client_downloads**: 客户端下载管理表 - **client_downloads**: 客户端下载管理表
- **terminals**: 专用终端设备表
--- ---
@ -309,14 +310,29 @@
- `platform_code` 关联 `dict_data` 表的 `dict_code` 字段client_platform类型 - `platform_code` 关联 `dict_data` 表的 `dict_code` 字段client_platform类型
- 业务逻辑需确保:设置新最新版本时,自动取消同平台其他版本的最新状态 - 业务逻辑需确保:设置新最新版本时,自动取消同平台其他版本的最新状态
**相关API接口** ### 2.18. `terminals` - 专用终端设备表
- `GET /api/clients/latest/by-platform` - 获取最新版本客户端
- 支持两种调用方式(兼容新旧版本,返回数据结构一致): 存储专用终端设备信息(如录音笔、会议平板等),用于设备激活管理和状态监控。
1. 旧版:传 `platform_type``platform_name` 参数
2. 新版:传 `platform_code` 参数(推荐) | 字段名 | 类型 | 约束 | 描述 |
- `POST /api/clients/upload` - 上传客户端安装包(管理员) | :--- | :--- | :--- | :--- |
- 自动解析APK文件的版本信息 | `id` | INT | PRIMARY KEY, AUTO_INCREMENT | 主键ID |
- 自动读取文件大小并生成下载URL | `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 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{ meetings : "creates"
users ||--o{ attendees : "attends" users ||--o{ attendees : "attends"
users }|..|| roles : "has role" users }|..|| roles : "has role"
@ -409,4 +433,4 @@ erDiagram
meetings ||--|{ meeting_summaries : "has" meetings ||--|{ meeting_summaries : "has"
meetings ||--|{ llm_tasks : "has" meetings ||--|{ llm_tasks : "has"
``` ```

View File

@ -1,26 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> <?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>
<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>

Before

Width:  |  Height:  |  Size: 951 B

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -104,6 +104,14 @@ const API_CONFIG = {
TEMPLATE: '/api/voiceprint/template', TEMPLATE: '/api/voiceprint/template',
UPLOAD: (userId) => `/api/voiceprint/${userId}`, UPLOAD: (userId) => `/api/voiceprint/${userId}`,
DELETE: (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`
} }
} }
}; };

View File

@ -1,9 +1,10 @@
import React from 'react'; 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 { Tabs } from 'antd';
import UserManagement from './admin/UserManagement'; import UserManagement from './admin/UserManagement';
import ClientManagement from './ClientManagement'; import ClientManagement from './ClientManagement';
import ExternalAppManagement from './admin/ExternalAppManagement'; import ExternalAppManagement from './admin/ExternalAppManagement';
import TerminalManagement from './admin/TerminalManagement';
import PermissionManagement from './admin/PermissionManagement'; import PermissionManagement from './admin/PermissionManagement';
import DictManagement from './admin/DictManagement'; import DictManagement from './admin/DictManagement';
import HotWordManagement from './admin/HotWordManagement'; import HotWordManagement from './admin/HotWordManagement';
@ -44,6 +45,11 @@ const AdminManagement = () => {
label: <span><Package size={16} /> 外部应用管理</span>, label: <span><Package size={16} /> 外部应用管理</span>,
children: <ExternalAppManagement />, children: <ExternalAppManagement />,
}, },
{
key: 'terminalManagement',
label: <span><Monitor size={16} /> 终端管理</span>,
children: <TerminalManagement />,
},
]; ];
return ( return (

View File

@ -62,6 +62,7 @@
.search-box svg:first-child { .search-box svg:first-child {
color: #94a3b8; color: #94a3b8;
margin-right: 0.75rem; margin-right: 0.75rem;
margin-left: 0.5rem;
} }
.search-box input { .search-box input {

View File

@ -86,7 +86,7 @@
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
} }
.search-box { .client-management .search-box {
flex: 1; flex: 1;
min-width: 300px; min-width: 300px;
position: relative; position: relative;
@ -98,19 +98,20 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.search-box svg:first-child { .client-management .search-box svg:first-child {
color: #94a3b8; color: #94a3b8;
margin-right: 0.75rem; margin-right: 0.75rem;
margin-left: 0.5rem;
} }
.search-box input { .client-management .search-box input {
flex: 1; flex: 1;
border: none; border: none;
outline: none; outline: none;
font-size: 0.95rem; font-size: 0.95rem;
} }
.clear-search { .client-management .clear-search {
background: none; background: none;
border: none; border: none;
color: #94a3b8; color: #94a3b8;
@ -121,7 +122,7 @@
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.clear-search:hover { .client-management .clear-search:hover {
color: #64748b; color: #64748b;
} }

View File

@ -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;
}

View File

@ -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;

View File

@ -19,24 +19,59 @@
gap: 12px; gap: 12px;
} }
.user-management .search-input { .user-management .search-box {
padding: 8px 16px; position: relative;
min-width: 240px;
background: white;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 8px;
font-size: 14px; display: flex;
width: 240px; align-items: center;
padding: 0.5rem 1rem;
transition: all 0.2s; transition: all 0.2s;
margin: 0px;
} }
.user-management .search-input:focus { .user-management .search-box:focus-within {
outline: none;
border-color: #667eea; border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
} }
.user-management .search-input::placeholder { .user-management .search-box svg {
color: #94a3b8; 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 { .users-table {

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
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 { 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 ConfirmDialog from '../../components/ConfirmDialog';
import FormModal from '../../components/FormModal'; import FormModal from '../../components/FormModal';
import Toast from '../../components/Toast'; import Toast from '../../components/Toast';
@ -175,16 +175,23 @@ const UserManagement = () => {
<div className="toolbar"> <div className="toolbar">
<h2>用户列表</h2> <h2>用户列表</h2>
<div className="toolbar-actions"> <div className="toolbar-actions">
<input <div className="search-box">
type="text" <Search size={18} />
placeholder="搜索用户名或姓名..." <input
value={searchText} type="text"
onChange={(e) => { placeholder="搜索用户名或姓名..."
setSearchText(e.target.value); value={searchText}
setPage(1); // onChange={(e) => {
}} setSearchText(e.target.value);
className="search-input" 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> <button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
</div> </div>
</div> </div>