修复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
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1407,6 +1407,7 @@ const MeetingDetails = ({ user }) => {
|
|||
key={audioUrl || 'no-audio'} // 使用audioUrl作为key,确保切换会议时重新创建audio元素
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
style={{ display: 'none' }}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onCanPlay={handleCanPlay}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
|
|
|
|||
|
|
@ -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">支持JPG、PNG、GIF、WEBP格式</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">支持JPG、PNG、GIF、WEBP格式</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 删除提示词确认对话框 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||