修复ListTable行高对齐问题

- 修改.list-table-scroll为overflow-y: scroll,确保显示滚动条轨道
- 修改.list-table-actions为overflow-y: scroll,与数据列保持一致
- 两个表格现在会同时显示滚动条,保持行高完全对齐

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
codex/dev
mula.liu 2026-02-12 15:34:12 +08:00
parent 2eda8f9fc3
commit bbcc5466f0
26 changed files with 1987 additions and 831 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

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

View File

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

View File

@ -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 (
<div className={`data-table-wrapper ${className}`}>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
{columns.map((col, index) => (
<th
key={col.key || col.dataIndex || index}
style={{ width: col.width, minWidth: col.minWidth }}
className={`${col.headerClassName || ''} ${col.fixed === 'right' ? 'sticky-right' : ''}`}
>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} className="table-loading">
<div className="spinner"></div>
<span>加载中...</span>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="data-table-empty">
{emptyMessage}
</td>
</tr>
) : (
data.map((item, rowIndex) => (
<tr key={item[rowKey] || rowIndex}>
{columns.map((col, colIndex) => (
<td
key={col.key || col.dataIndex || colIndex}
className={`${col.className || ''} ${col.fixed === 'right' ? 'sticky-right' : ''}`}
style={{ width: col.width, minWidth: col.minWidth }}
>
{col.render ? col.render(item, rowIndex) : item[col.dataIndex]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{pagination && !loading && pagination.total > 0 && (
<div className="data-table-pagination">
<div className="pagination-info">
{pagination.total} 条记录
</div>
<div className="pagination-controls">
<button
className="pagination-btn"
disabled={pagination.current === 1}
onClick={handlePrev}
>
上一页
</button>
<span className="pagination-current">{pagination.current}</span>
<button
className="pagination-btn"
disabled={pagination.current >= totalPages}
onClick={handleNext}
>
下一页
</button>
</div>
</div>
)}
</div>
);
};
export default DataTable;

View File

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

View File

@ -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 (
<div className={`list-table-wrapper ${className}`}>
<div className="list-table-container">
<div className="list-table-content">
{/* 数据列区域 */}
<div className="list-table-scroll">
<table className="list-table">
<thead>
<tr>
{dataColumns.map((col, index) => (
<th
key={col.key || col.dataIndex || index}
style={{ width: col.width, minWidth: col.minWidth }}
className={col.headerClassName || ''}
>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={dataColumns.length} className="list-table-loading">
<div className="spinner"></div>
<span>加载中...</span>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={dataColumns.length} className="list-table-empty">
{emptyMessage}
</td>
</tr>
) : (
data.map((item, rowIndex) => (
<tr key={item[rowKey] || rowIndex}>
{dataColumns.map((col, colIndex) => (
<td
key={col.key || col.dataIndex || colIndex}
className={col.className || ''}
style={{ width: col.width, minWidth: col.minWidth }}
>
{col.render ? col.render(item, rowIndex) : item[col.dataIndex]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* 操作列区域(固定) */}
{actionColumn && (
<div className="list-table-actions" style={{ width: calculateActionColumnWidth() }}>
<table className="list-table list-table-actions-table">
<thead>
<tr>
<th>{actionColumn.title}</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td></td>
</tr>
) : data.length === 0 ? (
<tr>
<td></td>
</tr>
) : (
data.map((item, rowIndex) => (
<tr key={item[rowKey] || rowIndex}>
<td>
{actionColumn.render ? actionColumn.render(item, rowIndex) : item[actionColumn.dataIndex]}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 分页 */}
{showPagination && pagination && !loading && pagination.total > 0 && (
<div className="list-table-pagination">
<div className="pagination-info">
{pagination.total} 条记录
</div>
<div className="pagination-controls">
<button
className="pagination-btn"
disabled={pagination.current === 1}
onClick={handlePrev}
>
<ChevronLeft size={16} />
上一页
</button>
<span className="pagination-current">{pagination.current}</span>
<button
className="pagination-btn"
disabled={pagination.current >= totalPages}
onClick={handleNext}
>
下一页
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
);
};
export default ListTable;

View File

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

View File

@ -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 (
<div
className={`toggle-switch-container ${size} ${disabled ? 'disabled' : ''}`}
onClick={handleClick}
style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}
>
<div className={`toggle-switch ${checked ? 'on' : 'off'}`}>
<span className="toggle-text">
{checked ? '启用' : '停用'}
</span>
<div className="toggle-slider"></div>
</div>
{label && (
<span className="toggle-label">{label}</span>
)}
</div>
);
};
export default ToggleSwitch;

View File

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

View File

@ -1407,6 +1407,7 @@ const MeetingDetails = ({ user }) => {
key={audioUrl || 'no-audio'} // 使audioUrlkeyaudio
ref={audioRef}
src={audioUrl}
style={{ display: 'none' }}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}

View File

@ -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 */
}
/* 加载状态 */

View File

@ -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 (
<div className="client-management">
<div className="loading">加载中...</div>
</div>
);
}
const columns = [
{
title: '应用名称',
key: 'app_name',
render: (app) => (
<div className="app-name-cell">
{app.icon_url && (
<img src={app.icon_url} alt="" className="app-icon-small" />
)}
<span>{app.app_name}</span>
</div>
)
},
{
title: '类型',
key: 'app_type',
render: (app) => (
<div className="app-type-badge">
{app.app_type === 'native' ? (
<>
<Smartphone size={14} />
<span>原生应用</span>
</>
) : (
<>
<Globe size={14} />
<span>Web应用</span>
</>
)}
</div>
)
},
{
title: '版本',
key: 'version',
render: (app) => getAppInfo(app).version_name || '-'
},
{
title: '详细信息',
key: 'info',
render: (app) => {
const appInfo = getAppInfo(app);
return (
<div className="info-cell">
{app.app_type === 'native' ? (
<div className="info-content">
<div>包名: {appInfo.package_name || '-'}</div>
{appInfo.apk_url && (
<a href={appInfo.apk_url} target="_blank" rel="noopener noreferrer" className="apk-link">
<ExternalLink size={12} />
下载APK
</a>
)}
</div>
) : (
<div className="info-content">
{appInfo.web_url && (
<a href={appInfo.web_url} target="_blank" rel="noopener noreferrer" className="web-link">
<ExternalLink size={12} />
{appInfo.web_url}
</a>
)}
</div>
)}
</div>
);
}
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
render: (item) => <div className="description-cell">{item.description || '-'}</div>
},
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
{
title: '状态',
key: 'status',
render: (app) => (
<ToggleSwitch
checked={app.is_active}
onChange={() => 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) => (
<div className="action-buttons" style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button
onClick={() => handleEditApp(app)}
title="编辑"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#3b82f6' }}
>
<Edit size={16} />
</button>
<button
onClick={() => setDeleteConfirmInfo({ id: app.id, name: app.app_name })}
title="删除"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#ef4444' }}
>
<Trash2 size={16} />
</button>
</div>
)
}
];
return (
<div className="client-management">
@ -383,344 +510,231 @@ const ExternalAppManagement = ({ user }) => {
</div>
{/* 应用列表 */}
<div className="apps-table-container">
<table className="apps-table">
<thead>
<tr>
<th>应用名称</th>
<th>类型</th>
<th>版本</th>
<th>详细信息</th>
<th>描述</th>
<th>排序</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredApps.length === 0 ? (
<tr>
<td colSpan="9" className="empty-state">
暂无数据
</td>
</tr>
) : (
filteredApps.map(app => {
const appInfo = getAppInfo(app);
return (
<tr key={app.id}>
<td>
<div className="app-name-cell">
{app.icon_url && (
<img src={app.icon_url} alt="" className="app-icon-small" />
)}
<span>{app.app_name}</span>
</div>
</td>
<td>
<div className="app-type-badge">
{app.app_type === 'native' ? (
<>
<Smartphone size={14} />
<span>原生应用</span>
</>
) : (
<>
<Globe size={14} />
<span>Web应用</span>
</>
)}
</div>
</td>
<td>{appInfo.version_name || '-'}</td>
<td className="info-cell">
{app.app_type === 'native' ? (
<div className="info-content">
<div>包名: {appInfo.package_name || '-'}</div>
{appInfo.apk_url && (
<a href={appInfo.apk_url} target="_blank" rel="noopener noreferrer" className="apk-link">
<ExternalLink size={12} />
下载APK
</a>
)}
</div>
) : (
<div className="info-content">
{appInfo.web_url && (
<a href={appInfo.web_url} target="_blank" rel="noopener noreferrer" className="web-link">
<ExternalLink size={12} />
{appInfo.web_url}
</a>
)}
</div>
)}
</td>
<td>
<div className="description-cell">
{app.description || '-'}
</div>
</td>
<td>{app.sort_order}</td>
<td>
<span className={`status-badge ${app.is_active ? 'active' : 'inactive'}`}>
{app.is_active ? '启用' : '禁用'}
</span>
</td>
<td>{new Date(app.created_at).toLocaleDateString()}</td>
<td>
<div className="action-buttons">
<button
className="btn-icon btn-edit"
onClick={() => handleEditApp(app)}
title="编辑"
>
<Edit size={16} />
</button>
<button
className="btn-icon btn-delete"
onClick={() => setDeleteConfirmInfo({ id: app.id, name: app.app_name })}
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<ListTable
columns={columns}
data={filteredApps}
loading={loading}
rowKey="id"
emptyMessage="暂无外部应用"
showPagination={false}
/>
{/* 添加/编辑应用模态框 */}
{showAppModal && (
<div className="modal-overlay" onClick={() => setShowAppModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{isEditing ? '编辑应用' : '添加应用'}</h2>
<button className="close-btn" onClick={() => setShowAppModal(false)}>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{/* 应用类型 */}
<div className="form-group">
<label>应用类型 *</label>
<select
value={formData.app_type}
onChange={(e) => handleFormChange('app_type', e.target.value)}
required
>
<option value="native">原生应用Android APK</option>
<option value="web">Web应用</option>
</select>
</div>
{/* 原生应用 - APK上传 */}
{formData.app_type === 'native' && (
<div className="form-group">
<label>上传APK文件</label>
<div className="upload-apk-section">
<input
type="file"
accept=".apk"
onChange={handleApkUpload}
disabled={uploadingApk}
id="apk-upload"
style={{ display: 'none' }}
/>
<label htmlFor="apk-upload" className="btn-upload">
<Upload size={16} />
{uploadingApk ? '解析中...' : '选择APK文件'}
</label>
<span className="upload-hint">上传后自动解析包名版本等信息</span>
</div>
</div>
)}
{/* 应用名称 */}
<div className="form-group">
<label>应用名称 *</label>
<input
type="text"
value={formData.app_name}
onChange={(e) => handleFormChange('app_name', e.target.value)}
placeholder="请输入应用名称"
required
/>
</div>
{/* 原生应用字段 */}
{formData.app_type === 'native' && (
<>
<div className="form-group">
<label>版本名称 *</label>
<input
type="text"
value={formData.app_info.version_name}
onChange={(e) => handleFormChange('app_info.version_name', e.target.value)}
placeholder="例如: 1.0.0"
required
/>
</div>
<div className="form-group">
<label>包名 *</label>
<input
type="text"
value={formData.app_info.package_name}
onChange={(e) => handleFormChange('app_info.package_name', e.target.value)}
placeholder="例如: com.example.app"
required
/>
</div>
<div className="form-group">
<label>APK下载链接 *</label>
<input
type="text"
value={formData.app_info.apk_url}
onChange={(e) => handleFormChange('app_info.apk_url', e.target.value)}
placeholder="APK文件的下载URL"
required
/>
</div>
</>
)}
{/* Web应用字段 */}
{formData.app_type === 'web' && (
<>
<div className="form-group">
<label>版本名称</label>
<input
type="text"
value={formData.app_info.version_name}
onChange={(e) => handleFormChange('app_info.version_name', e.target.value)}
placeholder="例如: 1.0(可选)"
/>
</div>
<div className="form-group">
<label>Web应用URL *</label>
<input
type="url"
value={formData.app_info.web_url}
onChange={(e) => handleFormChange('app_info.web_url', e.target.value)}
placeholder="https://example.com"
required
/>
</div>
</>
)}
{/* 应用图标 */}
<div className="form-group">
<label>应用图标</label>
<div className="upload-apk-section">
<input
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleIconUpload}
disabled={uploadingIcon}
id="icon-upload"
style={{ display: 'none' }}
/>
<label htmlFor="icon-upload" className="btn-upload">
<Upload size={16} />
{uploadingIcon ? '上传中...' : '选择图标'}
</label>
<span className="upload-hint">支持JPGPNGGIFWEBP格式</span>
</div>
{formData.icon_url && (
<div style={{ marginTop: '0.75rem' }}>
<img
src={formData.icon_url}
alt="应用图标预览"
style={{
width: '64px',
height: '64px',
borderRadius: '8px',
objectFit: 'cover',
border: '1px solid #e2e8f0'
}}
/>
</div>
)}
</div>
{/* 应用描述 */}
<div className="form-group">
<label>应用描述</label>
<textarea
value={formData.description}
onChange={(e) => handleFormChange('description', e.target.value)}
placeholder="请输入应用描述"
rows={3}
/>
</div>
{/* 排序 */}
<div className="form-group">
<label>排序顺序</label>
<input
type="number"
value={formData.sort_order}
onChange={(e) => handleFormChange('sort_order', e.target.value)}
placeholder="数字越小越靠前"
/>
</div>
{/* 启用状态 */}
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleFormChange('is_active', e.target.checked)}
/>
启用此应用
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowAppModal(false)}>
取消
</button>
<button type="submit" className="btn-primary">
{isEditing ? '更新' : '创建'}
</button>
</div>
</form>
</div>
<FormModal
isOpen={showAppModal}
onClose={() => setShowAppModal(false)}
title={isEditing ? '编辑应用' : '添加应用'}
size="medium"
actions={
<>
<button type="button" className="btn btn-secondary" onClick={() => setShowAppModal(false)}>
取消
</button>
<button type="button" className="btn btn-primary" onClick={handleSubmit}>
{isEditing ? '更新' : '创建'}
</button>
</>
}
>
{/* 应用类型 */}
<div className="form-group">
<label>应用类型 *</label>
<select
value={formData.app_type}
onChange={(e) => handleFormChange('app_type', e.target.value)}
required
>
<option value="native">原生应用Android APK</option>
<option value="web">Web应用</option>
</select>
</div>
)}
{/* 原生应用 - APK上传 */}
{formData.app_type === 'native' && (
<div className="form-group">
<label>上传APK文件</label>
<div className="upload-apk-section">
<input
type="file"
accept=".apk"
onChange={handleApkUpload}
disabled={uploadingApk}
id="apk-upload"
style={{ display: 'none' }}
/>
<label htmlFor="apk-upload" className="btn-upload">
<Upload size={16} />
{uploadingApk ? '解析中...' : '选择APK文件'}
</label>
<span className="upload-hint">上传后自动解析包名版本等信息</span>
</div>
</div>
)}
{/* 应用名称 */}
<div className="form-group">
<label>应用名称 *</label>
<input
type="text"
value={formData.app_name}
onChange={(e) => handleFormChange('app_name', e.target.value)}
placeholder="请输入应用名称"
required
/>
</div>
{/* 原生应用字段 */}
{formData.app_type === 'native' && (
<>
<div className="form-group">
<label>版本名称 *</label>
<input
type="text"
value={formData.app_info.version_name}
onChange={(e) => handleFormChange('app_info.version_name', e.target.value)}
placeholder="例如: 1.0.0"
required
/>
</div>
<div className="form-group">
<label>包名 *</label>
<input
type="text"
value={formData.app_info.package_name}
onChange={(e) => handleFormChange('app_info.package_name', e.target.value)}
placeholder="例如: com.example.app"
required
/>
</div>
<div className="form-group">
<label>APK下载链接 *</label>
<input
type="text"
value={formData.app_info.apk_url}
onChange={(e) => handleFormChange('app_info.apk_url', e.target.value)}
placeholder="APK文件的下载URL"
required
/>
</div>
</>
)}
{/* Web应用字段 */}
{formData.app_type === 'web' && (
<>
<div className="form-group">
<label>版本名称</label>
<input
type="text"
value={formData.app_info.version_name}
onChange={(e) => handleFormChange('app_info.version_name', e.target.value)}
placeholder="例如: 1.0(可选)"
/>
</div>
<div className="form-group">
<label>Web应用URL *</label>
<input
type="url"
value={formData.app_info.web_url}
onChange={(e) => handleFormChange('app_info.web_url', e.target.value)}
placeholder="https://example.com"
required
/>
</div>
</>
)}
{/* 应用图标 */}
<div className="form-group">
<label>应用图标</label>
<div className="upload-apk-section">
<input
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleIconUpload}
disabled={uploadingIcon}
id="icon-upload"
style={{ display: 'none' }}
/>
<label htmlFor="icon-upload" className="btn-upload">
<Upload size={16} />
{uploadingIcon ? '上传中...' : '选择图标'}
</label>
<span className="upload-hint">支持JPGPNGGIFWEBP格式</span>
</div>
{formData.icon_url && (
<div style={{ marginTop: '0.75rem' }}>
<img
src={formData.icon_url}
alt="应用图标预览"
style={{
width: '64px',
height: '64px',
borderRadius: '8px',
objectFit: 'cover',
border: '1px solid #e2e8f0'
}}
/>
</div>
)}
</div>
{/* 应用描述 */}
<div className="form-group">
<label>应用描述</label>
<textarea
value={formData.description}
onChange={(e) => handleFormChange('description', e.target.value)}
placeholder="请输入应用描述"
rows={3}
/>
</div>
{/* 排序 */}
<div className="form-group">
<label>排序顺序</label>
<input
type="number"
value={formData.sort_order}
onChange={(e) => handleFormChange('sort_order', e.target.value)}
placeholder="数字越小越靠前"
/>
</div>
{/* 启用状态 */}
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleFormChange('is_active', e.target.checked)}
/>
启用此应用
</label>
</div>
</FormModal>
{/* 删除确认对话框 */}
{deleteConfirmInfo && (
<ConfirmDialog
isOpen={true}
title="确认删除"
message={`确定要删除应用 "${deleteConfirmInfo.name}" 吗?此操作无法撤销。`}
onConfirm={handleDeleteApp}
onClose={() => setDeleteConfirmInfo(null)}
confirmText="删除"
cancelText="取消"
type="danger"
/>
)}
<ConfirmDialog
isOpen={!!deleteConfirmInfo}
onClose={() => setDeleteConfirmInfo(null)}
onConfirm={handleDeleteApp}
title="确认删除"
message={`确定要删除应用 "${deleteConfirmInfo?.name}" 吗?此操作无法撤销。`}
confirmText="删除"
cancelText="取消"
type="danger"
/>
{/* Toast 通知 */}
<div className="toast-container">
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
};

View File

@ -1,38 +1,194 @@
.hot-word-management {
padding: 16px 0;
padding: 1.5rem;
background-color: #f8fafc;
min-height: 100%;
}
.hot-word-management .status-bar {
background-color: #e6f7ff;
border: 1px solid #91d5ff;
padding: 10px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
color: #003a8c;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.hot-word-management .table-header {
margin-bottom: 24px;
.header-left h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.25rem;
}
.subtitle {
color: #64748b;
font-size: 0.875rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.vocab-info {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
font-size: 0.75rem;
color: #64748b;
margin-right: 1rem;
}
.hot-word-management .helper-text {
font-size: 13px;
color: #8c8c8c;
.vocab-info b {
color: #1e293b;
}
.animate-spin {
.helper-text-bar {
background: #eff6ff;
border: 1px solid #dbeafe;
color: #1e40af;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.weight-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.lang-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #f1f5f9;
color: #475569;
border-radius: 4px;
font-size: 0.875rem;
}
/* Status Toggle - 已迁移到 ToggleSwitch 组件 */
/* Buttons */
.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);
}
.btn-secondary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
color: #475569;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hot-word-management .btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.hot-word-management .btn-icon:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.hot-word-management .btn-edit { color: #3b82f6; }
.hot-word-management .btn-delete { color: #ef4444; background: white; }
.hot-word-management .btn-delete:hover { background: #fef2f2; border-color: #fecaca; }
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Form Styles */
.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 {
width: 100%;
padding: 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.875rem;
background: white;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-group input {
width: auto;
margin: 0;
}

View File

@ -1,18 +1,33 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Tag } from 'antd';
import { Plus, RefreshCw, Trash2, Edit } from 'lucide-react';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import Toast from '../../components/Toast';
import ListTable from '../../components/ListTable';
import FormModal from '../../components/FormModal';
import ConfirmDialog from '../../components/ConfirmDialog';
import ToggleSwitch from '../../components/ToggleSwitch';
import './HotWordManagement.css';
const HotWordManagement = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [syncLoading, setSyncLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [form] = Form.useForm();
// Modal State
const [showModal, setShowModal] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editingId, setEditingId] = useState(null);
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
// Form State
const [formData, setFormData] = useState({
text: '',
weight: 4,
lang: 'zh',
status: 1
});
const [vocabInfo, setVocabInfo] = useState(null);
const [toasts, setToasts] = useState([]);
@ -30,10 +45,8 @@ const HotWordManagement = () => {
setLoading(true);
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.LIST));
console.log('Fetch hot words response:', response);
// apiClient unwrap the code 200 responses, so response IS {code, message, data}
if (response.code === "200") {
setData(response.data);
setData(response.data || []);
} else {
showToast(response.message, 'error');
}
@ -52,12 +65,11 @@ const HotWordManagement = () => {
);
if (response.code === "200") {
const data = response.data;
// extension_attr is already parsed by backend
const vocabId = data.extension_attr?.value || '';
setVocabInfo({ ...data, vocabId });
}
} catch (error) {
// Ignore error if config not found
// Ignore error
}
};
@ -66,30 +78,65 @@ const HotWordManagement = () => {
fetchVocabInfo();
}, []);
useEffect(() => {
if (modalVisible) {
form.resetFields();
if (editingItem) {
form.setFieldsValue(editingItem);
} else {
form.setFieldsValue({ weight: 4, lang: 'zh', status: 1 });
}
const handleOpenAdd = () => {
setIsEditing(false);
setEditingId(null);
setFormData({ text: '', weight: 4, lang: 'zh', status: 1 });
setShowModal(true);
};
const handleOpenEdit = (item) => {
setIsEditing(true);
setEditingId(item.id);
setFormData({
text: item.text,
weight: item.weight,
lang: item.lang,
status: item.status
});
setShowModal(true);
};
const handleSubmit = async () => {
if (!formData.text) {
showToast('请输入热词内容', 'error');
return;
}
}, [modalVisible, editingItem, form]);
const handleAdd = () => {
setEditingItem(null);
setModalVisible(true);
};
const handleEdit = (record) => {
setEditingItem(record);
setModalVisible(true);
};
const handleDelete = async (id) => {
try {
const response = await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.DELETE(id)));
if (isEditing) {
const response = await apiClient.put(
buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.UPDATE(editingId)),
formData
);
if (response.code === "200") {
showToast('更新成功', 'success');
} else {
showToast(response.message, 'error');
}
} else {
const response = await apiClient.post(
buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.CREATE),
formData
);
if (response.code === "200") {
showToast('创建成功', 'success');
} else {
showToast(response.message, 'error');
}
}
setShowModal(false);
fetchHotWords();
} catch (error) {
console.error('Submit error:', error);
showToast('操作失败', 'error');
}
};
const handleDelete = async () => {
if (!deleteConfirmInfo) return;
try {
const response = await apiClient.delete(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.DELETE(deleteConfirmInfo.id)));
if (response.code === "200") {
showToast('删除成功', 'success');
fetchHotWords();
@ -98,15 +145,15 @@ const HotWordManagement = () => {
}
} catch (error) {
showToast('删除失败', 'error');
} finally {
setDeleteConfirmInfo(null);
}
};
const handleSync = async () => {
console.log('Starting sync...');
setSyncLoading(true);
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.SYNC));
console.log('Sync response:', response);
if (response.code === "200") {
showToast('同步到阿里云成功', 'success');
fetchVocabInfo();
@ -121,208 +168,191 @@ const HotWordManagement = () => {
}
};
const handleModalOk = async () => {
const handleToggleStatus = async (item) => {
try {
const values = await form.validateFields();
if (editingItem) {
const response = await apiClient.put(
buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.UPDATE(editingItem.id)),
values
);
if (response.code === "200") {
showToast('更新成功', 'success');
setModalVisible(false);
fetchHotWords();
} else {
showToast(response.message, 'error');
}
} else {
const response = await apiClient.post(
buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.CREATE),
values
);
if (response.code === "200") {
showToast('创建成功', 'success');
setModalVisible(false);
fetchHotWords();
} else {
showToast(response.message, 'error');
}
}
const newStatus = item.status === 1 ? 0 : 1;
await apiClient.put(
buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.UPDATE(item.id)),
{ status: newStatus }
);
// Optimistic update - don't refetch
setData(prev => prev.map(d =>
d.id === item.id ? { ...d, status: newStatus } : d
));
showToast(`${newStatus === 1 ? '启用' : '停用'}热词`, 'success');
} catch (error) {
console.error('Submit hot word error:', error);
showToast('更新状态失败', 'error');
}
};
const columns = [
{
title: '热词内容',
dataIndex: 'text',
key: 'text',
dataIndex: 'text'
},
{
title: '权重',
dataIndex: 'weight',
key: 'weight',
render: (weight) => <Tag color="blue">{weight}</Tag>
render: (item) => <span className="weight-badge">{item.weight}</span>
},
{
title: '语言',
dataIndex: 'lang',
key: 'lang',
render: (lang) => <Tag>{lang === 'zh' ? '中文' : '英文'}</Tag>
render: (item) => <span className="lang-badge">{item.lang === 'zh' ? '中文' : '英文'}</span>
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status, record) => (
<Switch
checked={status === 1}
onChange={async (checked) => {
try {
await apiClient.put(
buildApiUrl(API_ENDPOINTS.ADMIN.HOT_WORDS.UPDATE(record.id)),
{ status: checked ? 1 : 0 }
);
fetchHotWords();
} catch (error) {
showToast('更新状态失败', 'error');
}
}}
render: (item) => (
<ToggleSwitch
checked={item.status === 1}
onChange={() => handleToggleStatus(item)}
size="medium"
/>
)
},
{
title: '更新时间',
dataIndex: 'update_time',
key: 'update_time',
render: (time) => new Date(time).toLocaleString()
render: (item) => new Date(item.update_time).toLocaleString()
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button
type="text"
icon={<Edit size={16} />}
onClick={() => handleEdit(record)}
/>
<Popconfirm
title="确定要删除这个热词吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
fixed: 'right',
width: '110px',
render: (item) => (
<div className="action-buttons" style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button
onClick={() => handleOpenEdit(item)}
title="编辑"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#3b82f6' }}
>
<Button
type="text"
danger
icon={<Trash2 size={16} />}
/>
</Popconfirm>
</Space>
),
},
<Edit size={16} />
</button>
<button
onClick={() => setDeleteConfirmInfo({ id: item.id, title: item.text })}
title="删除"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#ef4444' }}
>
<Trash2 size={16} />
</button>
</div>
)
}
];
return (
<div className="hot-word-management">
{vocabInfo && (
<div className="status-bar">
<Space size="large">
<span>当前生效热词表 ID: <b>{vocabInfo.vocabId}</b></span>
<span>上次同步时间: {new Date(vocabInfo.update_time).toLocaleString()}</span>
</Space>
<div className="page-header">
<div className="header-left">
<h1>热词管理</h1>
<p className="subtitle">管理语音转录的自定义热词</p>
</div>
)}
<div className="table-header">
<Space>
<Button
type="primary"
icon={<Plus size={16} />}
onClick={handleAdd}
>
添加热词
</Button>
<Button
icon={<RefreshCw size={16} className={syncLoading ? 'animate-spin' : ''} />}
<div className="header-actions">
{vocabInfo && (
<div className="vocab-info">
<span>生效ID: <b>{vocabInfo.vocabId}</b></span>
<span>同步: {new Date(vocabInfo.update_time).toLocaleDateString()}</span>
</div>
)}
<button
className="btn-secondary"
onClick={handleSync}
loading={syncLoading}
disabled={syncLoading}
title="同步到阿里云"
>
同步到阿里云
</Button>
</Space>
<div className="helper-text">
提示修改热词后需点击同步到阿里云才能在转录中生效权重越高识别概率越大
<RefreshCw size={16} className={syncLoading ? 'spin' : ''} />
同步
</button>
<button className="btn-primary" onClick={handleOpenAdd}>
<Plus size={16} />
添加热词
</button>
</div>
</div>
<Table
<div className="helper-text-bar">
提示修改热词后需点击同步才能在转录中生效权重越高识别概率越大 (1-10)
</div>
<ListTable
columns={columns}
dataSource={data}
rowKey="id"
data={data}
loading={loading}
rowKey="id"
emptyMessage="暂无热词"
showPagination={false}
/>
<Modal
title={editingItem ? "编辑热词" : "添加热词"}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
forceRender
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={isEditing ? "编辑热词" : "添加热词"}
actions={
<>
<button className="btn-secondary" onClick={() => setShowModal(false)}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>保存</button>
</>
}
>
<div style={{ paddingTop: '10px' }}>
<Form
form={form}
layout="vertical"
preserve={false}
<div className="form-group">
<label>热词内容 *</label>
<input
type="text"
value={formData.text}
onChange={(e) => setFormData({...formData, text: e.target.value})}
placeholder="例如:通义千问"
/>
</div>
<div className="form-group">
<label>权重 (1-10)</label>
<input
type="number"
min="1" max="10"
value={formData.weight}
onChange={(e) => setFormData({...formData, weight: parseInt(e.target.value) || 4})}
/>
</div>
<div className="form-group">
<label>语言</label>
<select
value={formData.lang}
onChange={(e) => setFormData({...formData, lang: e.target.value})}
>
<Form.Item
name="text"
label="热词内容"
rules={[{ required: true, message: '请输入热词内容' }]}
>
<Input placeholder="例如:通义千问" />
</Form.Item>
<Form.Item
name="weight"
label="权重 (1-10)"
rules={[{ required: true, message: '请输入权重' }]}
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="lang"
label="语言"
rules={[{ required: true }]}
>
<Input placeholder="zh 或 en" />
</Form.Item>
<Form.Item
name="status"
label="启用状态"
valuePropName="checked"
getValueProps={(value) => ({ checked: value === 1 })}
getValueFromEvent={(checked) => (checked ? 1 : 0)}
>
<Switch />
</Form.Item>
</Form>
</div>
</Modal>
<option value="zh">中文</option>
<option value="en">英文</option>
</select>
</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>
{/* Toast notifications */}
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
{deleteConfirmInfo && (
<ConfirmDialog
isOpen={true}
title="删除热词"
message={`确定要删除热词 "${deleteConfirmInfo.title}" 吗?`}
onConfirm={handleDelete}
onClose={() => setDeleteConfirmInfo(null)}
type="danger"
/>
)}
{toasts.map(t => (
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
))}
</div>
);
};
export default HotWordManagement;
</div>
);
};
export default HotWordManagement;

View File

@ -425,6 +425,38 @@
color: #1e40af;
}
/* 描述行样式 */
.prompt-desc-row {
padding: 1rem 2rem;
border-bottom: 1px solid #e9ecef;
background: #fafbfc;
}
.prompt-desc-input {
width: 100%;
min-height: 60px;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
color: #475569;
resize: vertical;
outline: none;
transition: all 0.2s ease;
box-sizing: border-box;
}
.prompt-desc-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
background: white;
}
.prompt-desc-input::placeholder {
color: #94a3b8;
}
/* Switch 开关样式 */
.switch-label {
display: flex;

View File

@ -24,7 +24,7 @@ const PromptManagement = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [newPromptData, setNewPromptData] = useState({ name: '', task_type: 'MEETING_TASK' });
const [newPromptData, setNewPromptData] = useState({ name: '', task_type: 'MEETING_TASK', desc: '' });
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const [toasts, setToasts] = useState([]);
const [isSaving, setIsSaving] = useState(false);
@ -64,13 +64,13 @@ const PromptManagement = () => {
};
const handleOpenCreateModal = () => {
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
setNewPromptData({ name: '', task_type: 'MEETING_TASK', desc: '' });
setShowCreateModal(true);
};
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setNewPromptData({ name: '', task_type: 'MEETING_TASK' });
setNewPromptData({ name: '', task_type: 'MEETING_TASK', desc: '' });
};
const handleCreatePrompt = async () => {
@ -83,6 +83,7 @@ const PromptManagement = () => {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), {
name: newPromptData.name,
task_type: newPromptData.task_type,
desc: newPromptData.desc || '',
content: '',
is_default: false,
is_active: true
@ -114,7 +115,7 @@ const PromptManagement = () => {
setEditingPrompt(prev => ({ ...prev, [field]: value }));
};
//
//
const handleSaveTitle = async () => {
if (!editingPrompt || !editingPrompt.id || !editingPrompt.name.trim()) {
showToast('标题不能为空', 'warning');
@ -127,6 +128,7 @@ const PromptManagement = () => {
name: editingPrompt.name,
task_type: editingPrompt.task_type,
content: editingPrompt.content || '',
desc: editingPrompt.desc || '',
is_default: Boolean(editingPrompt.is_default),
is_active: editingPrompt.is_active !== false
};
@ -141,7 +143,8 @@ const PromptManagement = () => {
//
const fullUpdatedPrompt = {
...prompts.find(p => p.id === editingPrompt.id),
name: editingPrompt.name
name: editingPrompt.name,
desc: editingPrompt.desc
};
//
@ -183,6 +186,7 @@ const PromptManagement = () => {
name: updatedPrompt.name,
task_type: updatedPrompt.task_type,
content: updatedPrompt.content || '',
desc: updatedPrompt.desc || '',
is_default: Boolean(updatedPrompt.is_default),
is_active: updatedPrompt.is_active !== false
};
@ -247,6 +251,7 @@ const PromptManagement = () => {
name: editingPrompt.name,
task_type: editingPrompt.task_type,
content: editingPrompt.content || '',
desc: editingPrompt.desc || '',
is_default: Boolean(editingPrompt.is_default),
is_active: editingPrompt.is_active !== false
};
@ -513,6 +518,17 @@ const PromptManagement = () => {
</div>
</div>
{/* 描述字段 */}
<div className="prompt-desc-row">
<textarea
className="prompt-desc-input"
value={editingPrompt.desc || ''}
onChange={(e) => handleEditChange('desc', e.target.value)}
placeholder="添加模版描述(描述此模版的使用场景)..."
rows={2}
/>
</div>
{/* 第二行:任务类型 + 设为默认 + 启用开关 */}
<div className="prompt-controls-row">
<span className="task-type-badge">
@ -603,6 +619,16 @@ const PromptManagement = () => {
<option value="KNOWLEDGE_TASK">知识库任务</option>
</select>
</div>
<div className="form-group">
<label><MessageSquare size={16} /> 描述可选</label>
<textarea
value={newPromptData.desc}
onChange={(e) => setNewPromptData(prev => ({ ...prev, desc: e.target.value }))}
placeholder="描述此模版的使用场景..."
rows={3}
/>
</div>
</FormModal>
{/* 删除提示词确认对话框 */}

View File

@ -164,26 +164,30 @@
border-color: #cbd5e1;
}
.btn-icon {
width: 36px;
height: 36px;
.terminal-management .btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e2e8f0;
border-radius: 8px;
border-radius: 6px;
background: white;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.btn-icon:hover {
background: #f1f5f9;
.terminal-management .btn-icon:hover {
background: #f8fafc;
color: #334155;
border-color: #cbd5e1;
}
.terminal-management .btn-edit { color: #3b82f6; }
.terminal-management .btn-delete { color: #ef4444; background: white; } /* Ensure white background override */
.terminal-management .btn-delete:hover { background: #fef2f2; border-color: #fecaca; }
/* Table */
.table-container {
background: white;
@ -245,49 +249,7 @@
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 Toggle - 已迁移到 ToggleSwitch 组件 */
/* Status Dot */
.status-dot {
@ -341,25 +303,38 @@
}
/* Pagination */
.pagination {
.terminal-management .pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.5rem;
padding: 1rem 0.5rem;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.pagination-info {
.terminal-management .pagination::before,
.terminal-management .pagination::after,
.terminal-management .pagination-controls::before,
.terminal-management .pagination-controls::after {
display: none !important;
content: none !important;
}
.terminal-management .pagination-info {
color: #64748b;
font-size: 0.875rem;
border: none !important;
}
.pagination-controls {
.terminal-management .pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
border: none !important;
}
.pagination-controls button {
.terminal-management .pagination-controls button {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
@ -369,17 +344,17 @@
cursor: pointer;
}
.pagination-controls button:disabled {
.terminal-management .pagination-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-controls button:not(:disabled):hover {
.terminal-management .pagination-controls button:not(:disabled):hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.page-number {
.terminal-management .page-number {
font-weight: 600;
color: #1e293b;
min-width: 1.5rem;

View File

@ -17,6 +17,8 @@ import ConfirmDialog from '../../components/ConfirmDialog';
import Toast from '../../components/Toast';
import PageLoading from '../../components/PageLoading';
import FormModal from '../../components/FormModal';
import ListTable from '../../components/ListTable';
import ToggleSwitch from '../../components/ToggleSwitch';
import './TerminalManagement.css';
const TerminalManagement = () => {
@ -213,6 +215,101 @@ const TerminalManagement = () => {
return type ? type.label_cn : code;
};
const columns = [
{
title: 'IMEI',
dataIndex: 'imei',
key: 'imei',
minWidth: '160px',
render: (item) => <span className="cell-mono cell-truncate" title={item.imei}>{item.imei}</span>
},
{
title: '终端名称',
dataIndex: 'terminal_name',
key: 'terminal_name',
minWidth: '150px',
render: (item) => <span className="cell-truncate" title={item.terminal_name}>{item.terminal_name || '-'}</span>
},
{
title: '类型',
key: 'terminal_type',
render: (item) => (
<span className="type-badge">
{getTerminalTypeLabel(item.terminal_type)}
</span>
)
},
{
title: '当前绑定账号',
key: 'current_user',
render: (item) => (
item.current_user_caption ? (
<div className="user-info cell-truncate" title={`${item.current_user_caption} (${item.current_username || ''})`}>
<span className="user-name">{item.current_user_caption}</span>
{item.current_username && <span className="user-sub text-xs text-gray-500">({item.current_username})</span>}
</div>
) : (
<span className="text-gray-400">-</span>
)
)
},
{
title: '状态',
key: 'status',
render: (item) => (
<ToggleSwitch
checked={item.status === 1}
onChange={() => handleToggleStatus(item)}
size="medium"
/>
)
},
{
title: '激活状态',
key: 'is_activated',
render: (item) => (
<>
<span className={`status-dot ${item.is_activated === 1 ? 'active' : 'inactive'}`}></span>
{item.is_activated === 1 ? '已激活' : '未激活'}
</>
)
},
{
title: '最后在线',
key: 'last_online_at',
render: (item) => item.last_online_at ? new Date(item.last_online_at).toLocaleString() : '-'
},
{
title: '创建时间',
key: 'created_at',
render: (item) => new Date(item.created_at).toLocaleDateString()
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: '110px',
render: (item) => (
<div className="action-buttons" style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button
onClick={() => handleOpenEdit(item)}
title="编辑"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#3b82f6' }}
>
<Edit size={16} />
</button>
<button
onClick={() => setDeleteConfirmInfo({ id: item.id, name: item.imei })}
title="删除"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#ef4444' }}
>
<Trash2 size={16} />
</button>
</div>
)
}
];
if (loading && terminals.length === 0) {
return <PageLoading message="加载终端数据..." />;
}
@ -276,108 +373,17 @@ const TerminalManagement = () => {
</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>
<th>操作</th>
</tr>
</thead>
<tbody>
{paginatedTerminals.length === 0 ? (
<tr>
<td colSpan="9" className="empty-state">暂无数据</td>
</tr>
) : (
paginatedTerminals.map(terminal => (
<tr key={terminal.id}>
<td className="font-mono truncate-cell" title={terminal.imei}>{terminal.imei}</td>
<td className="truncate-cell" title={terminal.terminal_name}>{terminal.terminal_name || '-'}</td>
<td>
<span className="type-badge">
{getTerminalTypeLabel(terminal.terminal_type)}
</span>
</td>
<td>
{terminal.current_user_caption ? (
<div className="user-info truncate-cell" title={`${terminal.current_user_caption} (${terminal.current_username || ''})`}>
<span className="user-name">{terminal.current_user_caption}</span>
{terminal.current_username && <span className="user-sub text-xs text-gray-500">({terminal.current_username})</span>}
</div>
) : (
<span className="text-gray-400">-</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>
)}
<ListTable
columns={columns}
data={paginatedTerminals}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: (p) => setPage(p)
}}
showPagination={true}
/>
{/* 弹窗 */}
<FormModal

View File

@ -143,14 +143,14 @@
text-align: center;
}
.pagination {
.user-management .pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
}
.pagination button {
.user-management .pagination button {
padding: 0.5rem 1rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
@ -159,17 +159,17 @@
transition: all 0.3s ease;
}
.pagination button:hover:not(:disabled) {
.user-management .pagination button:hover:not(:disabled) {
background: #f8fafc;
transform: translateY(-1px);
}
.pagination button:disabled {
.user-management .pagination button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.pagination span {
.user-management .pagination span {
margin: 0 1rem;
color: #64748b;
}

View File

@ -5,6 +5,7 @@ import { Plus, Edit, Trash2, KeyRound, User, Mail, Shield, Search, X } from 'luc
import ConfirmDialog from '../../components/ConfirmDialog';
import FormModal from '../../components/FormModal';
import Toast from '../../components/Toast';
import ListTable from '../../components/ListTable';
import './UserManagement.css';
const UserManagement = () => {
@ -170,6 +171,51 @@ const UserManagement = () => {
setResetConfirmInfo({ user_id: user.user_id, caption: user.caption });
};
const columns = [
{ title: 'ID', dataIndex: 'user_id', key: 'user_id' },
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '姓名', dataIndex: 'caption', key: 'caption' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '角色', dataIndex: 'role_name', key: 'role_name' },
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
render: (item) => new Date(item.created_at).toLocaleString()
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: '150px',
render: (item) => (
<div className="action-buttons" style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button
onClick={() => handleOpenModal(item)}
title="修改"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#3b82f6' }}
>
<Edit size={16} />
</button>
<button
onClick={() => openDeleteConfirm(item)}
title="删除"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#ef4444' }}
>
<Trash2 size={16} />
</button>
<button
onClick={() => openResetConfirm(item)}
title="重置密码"
style={{ width: '32px', height: '32px', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', background: 'white', color: '#f59e0b' }}
>
<KeyRound size={16} />
</button>
</div>
)
}
];
return (
<div className="user-management">
<div className="toolbar">
@ -195,50 +241,22 @@ const UserManagement = () => {
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
</div>
</div>
{loading && <p>加载中...</p>}
{error && <p className="error-message">{error}</p>}
{!loading && !error && (
<>
<table className="users-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>姓名</th>
<th>邮箱</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.user_id}>
<td>{user.user_id}</td>
<td>{user.username}</td>
<td>{user.caption}</td>
<td>{user.email}</td>
<td>{user.role_name}</td>
<td>{new Date(user.created_at).toLocaleString()}</td>
<td className="action-cell">
<button className="action-btn" onClick={() => handleOpenModal(user)} title="修改"><Edit size={16} />修改</button>
<button className="action-btn btn-danger" onClick={() => openDeleteConfirm(user)} title="删除"><Trash2 size={16} />删除</button>
<button className="action-btn btn-warning" onClick={() => openResetConfirm(user)} title="重置密码"><KeyRound size={16} />重置</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="pagination">
<span>总计 {total} </span>
<div>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>上一页</button>
<span> {page} / {Math.ceil(total / pageSize)}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * pageSize >= total}>下一页</button>
</div>
</div>
</>
)}
<ListTable
columns={columns}
data={users}
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: (p) => setPage(p)
}}
rowKey="user_id"
showPagination={true}
/>
{/* 用户表单模态框 */}
<FormModal