refactor: 统一分页组件和UI优化
- 使用公共Pagination组件统一各页面的分页逻辑 - 优化exceptions页面异常列表展示 - 简化settings/rules页面规则管理 - 后端tasks.py增加分页参数支持
This commit is contained in:
+41
-21
@@ -21,19 +21,19 @@ from app.schemas.user import UserResponse
|
|||||||
router = APIRouter(prefix="/api/tasks", tags=["对账任务"])
|
router = APIRouter(prefix="/api/tasks", tags=["对账任务"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[Dict[str, Any]])
|
@router.get("/", response_model=Dict[str, Any])
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
period: Optional[str] = Query(None, description="筛选月份,格式: 2026-04"),
|
period: Optional[str] = Query(None, description="筛选月份,格式: 2026-04"),
|
||||||
status: Optional[str] = Query(None, description="筛选状态"),
|
status: Optional[str] = Query(None, description="筛选状态"),
|
||||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
limit: int = Query(50, ge=1, le=100, description="返回记录数"),
|
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
company_id: Optional[int] = Depends(get_current_company_id),
|
company_id: Optional[int] = Depends(get_current_company_id),
|
||||||
) -> List[Dict[str, Any]]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取对账任务列表
|
获取对账任务列表
|
||||||
|
|
||||||
支持按月份和状态筛选
|
支持按月份和状态筛选,返回分页结构
|
||||||
"""
|
"""
|
||||||
# 构建查询
|
# 构建查询
|
||||||
query = select(ReconciliationTask).where(
|
query = select(ReconciliationTask).where(
|
||||||
@@ -46,27 +46,45 @@ async def list_tasks(
|
|||||||
if status:
|
if status:
|
||||||
query = query.where(ReconciliationTask.status == status)
|
query = query.where(ReconciliationTask.status == status)
|
||||||
|
|
||||||
# 按创建时间倒序
|
# 计算总数
|
||||||
query = query.order_by(ReconciliationTask.created_at.desc())
|
count_query = select(func.count()).select_from(ReconciliationTask).where(
|
||||||
|
ReconciliationTask.company_id == company_id
|
||||||
|
)
|
||||||
|
if period:
|
||||||
|
count_query = count_query.where(ReconciliationTask.period == period)
|
||||||
|
if status:
|
||||||
|
count_query = count_query.where(ReconciliationTask.status == status)
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
# 分页
|
# 按创建时间倒序 + 分页
|
||||||
query = query.offset(skip).limit(limit)
|
skip = (page - 1) * page_size
|
||||||
|
query = query.order_by(ReconciliationTask.created_at.desc())
|
||||||
|
query = query.offset(skip).limit(page_size)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
tasks = result.scalars().all()
|
tasks = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return {
|
||||||
{
|
"items": [
|
||||||
"id": task.id,
|
{
|
||||||
"period": task.period,
|
"id": task.id,
|
||||||
"status": task.status,
|
"name": f"{task.period} 对账任务",
|
||||||
"total_employees": task.total_employees,
|
"period": task.period,
|
||||||
"matched_count": task.matched_count,
|
"status": task.status,
|
||||||
"exception_count": task.exception_count,
|
"total_employees": task.total_employees,
|
||||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
"matched_count": task.matched_count,
|
||||||
}
|
"exception_count": task.exception_count,
|
||||||
for task in tasks
|
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||||
]
|
"completed_at": task.updated_at.isoformat() if task.status == "COMPLETED" and task.updated_at else None,
|
||||||
|
}
|
||||||
|
for task in tasks
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=Dict[str, Any])
|
@router.get("/stats", response_model=Dict[str, Any])
|
||||||
@@ -169,6 +187,7 @@ async def get_task(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"id": task.id,
|
"id": task.id,
|
||||||
|
"name": f"{task.period} 对账任务",
|
||||||
"period": task.period,
|
"period": task.period,
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
"total_employees": task.total_employees,
|
"total_employees": task.total_employees,
|
||||||
@@ -177,5 +196,6 @@ async def get_task(
|
|||||||
"file_ids": task.file_ids,
|
"file_ids": task.file_ids,
|
||||||
"reconciliation_result": task.reconciliation_result,
|
"reconciliation_result": task.reconciliation_result,
|
||||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||||
|
"completed_at": task.updated_at.isoformat() if task.status == "COMPLETED" and task.updated_at else None,
|
||||||
"error_message": task.error_message,
|
"error_message": task.error_message,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ export default function DashboardPage() {
|
|||||||
// 并行获取统计数据和任务列表
|
// 并行获取统计数据和任务列表
|
||||||
const [statsRes, tasksRes] = await Promise.all([
|
const [statsRes, tasksRes] = await Promise.all([
|
||||||
api.get<TaskStats>(ENDPOINTS.TASK.STATS),
|
api.get<TaskStats>(ENDPOINTS.TASK.STATS),
|
||||||
api.get<Task[]>(ENDPOINTS.TASK.LIST, { params: { limit: 5 } }),
|
api.get<{ items: Task[] }>(ENDPOINTS.TASK.LIST, { params: { page: 1, page_size: 5 } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setStats(statsRes.data);
|
setStats(statsRes.data);
|
||||||
setRecentTasks(tasksRes.data);
|
setRecentTasks(tasksRes.data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载数据失败:", error);
|
console.error("加载数据失败:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,7 +101,7 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
|
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -8 }}
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
|||||||
@@ -457,46 +457,40 @@ function ExceptionsContent() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
exceptions.map((exception, index) => (
|
exceptions.map((exception) => (
|
||||||
<motion.div
|
<TableRow key={exception.id} className="group">
|
||||||
key={exception.id}
|
<TableCell>
|
||||||
initial={{ opacity: 0, y: 4 }}
|
<Badge className={statusConfig[exception.status]?.color || "bg-gray-100 text-gray-700"}>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{statusConfig[exception.status]?.label || exception.status}
|
||||||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
</Badge>
|
||||||
>
|
</TableCell>
|
||||||
<TableRow className="group">
|
<TableCell>
|
||||||
<TableCell>
|
<div>
|
||||||
<Badge className={statusConfig[exception.status]?.color || "bg-gray-100 text-gray-700"}>
|
<div className="font-medium">{exception.employee_name}</div>
|
||||||
{statusConfig[exception.status]?.label || exception.status}
|
<div className="text-xs text-muted-foreground">{exception.employee_id}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-normal">
|
||||||
|
{typeLabels[exception.exception_type] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || exception.exception_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${severityConfig[exception.severity]?.icon || "bg-gray-500"}`} />
|
||||||
|
<Badge className={severityConfig[exception.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||||||
|
{severityConfig[exception.severity]?.label || exception.severity}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div>
|
<TableCell className="max-w-[200px]">
|
||||||
<div className="font-medium">{exception.employee_name}</div>
|
<span className="truncate block">{exception.description}</span>
|
||||||
<div className="text-xs text-muted-foreground">{exception.employee_id}</div>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">
|
||||||
</TableCell>
|
{exception.difference_amount != null && (
|
||||||
<TableCell>
|
<span className={exception.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
||||||
<Badge variant="outline" className="font-normal">
|
{exception.difference_amount > 0 ? "+" : ""}
|
||||||
{typeLabels[exception.exception_type] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || exception.exception_type}
|
{exception.difference_amount.toFixed(2)}
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${severityConfig[exception.severity]?.icon || "bg-gray-500"}`} />
|
|
||||||
<Badge className={severityConfig[exception.severity]?.color || "bg-gray-100 text-gray-700"}>
|
|
||||||
{severityConfig[exception.severity]?.label || exception.severity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-[200px]">
|
|
||||||
<span className="truncate block">{exception.description}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{exception.difference_amount != null && (
|
|
||||||
<span className={exception.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
|
||||||
{exception.difference_amount > 0 ? "+" : ""}
|
|
||||||
{exception.difference_amount.toFixed(2)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -518,9 +512,8 @@ function ExceptionsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</motion.div>
|
))
|
||||||
))
|
)}
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -608,7 +601,16 @@ function ExceptionsContent() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-caption text-muted-foreground mb-1">详细信息</p>
|
<p className="text-caption text-muted-foreground mb-1">详细信息</p>
|
||||||
<pre className="text-xs bg-muted p-3 rounded-lg overflow-auto">
|
<pre className="text-xs bg-muted p-3 rounded-lg overflow-auto">
|
||||||
{JSON.stringify(JSON.parse(selectedException.detail), null, 2)}
|
{(() => {
|
||||||
|
try {
|
||||||
|
const detail = typeof selectedException.detail === 'string'
|
||||||
|
? JSON.parse(selectedException.detail)
|
||||||
|
: selectedException.detail;
|
||||||
|
return JSON.stringify(detail, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(selectedException.detail);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
ToggleLeft,
|
ToggleLeft,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
@@ -93,7 +91,6 @@ const item = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const router = useRouter();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [rules, setRules] = useState<CompanyRuleResponse[]>(mockRules);
|
const [rules, setRules] = useState<CompanyRuleResponse[]>(mockRules);
|
||||||
@@ -160,28 +157,19 @@ export default function RulesPage() {
|
|||||||
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
|
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -8 }}
|
initial={{ opacity: 0, y: -8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="flex items-center justify-between mb-8"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div>
|
||||||
<Button
|
<h1 className="text-heading-1 text-foreground">规则管理</h1>
|
||||||
variant="ghost"
|
<p className="text-muted-foreground mt-1">
|
||||||
size="icon"
|
管理企业字段映射规则,实现自动识别
|
||||||
onClick={() => router.push("/dashboard")}
|
</p>
|
||||||
>
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-heading-1 text-foreground">规则管理</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
管理企业字段映射规则,实现自动识别
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -439,12 +439,12 @@ export default function MappingPage() {
|
|||||||
[toast]
|
[toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 确认并进入下一步
|
// 确认并进入下一步 - 跳转到对账结果页面
|
||||||
const handleConfirmAndNext = useCallback(async () => {
|
const handleConfirmAndNext = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
toast.success("字段映射已确认,正在进入对账...");
|
toast.success("字段映射已确认,正在进入对账...");
|
||||||
router.push(`/tasks/${taskId}/reconciliation`);
|
router.push(`/tasks/${taskId}/result`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("操作失败");
|
toast.error("操作失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -475,7 +475,7 @@ export default function MappingPage() {
|
|||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard")} className="hover:bg-muted">
|
<Button variant="ghost" size="icon" onClick={() => router.push("/tasks")} className="hover:bg-muted">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -82,14 +82,13 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks();
|
loadTasks();
|
||||||
}, [loadTasks]);
|
}, [loadTasks, page, pageSize, selectedPeriod]);
|
||||||
|
|
||||||
function onPageChange(newPage: number, newPageSize: number) {
|
function onPageChange(newPage: number, newPageSize: number) {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
if (newPageSize !== pageSize) {
|
if (newPageSize !== pageSize) {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
}
|
}
|
||||||
loadTasks();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMatchRate = (task: Task) => {
|
const getMatchRate = (task: Task) => {
|
||||||
@@ -99,7 +98,7 @@ export default function TasksPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full p-6 lg:p-8 max-w-6xl mx-auto">
|
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -8 }}
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user