diff --git a/.gemini-clipboard/clipboard-1769064595946.png b/.gemini-clipboard/clipboard-1769064595946.png deleted file mode 100644 index c3a448b..0000000 Binary files a/.gemini-clipboard/clipboard-1769064595946.png and /dev/null differ diff --git a/.gemini-clipboard/clipboard-1770372939346.png b/.gemini-clipboard/clipboard-1770372939346.png new file mode 100644 index 0000000..d63a6cd Binary files /dev/null and b/.gemini-clipboard/clipboard-1770372939346.png differ diff --git a/.gemini-clipboard/clipboard-1770373829996.png b/.gemini-clipboard/clipboard-1770373829996.png new file mode 100644 index 0000000..e013afe Binary files /dev/null and b/.gemini-clipboard/clipboard-1770373829996.png differ diff --git a/.gemini-clipboard/clipboard-1770375001237.png b/.gemini-clipboard/clipboard-1770375001237.png new file mode 100644 index 0000000..6210144 Binary files /dev/null and b/.gemini-clipboard/clipboard-1770375001237.png differ diff --git a/.gemini-clipboard/clipboard-1770375083072.png b/.gemini-clipboard/clipboard-1770375083072.png new file mode 100644 index 0000000..0ffbdcf Binary files /dev/null and b/.gemini-clipboard/clipboard-1770375083072.png differ diff --git a/.gemini-clipboard/clipboard-1770375320165.png b/.gemini-clipboard/clipboard-1770375320165.png new file mode 100644 index 0000000..6e80943 Binary files /dev/null and b/.gemini-clipboard/clipboard-1770375320165.png differ diff --git a/.gemini-clipboard/clipboard-1770375587949.png b/.gemini-clipboard/clipboard-1770375587949.png new file mode 100644 index 0000000..26ec9fd Binary files /dev/null and b/.gemini-clipboard/clipboard-1770375587949.png differ diff --git a/backend/app/api/endpoints/prompts.py b/backend/app/api/endpoints/prompts.py index bda1a14..d7bc985 100644 --- a/backend/app/api/endpoints/prompts.py +++ b/backend/app/api/endpoints/prompts.py @@ -13,6 +13,7 @@ class PromptIn(BaseModel): name: str task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK' content: str + desc: Optional[str] = None # 模版描述 is_default: bool = False is_active: bool = True @@ -39,10 +40,10 @@ def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_use ) cursor.execute( - """INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id) - VALUES (%s, %s, %s, %s, %s, %s)""", - (prompt.name, prompt.task_type, prompt.content, prompt.is_default, - prompt.is_active, current_user["user_id"]) + """INSERT INTO prompts (name, task_type, content, desc, is_default, is_active, creator_id) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (prompt.name, prompt.task_type, prompt.content, prompt.desc, + prompt.is_default, prompt.is_active, current_user["user_id"]) ) connection.commit() new_id = cursor.lastrowid @@ -58,15 +59,21 @@ def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_use @router.get("/prompts/active/{task_type}") def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)): - """Get all active prompts for a specific task type.""" + """Get all active prompts for a specific task type. + + Returns: + - All active prompts created by administrators (role_id = 1) + - All active prompts created by the current logged-in user + """ with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute( - """SELECT id, name, is_default + """SELECT id, name, desc, content, is_default FROM prompts WHERE task_type = %s AND is_active = TRUE + AND (creator_id = 1 OR creator_id = %s) ORDER BY is_default DESC, created_at DESC""", - (task_type,) + (task_type, current_user["user_id"]) ) prompts = cursor.fetchall() return create_api_response( @@ -106,7 +113,7 @@ def get_prompts( # 获取分页数据 offset = (page - 1) * size cursor.execute( - f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at + f"""SELECT id, name, task_type, content, desc, is_default, is_active, creator_id, created_at FROM prompts WHERE {where_clause} ORDER BY created_at DESC @@ -126,7 +133,7 @@ def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute( - """SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at + """SELECT id, name, task_type, content, desc, is_default, is_active, creator_id, created_at FROM prompts WHERE id = %s""", (prompt_id,) ) @@ -166,9 +173,9 @@ def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends print(f"[UPDATE PROMPT] Executing UPDATE query") cursor.execute( """UPDATE prompts - SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s + SET name = %s, task_type = %s, content = %s, desc = %s, is_default = %s, is_active = %s WHERE id = %s""", - (prompt.name, prompt.task_type, prompt.content, prompt.is_default, + (prompt.name, prompt.task_type, prompt.content, prompt.desc, prompt.is_default, prompt.is_active, prompt_id) ) rows_affected = cursor.rowcount diff --git a/frontend/src/components/DataTable.css b/frontend/src/components/DataTable.css new file mode 100644 index 0000000..0be2ba2 --- /dev/null +++ b/frontend/src/components/DataTable.css @@ -0,0 +1,176 @@ +.data-table-wrapper { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.table-container { + background: white; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + overflow-x: auto; /* Enable horizontal scroll */ + border: 1px solid #e2e8f0; + /* 隐藏横向滚动条在 sticky 列下方 */ + overflow-y: hidden; +} + +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + min-width: 100%; +} + +.data-table tr { + background-color: white; +} + +/* Sticky Right Column - 操作列固定 */ +.data-table th.sticky-right, +.data-table td.sticky-right { + position: sticky; + right: 0; + z-index: 1; + background-color: white; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.05); + border-left: 1px solid #f1f5f9; + /* 根据按钮个数自动宽度(通过 col.width 控制) */ +} + +.data-table th.sticky-right { + background-color: #f8fafc; + z-index: 2; +} + +.data-table tr:hover td.sticky-right { + background-color: #f8fafc; +} + +.data-table th { + background: #f8fafc; + padding: 0.875rem 1rem; + text-align: left; + font-weight: 600; + color: #475569; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + font-size: 0.875rem; + height: 48px; +} + +.data-table td { + padding: 0.875rem 1rem; + border-bottom: 1px solid #f1f5f9; + color: #1e293b; + vertical-align: middle; + font-size: 0.875rem; + height: 48px; +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:hover { + background-color: #f8fafc; +} + +.data-table-empty { + text-align: center; + padding: 3rem !important; + color: #94a3b8; +} + +/* Pagination */ +.data-table-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-btn { + padding: 0.375rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: white; + color: #475569; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; +} + +.pagination-btn:hover:not(:disabled) { + background: #f8fafc; + border-color: #cbd5e1; +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background: #f1f5f9; +} + +.pagination-current { + font-weight: 600; + color: #1e293b; + min-width: 1.5rem; + text-align: center; + font-size: 0.875rem; +} + +/* Common Utility Classes for Cells */ +.cell-truncate { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.cell-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #334155; +} + +.cell-actions { + display: flex; + gap: 0.5rem; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; +} + +/* Loading State inside table */ +.table-loading { + padding: 3rem; + display: flex; + justify-content: center; + align-items: center; + color: #64748b; + gap: 0.5rem; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e2e8f0; + border-top-color: #667eea; + border-radius: 50%; + animation: table-spin 1s linear infinite; +} + +@keyframes table-spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx new file mode 100644 index 0000000..a735020 --- /dev/null +++ b/frontend/src/components/DataTable.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import './DataTable.css'; + +const DataTable = ({ + columns = [], + data = [], + loading = false, + pagination = null, // { current, pageSize, total, onChange } + emptyMessage = "暂无数据", + rowKey = "id", + className = "" +}) => { + // Calculate total pages + const totalPages = pagination ? Math.ceil(pagination.total / pagination.pageSize) : 0; + + // Handle page change + const handlePrev = () => { + if (pagination && pagination.current > 1) { + pagination.onChange(pagination.current - 1); + } + }; + + const handleNext = () => { + if (pagination && pagination.current < totalPages) { + pagination.onChange(pagination.current + 1); + } + }; + + return ( +
+
+ + + + {columns.map((col, index) => ( + + ))} + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item, rowIndex) => ( + + {columns.map((col, colIndex) => ( + + ))} + + )) + )} + +
+ {col.title} +
+
+ 加载中... +
+ {emptyMessage} +
+ {col.render ? col.render(item, rowIndex) : item[col.dataIndex]} +
+
+ + {pagination && !loading && pagination.total > 0 && ( +
+
+ 共 {pagination.total} 条记录 +
+
+ + {pagination.current} + +
+
+ )} +
+ ); +}; + +export default DataTable; diff --git a/frontend/src/components/ListTable.css b/frontend/src/components/ListTable.css new file mode 100644 index 0000000..0ecfecb --- /dev/null +++ b/frontend/src/components/ListTable.css @@ -0,0 +1,237 @@ +/* ListTable 列表表格组件 */ + +.list-table-wrapper { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.list-table-container { + background: white; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + border: 1px solid #e2e8f0; + overflow: hidden; +} + +/* 内容区域:flex 布局,数据列可滚动 + 操作列固定 */ +.list-table-content { + display: flex; + width: 100%; + min-height: 0; + overflow: hidden; +} + +/* 可滚动的数据列区域 */ +.list-table-scroll { + flex: 1; + overflow-x: auto; + overflow-y: scroll; +} + +.list-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; +} + +.list-table thead tr { + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.list-table thead th { + padding: 0.875rem 1rem; + text-align: left; + font-weight: 600; + color: #475569; + font-size: 0.875rem; + white-space: nowrap; + height: 48px; + vertical-align: middle; +} + +.list-table tbody tr { + border-bottom: 1px solid #f1f5f9; + background: white; +} + +.list-table tbody tr:last-child { + border-bottom: none; +} + +.list-table tbody tr:hover { + background: #f8fafc; +} + +.list-table tbody td { + padding: 0.875rem 1rem; + color: #1e293b; + font-size: 0.875rem; + height: 48px; + vertical-align: middle; +} + +/* 固定操作列 */ +.list-table-actions { + flex-shrink: 0; + border-left: 1px solid #e2e8f0; + background: white; + overflow-y: scroll; + overflow-x: hidden; +} + +.list-table-actions-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; +} + +.list-table-actions thead tr { + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.list-table-actions thead th { + padding: 0.875rem 1rem; + text-align: left; + font-weight: 600; + color: #475569; + font-size: 0.875rem; + white-space: nowrap; + height: 48px; + vertical-align: middle; +} + +.list-table-actions tbody tr { + border-bottom: 1px solid #f1f5f9; + background: white; +} + +.list-table-actions tbody tr:last-child { + border-bottom: none; +} + +.list-table-actions tbody tr:hover { + background: #f8fafc; +} + +.list-table-actions tbody td { + padding: 0.875rem 1rem; + color: #1e293b; + font-size: 0.875rem; + height: 48px; + vertical-align: middle; +} + +/* 空状态 */ +.list-table-empty { + text-align: center; + padding: 3rem !important; + color: #94a3b8; + display: flex !important; + align-items: center; + justify-content: center; +} + +/* 加载状态 */ +.list-table-loading { + text-align: center; + padding: 3rem !important; + display: flex; + justify-content: center; + align-items: center; + gap: 0.75rem; + color: #64748b; + height: auto !important; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e2e8f0; + border-top-color: #667eea; + border-radius: 50%; + animation: table-spin 1s linear infinite; +} + +@keyframes table-spin { + to { transform: rotate(360deg); } +} + +/* 分页 */ +.list-table-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0.5rem; +} + +.pagination-info { + color: #64748b; + font-size: 0.875rem; + font-weight: 500; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.pagination-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: white; + color: #475569; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.pagination-btn:hover:not(:disabled) { + background: #f8fafc; + border-color: #cbd5e1; + color: #1e293b; +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background: #f1f5f9; +} + +.pagination-current { + font-weight: 600; + color: #1e293b; + min-width: 1.5rem; + text-align: center; + font-size: 0.875rem; +} + +/* 兼容旧样式类 */ +.cell-truncate { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.cell-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #334155; +} + +.action-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; +} diff --git a/frontend/src/components/ListTable.jsx b/frontend/src/components/ListTable.jsx new file mode 100644 index 0000000..bcb4f25 --- /dev/null +++ b/frontend/src/components/ListTable.jsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import './ListTable.css'; + +/** + * 统一的列表表格组件 + * 特点: + * 1. 操作列固定在右侧,宽度自适应 + * 2. 其他列可横向滚动 + * 3. 自动计算操作列宽度(基于按钮数量) + * 4. 统一的样式和交互 + * 5. 可配置的分页器 + */ +const ListTable = ({ + columns = [], + data = [], + loading = false, + pagination = null, // { current, pageSize, total, onChange } + emptyMessage = "暂无数据", + rowKey = "id", + className = "", + showPagination = true // 是否显示分页器,默认显示 +}) => { + const totalPages = pagination ? Math.ceil(pagination.total / pagination.pageSize) : 0; + + const handlePrev = () => { + if (pagination && pagination.current > 1) { + pagination.onChange(pagination.current - 1); + } + }; + + const handleNext = () => { + if (pagination && pagination.current < totalPages) { + pagination.onChange(pagination.current + 1); + } + }; + + // 分离操作列和数据列 + const actionColumn = columns.find(col => col.fixed === 'right'); + const dataColumns = columns.filter(col => col.fixed !== 'right'); + + // 根据按钮数量自动计算操作列宽度 + const calculateActionColumnWidth = () => { + if (!actionColumn) return '0'; + // 每个按钮 32px + 间距 8px,加上左右 padding 各 16px + // 3个按钮: 32 + 8 + 32 + 8 + 32 + 16 + 16 = 144px + // 2个按钮: 32 + 8 + 32 + 16 + 16 = 104px + return actionColumn.width || 'auto'; + }; + + return ( +
+
+
+ {/* 数据列区域 */} +
+ + + + {dataColumns.map((col, index) => ( + + ))} + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item, rowIndex) => ( + + {dataColumns.map((col, colIndex) => ( + + ))} + + )) + )} + +
+ {col.title} +
+
+ 加载中... +
+ {emptyMessage} +
+ {col.render ? col.render(item, rowIndex) : item[col.dataIndex]} +
+
+ + {/* 操作列区域(固定) */} + {actionColumn && ( +
+ + + + + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item, rowIndex) => ( + + + + )) + )} + +
{actionColumn.title}
+ {actionColumn.render ? actionColumn.render(item, rowIndex) : item[actionColumn.dataIndex]} +
+
+ )} +
+
+ + {/* 分页 */} + {showPagination && pagination && !loading && pagination.total > 0 && ( +
+
+ 共 {pagination.total} 条记录 +
+
+ + {pagination.current} + +
+
+ )} +
+ ); +}; + +export default ListTable; diff --git a/frontend/src/components/ToggleSwitch.css b/frontend/src/components/ToggleSwitch.css new file mode 100644 index 0000000..7b82018 --- /dev/null +++ b/frontend/src/components/ToggleSwitch.css @@ -0,0 +1,124 @@ +/* 统一的开关控件样式 */ + +.toggle-switch-container { + display: inline-flex; + align-items: center; + gap: 0.5rem; + user-select: none; +} + +.toggle-switch-container.disabled { + opacity: 0.5; + cursor: not-allowed !important; +} + +/* Medium size (默认) - 与表格行高一致 */ +.toggle-switch-container.medium .toggle-switch { + width: 60px; + height: 28px; + padding: 0 0.375rem; +} + +.toggle-switch-container.medium .toggle-text { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.2px; + flex: 1; +} + +.toggle-switch-container.medium .toggle-slider { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* Small size */ +.toggle-switch-container.small .toggle-switch { + width: 50px; + height: 24px; + padding: 0 0.3rem; +} + +.toggle-switch-container.small .toggle-text { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.15px; + flex: 1; +} + +.toggle-switch-container.small .toggle-slider { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* Large size */ +.toggle-switch-container.large .toggle-switch { + width: 70px; + height: 32px; + padding: 0 0.4rem; +} + +.toggle-switch-container.large .toggle-text { + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.25px; + flex: 1; +} + +.toggle-switch-container.large .toggle-slider { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +/* 开关核心样式 */ +.toggle-switch { + border-radius: 999px; + position: relative; + transition: all 0.25s ease; + display: flex; + align-items: center; + justify-content: space-between; + color: white; +} + +.toggle-switch.on { + background-color: #3b82f6; + box-shadow: 0 2px 6px rgba(59, 130, 246, 0.2); +} + +.toggle-switch.off { + background-color: #cbd5e1; + box-shadow: 0 2px 6px rgba(203, 213, 225, 0.15); +} + +.toggle-switch:hover:not(.disabled) { + filter: brightness(1.05); +} + +.toggle-text { + position: relative; + z-index: 1; + color: white; + text-align: center; + white-space: nowrap; +} + +.toggle-slider { + border-radius: 50%; + background: white; + transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + z-index: 2; + flex-shrink: 0; +} + +/* 标签样式 */ +.toggle-label { + font-size: 0.875rem; + color: #64748b; + font-weight: 500; + user-select: none; +} + diff --git a/frontend/src/components/ToggleSwitch.jsx b/frontend/src/components/ToggleSwitch.jsx new file mode 100644 index 0000000..1a2d78a --- /dev/null +++ b/frontend/src/components/ToggleSwitch.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import './ToggleSwitch.css'; + +/** + * 统一的启/停开关组件 + * @param {boolean} checked - 开关状态 + * @param {function} onChange - 状态变化回调 + * @param {string} label - 标签文字 (可选,如果不提供则文字显示在开关内) + * @param {boolean} disabled - 是否禁用 + * @param {string} size - 大小: 'small' | 'medium' (默认) | 'large' + */ +const ToggleSwitch = ({ + checked = false, + onChange, + label, + disabled = false, + size = 'medium', + onClick = null +}) => { + const handleClick = (e) => { + if (disabled) return; + e.stopPropagation(); + onChange?.(!checked); + onClick?.(); + }; + + return ( +
+
+ + {checked ? '启用' : '停用'} + +
+
+ {label && ( + {label} + )} +
+ ); +}; + +export default ToggleSwitch; + diff --git a/frontend/src/pages/MeetingDetails.css b/frontend/src/pages/MeetingDetails.css index bd68da2..f8936d5 100644 --- a/frontend/src/pages/MeetingDetails.css +++ b/frontend/src/pages/MeetingDetails.css @@ -440,7 +440,6 @@ border-left: 3px solid #667eea; display: flex; align-items: center; - flex-wrap: wrap; gap: 0.75rem; } @@ -448,6 +447,11 @@ font-size: 0.9rem; color: #475569; font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .audio-loading-hint { @@ -655,6 +659,10 @@ border-radius: 50%; width: 50px; height: 50px; + min-width: 50px; /* Force fixed size */ + min-height: 50px; /* Force fixed size */ + max-width: 50px; /* Force fixed size */ + max-height: 50px; /* Force fixed size */ display: flex; align-items: center; justify-content: center; @@ -662,6 +670,7 @@ cursor: pointer; transition: all 0.3s ease; outline: none; /* 去掉点击后的蓝色边框 */ + flex-shrink: 0; /* Prevent shrinking */ } .play-button:focus { @@ -714,6 +723,7 @@ font-weight: 500; min-width: 50px; text-align: center; + flex-shrink: 0; } .progress-container { @@ -725,48 +735,16 @@ padding: 0 1rem; margin: 0 0.5rem; user-select: none; + min-width: 100px; /* Ensure minimum width */ } -.progress-bar { - width: 100%; - height: 6px; - background: rgba(255, 255, 255, 0.3); - border-radius: 3px; - position: relative; - overflow: hidden; -} - -.progress-fill { - height: 100%; - background: rgba(255, 255, 255, 0.9); - border-radius: 3px; - transition: width 0.1s ease; - position: relative; -} - -.progress-fill::after { - content: ''; - position: absolute; - right: -6px; - top: 50%; - transform: translateY(-50%); - width: 12px; - height: 12px; - background: white; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - opacity: 0; - transition: opacity 0.2s ease; -} - -.progress-container:hover .progress-fill::after { - opacity: 1; -} +/* ... existing styles ... */ .volume-control { display: flex; align-items: center; gap: 0.5rem; + flex-shrink: 0; } .volume-slider { diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx index 5c358ad..c9f3556 100644 --- a/frontend/src/pages/MeetingDetails.jsx +++ b/frontend/src/pages/MeetingDetails.jsx @@ -1407,6 +1407,7 @@ const MeetingDetails = ({ user }) => { key={audioUrl || 'no-audio'} // 使用audioUrl作为key,确保切换会议时重新创建audio元素 ref={audioRef} src={audioUrl} + style={{ display: 'none' }} onTimeUpdate={handleTimeUpdate} onLoadedMetadata={handleLoadedMetadata} onCanPlay={handleCanPlay} diff --git a/frontend/src/pages/admin/ExternalAppManagement.css b/frontend/src/pages/admin/ExternalAppManagement.css index 927ffd4..a8c4646 100644 --- a/frontend/src/pages/admin/ExternalAppManagement.css +++ b/frontend/src/pages/admin/ExternalAppManagement.css @@ -57,6 +57,14 @@ flex-wrap: wrap; } +/* DataTable 容器 - 与背景区隔 */ +.client-management .data-table-wrapper { + background: white; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + .filter-group { display: flex; align-items: center; @@ -258,6 +266,50 @@ white-space: nowrap; } +/* 状态开关 */ +.client-management .status-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.client-management .toggle-switch { + width: 36px; + height: 20px; + border-radius: 999px; + position: relative; + transition: background-color 0.2s; +} + +.client-management .toggle-switch.on { + background-color: #10b981; +} + +.client-management .toggle-switch.off { + background-color: #cbd5e1; +} + +.client-management .toggle-slider { + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + position: absolute; + top: 2px; + left: 2px; + transition: transform 0.2s; +} + +.client-management .toggle-switch.on .toggle-slider { + transform: translateX(16px); +} + +.client-management .status-text { + font-size: 0.875rem; + color: #64748b; +} + /* 状态徽章 */ .status-badge { display: inline-block; @@ -284,13 +336,13 @@ align-items: center; } -.btn-icon { - width: 36px; - height: 36px; - min-width: 36px; - min-height: 36px; +.client-management .btn-icon { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; padding: 0; - border: none; + border: 1px solid #e2e8f0; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; @@ -298,35 +350,35 @@ align-items: center; justify-content: center; flex-shrink: 0; + background: white; } -.btn-icon svg { +.client-management .btn-icon:hover { + background: #f8fafc; + border-color: #cbd5e1; +} + +.client-management .btn-icon svg { display: block; width: 16px !important; height: 16px !important; flex-shrink: 0; } -.btn-edit { - background: #dbeafe; +.client-management .btn-edit { color: #3b82f6; } -.btn-edit:hover { - background: #3b82f6; - color: white; - transform: translateY(-1px); -} - -.btn-delete { - background: #fee2e2; +.client-management .btn-delete { color: #ef4444; + background: white; } -.btn-delete:hover { - background: #ef4444; - color: white; - transform: translateY(-1px); +.client-management .btn-delete:hover { + background: #fef2f2; + border-color: #fecaca; + color: #dc2626; + transform: none; /* Reset transform if needed */ } /* 加载状态 */ diff --git a/frontend/src/pages/admin/ExternalAppManagement.jsx b/frontend/src/pages/admin/ExternalAppManagement.jsx index ec7ee5a..e169dfd 100644 --- a/frontend/src/pages/admin/ExternalAppManagement.jsx +++ b/frontend/src/pages/admin/ExternalAppManagement.jsx @@ -15,6 +15,9 @@ import apiClient from '../../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../../config/api'; import ConfirmDialog from '../../components/ConfirmDialog'; import Toast from '../../components/Toast'; +import FormModal from '../../components/FormModal'; +import ToggleSwitch from '../../components/ToggleSwitch'; +import ListTable from '../../components/ListTable'; import './ExternalAppManagement.css'; const ExternalAppManagement = ({ user }) => { @@ -59,6 +62,22 @@ const ExternalAppManagement = ({ user }) => { fetchApps(); }, []); + const handleToggleAppStatus = async (app) => { + const newActive = app.is_active ? false : true; + try { + await apiClient.put( + buildApiUrl(API_ENDPOINTS.EXTERNAL_APPS.UPDATE(app.id)), + { is_active: newActive } + ); + setApps(prev => prev.map(a => + a.id === app.id ? { ...a, is_active: newActive } : a + )); + showToast(`已${newActive ? '启用' : '禁用'}应用`, 'success'); + } catch (error) { + showToast('状态更新失败', 'error'); + } + }; + const fetchApps = async () => { setLoading(true); try { @@ -247,9 +266,7 @@ const ExternalAppManagement = ({ user }) => { } }; - const handleSubmit = async (e) => { - e.preventDefault(); - + const handleSubmit = async () => { // 验证必填字段 if (!formData.app_name) { showToast('请输入应用名称', 'error'); @@ -330,13 +347,123 @@ const ExternalAppManagement = ({ user }) => { return app.app_info || {}; }; - if (loading) { - return ( -
-
加载中...
-
- ); - } + const columns = [ + { + title: '应用名称', + key: 'app_name', + render: (app) => ( +
+ {app.icon_url && ( + + )} + {app.app_name} +
+ ) + }, + { + title: '类型', + key: 'app_type', + render: (app) => ( +
+ {app.app_type === 'native' ? ( + <> + + 原生应用 + + ) : ( + <> + + Web应用 + + )} +
+ ) + }, + { + title: '版本', + key: 'version', + render: (app) => getAppInfo(app).version_name || '-' + }, + { + title: '详细信息', + key: 'info', + render: (app) => { + const appInfo = getAppInfo(app); + return ( +
+ {app.app_type === 'native' ? ( +
+
包名: {appInfo.package_name || '-'}
+ {appInfo.apk_url && ( + + + 下载APK + + )} +
+ ) : ( +
+ {appInfo.web_url && ( + + + {appInfo.web_url} + + )} +
+ )} +
+ ); + } + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + render: (item) =>
{item.description || '-'}
+ }, + { title: '排序', dataIndex: 'sort_order', key: 'sort_order' }, + { + title: '状态', + key: 'status', + render: (app) => ( + handleToggleAppStatus(app)} + size="medium" + /> + ) + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + render: (item) => new Date(item.created_at).toLocaleDateString() + }, + { + title: '操作', + key: 'action', + fixed: 'right', + width: '110px', + render: (app) => ( +
+ + +
+ ) + } + ]; return (
@@ -383,344 +510,231 @@ const ExternalAppManagement = ({ user }) => {
{/* 应用列表 */} -
- - - - - - - - - - - - - - - - {filteredApps.length === 0 ? ( - - - - ) : ( - filteredApps.map(app => { - const appInfo = getAppInfo(app); - return ( - - - - - - - - - - - - ); - }) - )} - -
应用名称类型版本详细信息描述排序状态创建时间操作
- 暂无数据 -
-
- {app.icon_url && ( - - )} - {app.app_name} -
-
-
- {app.app_type === 'native' ? ( - <> - - 原生应用 - - ) : ( - <> - - Web应用 - - )} -
-
{appInfo.version_name || '-'} - {app.app_type === 'native' ? ( -
-
包名: {appInfo.package_name || '-'}
- {appInfo.apk_url && ( - - - 下载APK - - )} -
- ) : ( -
- {appInfo.web_url && ( - - - {appInfo.web_url} - - )} -
- )} -
-
- {app.description || '-'} -
-
{app.sort_order} - - {app.is_active ? '启用' : '禁用'} - - {new Date(app.created_at).toLocaleDateString()} -
- - -
-
-
+ {/* 添加/编辑应用模态框 */} - {showAppModal && ( -
setShowAppModal(false)}> -
e.stopPropagation()}> -
-

{isEditing ? '编辑应用' : '添加应用'}

- -
-
-
- {/* 应用类型 */} -
- - -
- - {/* 原生应用 - APK上传 */} - {formData.app_type === 'native' && ( -
- -
- - - 上传后自动解析包名、版本等信息 -
-
- )} - - {/* 应用名称 */} -
- - handleFormChange('app_name', e.target.value)} - placeholder="请输入应用名称" - required - /> -
- - {/* 原生应用字段 */} - {formData.app_type === 'native' && ( - <> -
- - handleFormChange('app_info.version_name', e.target.value)} - placeholder="例如: 1.0.0" - required - /> -
-
- - handleFormChange('app_info.package_name', e.target.value)} - placeholder="例如: com.example.app" - required - /> -
-
- - handleFormChange('app_info.apk_url', e.target.value)} - placeholder="APK文件的下载URL" - required - /> -
- - )} - - {/* Web应用字段 */} - {formData.app_type === 'web' && ( - <> -
- - handleFormChange('app_info.version_name', e.target.value)} - placeholder="例如: 1.0(可选)" - /> -
-
- - handleFormChange('app_info.web_url', e.target.value)} - placeholder="https://example.com" - required - /> -
- - )} - - {/* 应用图标 */} -
- -
- - - 支持JPG、PNG、GIF、WEBP格式 -
- {formData.icon_url && ( -
- 应用图标预览 -
- )} -
- - {/* 应用描述 */} -
- -