修复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 name: str
task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK' task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK'
content: str content: str
desc: Optional[str] = None # 模版描述
is_default: bool = False is_default: bool = False
is_active: bool = True is_active: bool = True
@ -39,10 +40,10 @@ def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_use
) )
cursor.execute( cursor.execute(
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id) """INSERT INTO prompts (name, task_type, content, desc, is_default, is_active, creator_id)
VALUES (%s, %s, %s, %s, %s, %s)""", VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(prompt.name, prompt.task_type, prompt.content, prompt.is_default, (prompt.name, prompt.task_type, prompt.content, prompt.desc,
prompt.is_active, current_user["user_id"]) prompt.is_default, prompt.is_active, current_user["user_id"])
) )
connection.commit() connection.commit()
new_id = cursor.lastrowid 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}") @router.get("/prompts/active/{task_type}")
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)): 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: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cursor.execute( cursor.execute(
"""SELECT id, name, is_default """SELECT id, name, desc, content, is_default
FROM prompts FROM prompts
WHERE task_type = %s AND is_active = TRUE WHERE task_type = %s AND is_active = TRUE
AND (creator_id = 1 OR creator_id = %s)
ORDER BY is_default DESC, created_at DESC""", ORDER BY is_default DESC, created_at DESC""",
(task_type,) (task_type, current_user["user_id"])
) )
prompts = cursor.fetchall() prompts = cursor.fetchall()
return create_api_response( return create_api_response(
@ -106,7 +113,7 @@ def get_prompts(
# 获取分页数据 # 获取分页数据
offset = (page - 1) * size offset = (page - 1) * size
cursor.execute( 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 FROM prompts
WHERE {where_clause} WHERE {where_clause}
ORDER BY created_at DESC 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: with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True) cursor = connection.cursor(dictionary=True)
cursor.execute( 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""", FROM prompts WHERE id = %s""",
(prompt_id,) (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") print(f"[UPDATE PROMPT] Executing UPDATE query")
cursor.execute( cursor.execute(
"""UPDATE prompts """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""", 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) prompt.is_active, prompt_id)
) )
rows_affected = cursor.rowcount 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; border-left: 3px solid #667eea;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
} }
@ -448,6 +447,11 @@
font-size: 0.9rem; font-size: 0.9rem;
color: #475569; color: #475569;
font-weight: 500; font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.audio-loading-hint { .audio-loading-hint {
@ -655,6 +659,10 @@
border-radius: 50%; border-radius: 50%;
width: 50px; width: 50px;
height: 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -662,6 +670,7 @@
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
outline: none; /* 去掉点击后的蓝色边框 */ outline: none; /* 去掉点击后的蓝色边框 */
flex-shrink: 0; /* Prevent shrinking */
} }
.play-button:focus { .play-button:focus {
@ -714,6 +723,7 @@
font-weight: 500; font-weight: 500;
min-width: 50px; min-width: 50px;
text-align: center; text-align: center;
flex-shrink: 0;
} }
.progress-container { .progress-container {
@ -725,48 +735,16 @@
padding: 0 1rem; padding: 0 1rem;
margin: 0 0.5rem; margin: 0 0.5rem;
user-select: none; user-select: none;
min-width: 100px; /* Ensure minimum width */
} }
.progress-bar { /* ... existing styles ... */
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;
}
.volume-control { .volume-control {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
} }
.volume-slider { .volume-slider {

View File

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

View File

@ -57,6 +57,14 @@
flex-wrap: wrap; 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 { .filter-group {
display: flex; display: flex;
align-items: center; align-items: center;
@ -258,6 +266,50 @@
white-space: nowrap; 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 { .status-badge {
display: inline-block; display: inline-block;
@ -284,13 +336,13 @@
align-items: center; align-items: center;
} }
.btn-icon { .client-management .btn-icon {
width: 36px; width: 32px;
height: 36px; height: 32px;
min-width: 36px; min-width: 32px;
min-height: 36px; min-height: 32px;
padding: 0; padding: 0;
border: none; border: 1px solid #e2e8f0;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -298,35 +350,35 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; 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; display: block;
width: 16px !important; width: 16px !important;
height: 16px !important; height: 16px !important;
flex-shrink: 0; flex-shrink: 0;
} }
.btn-edit { .client-management .btn-edit {
background: #dbeafe;
color: #3b82f6; color: #3b82f6;
} }
.btn-edit:hover { .client-management .btn-delete {
background: #3b82f6;
color: white;
transform: translateY(-1px);
}
.btn-delete {
background: #fee2e2;
color: #ef4444; color: #ef4444;
background: white;
} }
.btn-delete:hover { .client-management .btn-delete:hover {
background: #ef4444; background: #fef2f2;
color: white; border-color: #fecaca;
transform: translateY(-1px); 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 { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import ConfirmDialog from '../../components/ConfirmDialog'; import ConfirmDialog from '../../components/ConfirmDialog';
import Toast from '../../components/Toast'; import Toast from '../../components/Toast';
import FormModal from '../../components/FormModal';
import ToggleSwitch from '../../components/ToggleSwitch';
import ListTable from '../../components/ListTable';
import './ExternalAppManagement.css'; import './ExternalAppManagement.css';
const ExternalAppManagement = ({ user }) => { const ExternalAppManagement = ({ user }) => {
@ -59,6 +62,22 @@ const ExternalAppManagement = ({ user }) => {
fetchApps(); 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 () => { const fetchApps = async () => {
setLoading(true); setLoading(true);
try { try {
@ -247,9 +266,7 @@ const ExternalAppManagement = ({ user }) => {
} }
}; };
const handleSubmit = async (e) => { const handleSubmit = async () => {
e.preventDefault();
// //
if (!formData.app_name) { if (!formData.app_name) {
showToast('请输入应用名称', 'error'); showToast('请输入应用名称', 'error');
@ -330,13 +347,123 @@ const ExternalAppManagement = ({ user }) => {
return app.app_info || {}; return app.app_info || {};
}; };
if (loading) { 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 ( return (
<div className="client-management"> <div className="info-cell">
<div className="loading">加载中...</div> {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> </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 ( return (
<div className="client-management"> <div className="client-management">
@ -383,129 +510,32 @@ const ExternalAppManagement = ({ user }) => {
</div> </div>
{/* 应用列表 */} {/* 应用列表 */}
<div className="apps-table-container"> <ListTable
<table className="apps-table"> columns={columns}
<thead> data={filteredApps}
<tr> loading={loading}
<th>应用名称</th> rowKey="id"
<th>类型</th> emptyMessage="暂无外部应用"
<th>版本</th> showPagination={false}
<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>
{/* 添加/编辑应用模态框 */} {/* 添加/编辑应用模态框 */}
{showAppModal && ( <FormModal
<div className="modal-overlay" onClick={() => setShowAppModal(false)}> isOpen={showAppModal}
<div className="modal-content" onClick={(e) => e.stopPropagation()}> onClose={() => setShowAppModal(false)}
<div className="modal-header"> title={isEditing ? '编辑应用' : '添加应用'}
<h2>{isEditing ? '编辑应用' : '添加应用'}</h2> size="medium"
<button className="close-btn" onClick={() => setShowAppModal(false)}> actions={
<X size={20} /> <>
<button type="button" className="btn btn-secondary" onClick={() => setShowAppModal(false)}>
取消
</button> </button>
</div> <button type="button" className="btn btn-primary" onClick={handleSubmit}>
<form onSubmit={handleSubmit}> {isEditing ? '更新' : '创建'}
<div className="modal-body"> </button>
</>
}
>
{/* 应用类型 */} {/* 应用类型 */}
<div className="form-group"> <div className="form-group">
<label>应用类型 *</label> <label>应用类型 *</label>
@ -682,36 +712,21 @@ const ExternalAppManagement = ({ user }) => {
启用此应用 启用此应用
</label> </label>
</div> </div>
</div> </FormModal>
<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>
</div>
)}
{/* 删除确认对话框 */} {/* 删除确认对话框 */}
{deleteConfirmInfo && (
<ConfirmDialog <ConfirmDialog
isOpen={true} isOpen={!!deleteConfirmInfo}
title="确认删除"
message={`确定要删除应用 "${deleteConfirmInfo.name}" 吗?此操作无法撤销。`}
onConfirm={handleDeleteApp}
onClose={() => setDeleteConfirmInfo(null)} onClose={() => setDeleteConfirmInfo(null)}
onConfirm={handleDeleteApp}
title="确认删除"
message={`确定要删除应用 "${deleteConfirmInfo?.name}" 吗?此操作无法撤销。`}
confirmText="删除" confirmText="删除"
cancelText="取消" cancelText="取消"
type="danger" type="danger"
/> />
)}
{/* Toast 通知 */} {/* Toast 通知 */}
<div className="toast-container">
{toasts.map(toast => ( {toasts.map(toast => (
<Toast <Toast
key={toast.id} key={toast.id}
@ -721,7 +736,6 @@ const ExternalAppManagement = ({ user }) => {
/> />
))} ))}
</div> </div>
</div>
); );
}; };

View File

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

View File

@ -425,6 +425,38 @@
color: #1e40af; 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 开关样式 */
.switch-label { .switch-label {
display: flex; display: flex;

View File

@ -24,7 +24,7 @@ const PromptManagement = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false); 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 [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
const [toasts, setToasts] = useState([]); const [toasts, setToasts] = useState([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -64,13 +64,13 @@ const PromptManagement = () => {
}; };
const handleOpenCreateModal = () => { const handleOpenCreateModal = () => {
setNewPromptData({ name: '', task_type: 'MEETING_TASK' }); setNewPromptData({ name: '', task_type: 'MEETING_TASK', desc: '' });
setShowCreateModal(true); setShowCreateModal(true);
}; };
const handleCloseCreateModal = () => { const handleCloseCreateModal = () => {
setShowCreateModal(false); setShowCreateModal(false);
setNewPromptData({ name: '', task_type: 'MEETING_TASK' }); setNewPromptData({ name: '', task_type: 'MEETING_TASK', desc: '' });
}; };
const handleCreatePrompt = async () => { const handleCreatePrompt = async () => {
@ -83,6 +83,7 @@ const PromptManagement = () => {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), { const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.PROMPTS.CREATE), {
name: newPromptData.name, name: newPromptData.name,
task_type: newPromptData.task_type, task_type: newPromptData.task_type,
desc: newPromptData.desc || '',
content: '', content: '',
is_default: false, is_default: false,
is_active: true is_active: true
@ -114,7 +115,7 @@ const PromptManagement = () => {
setEditingPrompt(prev => ({ ...prev, [field]: value })); setEditingPrompt(prev => ({ ...prev, [field]: value }));
}; };
// //
const handleSaveTitle = async () => { const handleSaveTitle = async () => {
if (!editingPrompt || !editingPrompt.id || !editingPrompt.name.trim()) { if (!editingPrompt || !editingPrompt.id || !editingPrompt.name.trim()) {
showToast('标题不能为空', 'warning'); showToast('标题不能为空', 'warning');
@ -127,6 +128,7 @@ const PromptManagement = () => {
name: editingPrompt.name, name: editingPrompt.name,
task_type: editingPrompt.task_type, task_type: editingPrompt.task_type,
content: editingPrompt.content || '', content: editingPrompt.content || '',
desc: editingPrompt.desc || '',
is_default: Boolean(editingPrompt.is_default), is_default: Boolean(editingPrompt.is_default),
is_active: editingPrompt.is_active !== false is_active: editingPrompt.is_active !== false
}; };
@ -141,7 +143,8 @@ const PromptManagement = () => {
// //
const fullUpdatedPrompt = { const fullUpdatedPrompt = {
...prompts.find(p => p.id === editingPrompt.id), ...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, name: updatedPrompt.name,
task_type: updatedPrompt.task_type, task_type: updatedPrompt.task_type,
content: updatedPrompt.content || '', content: updatedPrompt.content || '',
desc: updatedPrompt.desc || '',
is_default: Boolean(updatedPrompt.is_default), is_default: Boolean(updatedPrompt.is_default),
is_active: updatedPrompt.is_active !== false is_active: updatedPrompt.is_active !== false
}; };
@ -247,6 +251,7 @@ const PromptManagement = () => {
name: editingPrompt.name, name: editingPrompt.name,
task_type: editingPrompt.task_type, task_type: editingPrompt.task_type,
content: editingPrompt.content || '', content: editingPrompt.content || '',
desc: editingPrompt.desc || '',
is_default: Boolean(editingPrompt.is_default), is_default: Boolean(editingPrompt.is_default),
is_active: editingPrompt.is_active !== false is_active: editingPrompt.is_active !== false
}; };
@ -513,6 +518,17 @@ const PromptManagement = () => {
</div> </div>
</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"> <div className="prompt-controls-row">
<span className="task-type-badge"> <span className="task-type-badge">
@ -603,6 +619,16 @@ const PromptManagement = () => {
<option value="KNOWLEDGE_TASK">知识库任务</option> <option value="KNOWLEDGE_TASK">知识库任务</option>
</select> </select>
</div> </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> </FormModal>
{/* 删除提示词确认对话框 */} {/* 删除提示词确认对话框 */}

View File

@ -164,26 +164,30 @@
border-color: #cbd5e1; border-color: #cbd5e1;
} }
.btn-icon { .terminal-management .btn-icon {
width: 36px; width: 32px;
height: 36px; height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 6px;
background: white; background: white;
color: #64748b; color: #64748b;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.btn-icon:hover { .terminal-management .btn-icon:hover {
background: #f1f5f9; background: #f8fafc;
color: #334155; color: #334155;
border-color: #cbd5e1; 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 */
.table-container { .table-container {
background: white; background: white;
@ -245,49 +249,7 @@
color: #4338ca; color: #4338ca;
} }
/* Status Toggle */ /* Status Toggle - 已迁移到 ToggleSwitch 组件 */
.status-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.toggle-switch {
width: 36px;
height: 20px;
border-radius: 999px;
position: relative;
transition: background-color 0.2s;
}
.toggle-switch.on {
background-color: #10b981;
}
.toggle-switch.off {
background-color: #cbd5e1;
}
.toggle-slider {
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.toggle-switch.on .toggle-slider {
transform: translateX(16px);
}
.status-text {
font-size: 0.875rem;
color: #64748b;
}
/* Status Dot */ /* Status Dot */
.status-dot { .status-dot {
@ -341,25 +303,38 @@
} }
/* Pagination */ /* Pagination */
.pagination { .terminal-management .pagination {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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; color: #64748b;
font-size: 0.875rem; font-size: 0.875rem;
border: none !important;
} }
.pagination-controls { .terminal-management .pagination-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border: none !important;
} }
.pagination-controls button { .terminal-management .pagination-controls button {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 6px; border-radius: 6px;
@ -369,17 +344,17 @@
cursor: pointer; cursor: pointer;
} }
.pagination-controls button:disabled { .terminal-management .pagination-controls button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.pagination-controls button:not(:disabled):hover { .terminal-management .pagination-controls button:not(:disabled):hover {
background: #f8fafc; background: #f8fafc;
border-color: #cbd5e1; border-color: #cbd5e1;
} }
.page-number { .terminal-management .page-number {
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
min-width: 1.5rem; min-width: 1.5rem;

View File

@ -17,6 +17,8 @@ import ConfirmDialog from '../../components/ConfirmDialog';
import Toast from '../../components/Toast'; import Toast from '../../components/Toast';
import PageLoading from '../../components/PageLoading'; import PageLoading from '../../components/PageLoading';
import FormModal from '../../components/FormModal'; import FormModal from '../../components/FormModal';
import ListTable from '../../components/ListTable';
import ToggleSwitch from '../../components/ToggleSwitch';
import './TerminalManagement.css'; import './TerminalManagement.css';
const TerminalManagement = () => { const TerminalManagement = () => {
@ -213,6 +215,101 @@ const TerminalManagement = () => {
return type ? type.label_cn : code; 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) { if (loading && terminals.length === 0) {
return <PageLoading message="加载终端数据..." />; return <PageLoading message="加载终端数据..." />;
} }
@ -276,108 +373,17 @@ const TerminalManagement = () => {
</div> </div>
</div> </div>
<div className="table-container"> <ListTable
<table className="data-table"> columns={columns}
<thead> data={paginatedTerminals}
<tr> pagination={{
<th>IMEI</th> current: page,
<th>终端名称</th> pageSize: pageSize,
<th>类型</th> total: total,
<th>当前绑定账号</th> onChange: (p) => setPage(p)
<th>状态</th> }}
<th>激活状态</th> showPagination={true}
<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>
)}
{/* 弹窗 */} {/* 弹窗 */}
<FormModal <FormModal

View File

@ -143,14 +143,14 @@
text-align: center; text-align: center;
} }
.pagination { .user-management .pagination {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.pagination button { .user-management .pagination button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@ -159,17 +159,17 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.pagination button:hover:not(:disabled) { .user-management .pagination button:hover:not(:disabled) {
background: #f8fafc; background: #f8fafc;
transform: translateY(-1px); transform: translateY(-1px);
} }
.pagination button:disabled { .user-management .pagination button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.pagination span { .user-management .pagination span {
margin: 0 1rem; margin: 0 1rem;
color: #64748b; 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 ConfirmDialog from '../../components/ConfirmDialog';
import FormModal from '../../components/FormModal'; import FormModal from '../../components/FormModal';
import Toast from '../../components/Toast'; import Toast from '../../components/Toast';
import ListTable from '../../components/ListTable';
import './UserManagement.css'; import './UserManagement.css';
const UserManagement = () => { const UserManagement = () => {
@ -170,6 +171,51 @@ const UserManagement = () => {
setResetConfirmInfo({ user_id: user.user_id, caption: user.caption }); 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 ( return (
<div className="user-management"> <div className="user-management">
<div className="toolbar"> <div className="toolbar">
@ -195,50 +241,22 @@ const UserManagement = () => {
<button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button> <button className="btn btn-primary" onClick={() => handleOpenModal()}><Plus size={16} /> 新增用户</button>
</div> </div>
</div> </div>
{loading && <p>加载中...</p>}
{error && <p className="error-message">{error}</p>} {error && <p className="error-message">{error}</p>}
{!loading && !error && (
<> <ListTable
<table className="users-table"> columns={columns}
<thead> data={users}
<tr> loading={loading}
<th>ID</th> pagination={{
<th>用户名</th> current: page,
<th>姓名</th> pageSize: pageSize,
<th>邮箱</th> total: total,
<th>角色</th> onChange: (p) => setPage(p)
<th>创建时间</th> }}
<th>操作</th> rowKey="user_id"
</tr> showPagination={true}
</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>
</>
)}
{/* 用户表单模态框 */} {/* 用户表单模态框 */}
<FormModal <FormModal