diff --git a/.gemini-clipboard/clipboard-1768468287858.png b/.gemini-clipboard/clipboard-1768468287858.png deleted file mode 100644 index bcd8f1a..0000000 Binary files a/.gemini-clipboard/clipboard-1768468287858.png and /dev/null differ diff --git a/.gemini-clipboard/clipboard-1768470582901.png b/.gemini-clipboard/clipboard-1768470582901.png deleted file mode 100644 index 7b998d4..0000000 Binary files a/.gemini-clipboard/clipboard-1768470582901.png and /dev/null differ diff --git a/.gemini-clipboard/clipboard-1768470643790.png b/.gemini-clipboard/clipboard-1768470643790.png deleted file mode 100644 index cb6ed0c..0000000 Binary files a/.gemini-clipboard/clipboard-1768470643790.png and /dev/null differ diff --git a/.gemini-clipboard/clipboard-1768974204648.png b/.gemini-clipboard/clipboard-1768974204648.png new file mode 100644 index 0000000..6838957 Binary files /dev/null and b/.gemini-clipboard/clipboard-1768974204648.png differ diff --git a/backend/app/api/endpoints/dict_data.py b/backend/app/api/endpoints/dict_data.py index b5c03c7..bc27185 100644 --- a/backend/app/api/endpoints/dict_data.py +++ b/backend/app/api/endpoints/dict_data.py @@ -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", diff --git a/backend/app/api/endpoints/external_apps.py b/backend/app/api/endpoints/external_apps.py index 7ca3887..259307a 100644 --- a/backend/app/api/endpoints/external_apps.py +++ b/backend/app/api/endpoints/external_apps.py @@ -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: diff --git a/backend/app/api/endpoints/terminals.py b/backend/app/api/endpoints/terminals.py new file mode 100644 index 0000000..9567fa6 --- /dev/null +++ b/backend/app/api/endpoints/terminals.py @@ -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)}" + ) diff --git a/backend/app/main.py b/backend/app/main.py index b53514c..6057481 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 647f8e7..f2f4259 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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 diff --git a/backend/app/services/terminal_service.py b/backend/app/services/terminal_service.py new file mode 100644 index 0000000..00656be --- /dev/null +++ b/backend/app/services/terminal_service.py @@ -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() diff --git a/backend/sql/add_dedicated_terminal.sql b/backend/sql/add_dedicated_terminal.sql index e0140ad..62abc5a 100644 --- a/backend/sql/add_dedicated_terminal.sql +++ b/backend/sql/add_dedicated_terminal.sql @@ -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='专用终端设备表'; \ No newline at end of file diff --git a/database.md b/database.md index 738b913..ec2bc83 100644 --- a/database.md +++ b/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" -``` +``` \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index cc9275d..b5ed56b 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,26 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - AI - - + \ No newline at end of file diff --git a/frontend/src/config/api.js b/frontend/src/config/api.js index 6be55dd..fc344f8 100644 --- a/frontend/src/config/api.js +++ b/frontend/src/config/api.js @@ -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` } } }; diff --git a/frontend/src/pages/AdminManagement.jsx b/frontend/src/pages/AdminManagement.jsx index 0546ea8..6f29441 100644 --- a/frontend/src/pages/AdminManagement.jsx +++ b/frontend/src/pages/AdminManagement.jsx @@ -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: 外部应用管理, children: , }, + { + key: 'terminalManagement', + label: 终端管理, + children: , + }, ]; return ( diff --git a/frontend/src/pages/ClientManagement.css b/frontend/src/pages/ClientManagement.css index 4392009..a10c7ff 100644 --- a/frontend/src/pages/ClientManagement.css +++ b/frontend/src/pages/ClientManagement.css @@ -62,6 +62,7 @@ .search-box svg:first-child { color: #94a3b8; margin-right: 0.75rem; + margin-left: 0.5rem; } .search-box input { diff --git a/frontend/src/pages/admin/ExternalAppManagement.css b/frontend/src/pages/admin/ExternalAppManagement.css index 05263a2..927ffd4 100644 --- a/frontend/src/pages/admin/ExternalAppManagement.css +++ b/frontend/src/pages/admin/ExternalAppManagement.css @@ -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; } diff --git a/frontend/src/pages/admin/TerminalManagement.css b/frontend/src/pages/admin/TerminalManagement.css new file mode 100644 index 0000000..1e1b213 --- /dev/null +++ b/frontend/src/pages/admin/TerminalManagement.css @@ -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; +} diff --git a/frontend/src/pages/admin/TerminalManagement.jsx b/frontend/src/pages/admin/TerminalManagement.jsx new file mode 100644 index 0000000..b34ea10 --- /dev/null +++ b/frontend/src/pages/admin/TerminalManagement.jsx @@ -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 ; + } + + return ( +
+
+
+

终端管理

+

管理专用终端设备的接入与状态

+
+ +
+ +
+
+
+ + setKeyword(e.target.value)} + /> + {keyword && ( + + )} +
+
+ +
+ + + + + +
+
+ +
+ + + + + + + + + + + + + + + {paginatedTerminals.length === 0 ? ( + + + + ) : ( + paginatedTerminals.map(terminal => ( + + + + + + + + + + + )) + )} + +
IMEI终端名称类型状态激活状态最后在线创建时间操作
暂无数据
{terminal.imei}{terminal.terminal_name || '-'} + + {getTerminalTypeLabel(terminal.terminal_type)} + + +
handleToggleStatus(terminal)}> +
+
+
+ {terminal.status === 1 ? '启用' : '停用'} +
+
+ + {terminal.is_activated === 1 ? '已激活' : '未激活'} + {terminal.last_online_at ? new Date(terminal.last_online_at).toLocaleString() : '-'}{new Date(terminal.created_at).toLocaleDateString()} +
+ + +
+
+
+ + {/* 分页 */} + {total > 0 && ( +
+
+ 共 {total} 条记录 +
+
+ + {page} + +
+
+ )} + + {/* 弹窗 */} + setShowModal(false)} + title={isEditing ? '编辑终端' : '添加终端'} + actions={ + <> + + + + } + > +
+ + setFormData({...formData, imei: e.target.value})} + placeholder="请输入设备IMEI号" + disabled={isEditing} // IMEI通常不可修改 + required + /> +
+
+ + setFormData({...formData, terminal_name: e.target.value})} + placeholder="给设备起个名字" + /> +
+
+ + +
+
+ +