feat: UI优化和功能完善
- 添加公共分页组件,支持上边显示、分页大小10 - 侧边栏/Header按Liner风格优化,添加动画效果 - 异常处理列表支持分页和明细弹窗 - 任务结果页面支持已匹配列表分页 - 修复Dialog弹窗背景透明问题 - 新增dropdown-menu组件 - 添加parsed_file_record模型和回填脚本 - 前端枚举中文化
This commit is contained in:
@@ -23,6 +23,18 @@ const loginSchema = z.object({
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
company_id: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 测试用户数据
|
||||
const TEST_USERS = [
|
||||
{ name: "管理员", email: "admin@xingchen.com", password: "admin123", role: "管理员" },
|
||||
@@ -43,7 +55,7 @@ export default function LoginPage() {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await api.post(ENDPOINTS.AUTH.LOGIN, data);
|
||||
const response = await api.post<LoginResponse>(ENDPOINTS.AUTH.LOGIN, data);
|
||||
const { access_token, user } = response.data;
|
||||
|
||||
// 保存到 localStorage
|
||||
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertTriangle,
|
||||
import { AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface ExceptionItem {
|
||||
id: number;
|
||||
@@ -93,6 +93,35 @@ const typeLabels: Record<string, string> = {
|
||||
duplicate_record: "重复记录",
|
||||
format_error: "格式错误",
|
||||
logic_error: "逻辑错误",
|
||||
rule_violation: "规则违规",
|
||||
threshold_exceeded: "阈值超限",
|
||||
missing_employee: "员工缺失",
|
||||
extra_employee: "多余员工",
|
||||
amount_difference: "金额差异",
|
||||
zero_amount: "金额为零",
|
||||
negative_amount: "负数金额",
|
||||
unusual_amount: "金额异常",
|
||||
duplicate_employee: "重复员工",
|
||||
bank_mismatch: "银行不匹配",
|
||||
bank_missing: "银行记录缺失",
|
||||
bank_extra: "银行多余记录",
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_RECORD: "记录缺失",
|
||||
DUPLICATE_RECORD: "重复记录",
|
||||
FORMAT_ERROR: "格式错误",
|
||||
LOGIC_ERROR: "逻辑错误",
|
||||
RULE_VIOLATION: "规则违规",
|
||||
THRESHOLD_EXCEEDED: "阈值超限",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
EXTRA_EMPLOYEE: "多余员工",
|
||||
AMOUNT_DIFFERENCE: "金额差异",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
|
||||
const container = {
|
||||
@@ -121,8 +150,8 @@ function ExceptionsContent() {
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
|
||||
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
@@ -130,7 +159,16 @@ function ExceptionsContent() {
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId, status, severity, exceptionType, page]);
|
||||
}, [taskId, status, severity, exceptionType]);
|
||||
|
||||
function onPageChange(newPage: number, newPageSize: number) {
|
||||
setPage(newPage);
|
||||
if (newPageSize !== pageSize) {
|
||||
setPageSize(newPageSize);
|
||||
}
|
||||
fetchExceptions();
|
||||
fetchSummary();
|
||||
}
|
||||
|
||||
async function fetchExceptions() {
|
||||
setLoading(true);
|
||||
@@ -344,6 +382,10 @@ function ExceptionsContent() {
|
||||
<SelectItem value="duplicate_record">重复记录</SelectItem>
|
||||
<SelectItem value="format_error">格式错误</SelectItem>
|
||||
<SelectItem value="logic_error">逻辑错误</SelectItem>
|
||||
<SelectItem value="missing_employee">员工缺失</SelectItem>
|
||||
<SelectItem value="duplicate_employee">重复员工</SelectItem>
|
||||
<SelectItem value="zero_amount">金额为零</SelectItem>
|
||||
<SelectItem value="unusual_amount">金额异常</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -371,6 +413,14 @@ function ExceptionsContent() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{total > 0 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
@@ -428,7 +478,7 @@ function ExceptionsContent() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exception.exception_type] || exception.exception_type}
|
||||
{typeLabels[exception.exception_type] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || exception.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -473,35 +523,6 @@ function ExceptionsContent() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
第 {page} 页,共 {totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="btn-press"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="btn-press"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
@@ -525,7 +546,7 @@ function ExceptionsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">异常类型</p>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
|
||||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||||
|
||||
@@ -2,8 +2,26 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Search,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { ENDPOINTS } from "@/lib/api/endpoints";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
@@ -61,6 +79,19 @@ const RULE_TYPE_LABELS: Record<string, string> = {
|
||||
DEPARTMENT_MAPPING: "部门映射",
|
||||
};
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
export default function RulesPage() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
@@ -129,196 +160,275 @@ export default function RulesPage() {
|
||||
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex items-center justify-between mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
规则管理
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<h1 className="text-heading-1 text-foreground">规则管理</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
管理企业字段映射规则,实现自动识别
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{rules.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">总规则数</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">启用中</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-2xl font-bold text-gray-400">{inactiveCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">已停用</div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"
|
||||
>
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">总规则数</p>
|
||||
<p className="text-3xl font-semibold mt-1">{rules.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">启用中</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-emerald-600">{activeCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={item}>
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<p className="text-caption text-muted-foreground uppercase tracking-wide">已停用</p>
|
||||
<p className="text-3xl font-semibold mt-1 text-muted-foreground">{inactiveCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索规则..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索规则..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="FIELD_MAPPING">字段映射</option>
|
||||
<option value="ACCOUNT_MAPPING">科目映射</option>
|
||||
<option value="DEPARTMENT_MAPPING">部门映射</option>
|
||||
</select>
|
||||
<div className="w-[150px]">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部类型</SelectItem>
|
||||
<SelectItem value="FIELD_MAPPING">字段映射</SelectItem>
|
||||
<SelectItem value="ACCOUNT_MAPPING">科目映射</SelectItem>
|
||||
<SelectItem value="DEPARTMENT_MAPPING">部门映射</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">启用</option>
|
||||
<option value="INACTIVE">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部状态</SelectItem>
|
||||
<SelectItem value="ACTIVE">启用</SelectItem>
|
||||
<SelectItem value="INACTIVE">停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
规则类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
匹配条件
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
|
||||
目标字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
匹配次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
|
||||
最近使用
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{filteredRules.map((rule) => (
|
||||
<tr
|
||||
key={rule.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-xs">
|
||||
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{rule.target_value}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
|
||||
{rule.match_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
rule.status === "ACTIVE"
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? "启用" : "停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
|
||||
{rule.last_used_at
|
||||
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(rule.id, rule.status)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
rule.status === "ACTIVE"
|
||||
? "text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30"
|
||||
: "text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
title={rule.status === "ACTIVE" ? "停用" : "启用"}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredRules.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
暂无规则数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mt-6"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
规则类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
匹配条件
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
目标字段
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
匹配次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
最近使用
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground uppercase tracking-wide text-xs">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredRules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||||
<FileText className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-foreground font-medium">暂无规则数据</p>
|
||||
<p className="text-caption text-muted-foreground mt-1">
|
||||
上传文件后自动创建映射规则
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRules.map((rule, index) => (
|
||||
<motion.tr
|
||||
key={rule.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
||||
className="hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">
|
||||
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{rule.target_value}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">
|
||||
{rule.match_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge className={rule.status === "ACTIVE"
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500"
|
||||
}>
|
||||
{rule.status === "ACTIVE" ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground text-caption">
|
||||
{rule.last_used_at
|
||||
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleStatus(rule.id, rule.status)}
|
||||
className={`h-8 w-8 ${rule.status === "ACTIVE"
|
||||
? "text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
title={rule.status === "ACTIVE" ? "停用" : "启用"}
|
||||
>
|
||||
{rule.status === "ACTIVE" ? (
|
||||
<ToggleRight className="w-5 h-5" />
|
||||
) : (
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="font-medium text-blue-800 dark:text-blue-300 mb-2">
|
||||
规则说明
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<li>• 字段映射规则会在上传同类文件时自动应用</li>
|
||||
<li>• 精确匹配的规则(完全相同的字段名)会优先于模糊匹配</li>
|
||||
<li>• 停用规则不会在自动识别中应用,但不会删除</li>
|
||||
<li>• 删除规则后,下次上传相同格式文件需要重新确认映射</li>
|
||||
</ul>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mt-6"
|
||||
>
|
||||
<Card className="border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-medium text-foreground mb-3">
|
||||
规则说明
|
||||
</h3>
|
||||
<ul className="text-body-small text-muted-foreground space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
字段映射规则会在上传同类文件时自动应用
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
精确匹配的规则(完全相同的字段名)会优先于模糊匹配
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
停用规则不会在自动识别中应用,但不会删除
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
删除规则后,下次上传相同格式文件需要重新确认映射
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -26,6 +27,12 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
@@ -35,6 +42,7 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
AlertOctagon,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TaskDetail {
|
||||
@@ -76,6 +84,15 @@ interface ExceptionItem {
|
||||
difference_amount: number | null;
|
||||
}
|
||||
|
||||
interface MatchedItem {
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
salary_amount: number | null;
|
||||
social_security_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
net_salary: number | null;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
low: { label: "低", bg: "bg-blue-100 dark:bg-blue-900/40", text: "text-blue-700 dark:text-blue-300" },
|
||||
medium: { label: "中", bg: "bg-yellow-100 dark:bg-yellow-900/40", text: "text-yellow-700 dark:text-yellow-300" },
|
||||
@@ -91,6 +108,33 @@ const typeLabels: Record<string, string> = {
|
||||
logic_error: "逻辑错误",
|
||||
rule_violation: "规则违规",
|
||||
threshold_exceeded: "阈值超限",
|
||||
missing_employee: "员工缺失",
|
||||
extra_employee: "多余员工",
|
||||
amount_difference: "金额差异",
|
||||
zero_amount: "金额为零",
|
||||
negative_amount: "负数金额",
|
||||
unusual_amount: "金额异常",
|
||||
duplicate_employee: "重复员工",
|
||||
bank_mismatch: "银行不匹配",
|
||||
bank_missing: "银行记录缺失",
|
||||
bank_extra: "银行多余记录",
|
||||
AMOUNT_MISMATCH: "金额不一致",
|
||||
MISSING_RECORD: "记录缺失",
|
||||
DUPLICATE_RECORD: "重复记录",
|
||||
FORMAT_ERROR: "格式错误",
|
||||
LOGIC_ERROR: "逻辑错误",
|
||||
RULE_VIOLATION: "规则违规",
|
||||
THRESHOLD_EXCEEDED: "阈值超限",
|
||||
MISSING_EMPLOYEE: "员工缺失",
|
||||
EXTRA_EMPLOYEE: "多余员工",
|
||||
AMOUNT_DIFFERENCE: "金额差异",
|
||||
ZERO_AMOUNT: "金额为零",
|
||||
NEGATIVE_AMOUNT: "负数金额",
|
||||
UNUSUAL_AMOUNT: "金额异常",
|
||||
DUPLICATE_EMPLOYEE: "重复员工",
|
||||
BANK_MISMATCH: "银行不匹配",
|
||||
BANK_MISSING: "银行记录缺失",
|
||||
BANK_EXTRA: "银行多余记录",
|
||||
};
|
||||
|
||||
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -100,9 +144,18 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const [result, setResult] = useState<ReconciliationResult | null>(null);
|
||||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||||
const [matchedRecords, setMatchedRecords] = useState<MatchedItem[]>([]);
|
||||
const [matchedTotal, setMatchedTotal] = useState(0);
|
||||
const [matchedPage, setMatchedPage] = useState(1);
|
||||
const [matchedPageSize, setMatchedPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [matchedLoading, setMatchedLoading] = useState(false);
|
||||
const [exceptionDetailOpen, setExceptionDetailOpen] = useState(false);
|
||||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||||
const [matchedDetailOpen, setMatchedDetailOpen] = useState(false);
|
||||
const [selectedMatched, setSelectedMatched] = useState<MatchedItem | null>(null);
|
||||
|
||||
const fetchResultRef = useRef<() => Promise<void>>();
|
||||
const fetchResultRef = useRef<(() => Promise<void>) | undefined>(undefined);
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
@@ -144,12 +197,36 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const fetchMatchedRecords = useCallback(async () => {
|
||||
setMatchedLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ items: MatchedItem[]; total: number }>(`/api/reconciliation/matched/${taskId}`, {
|
||||
params: { page: matchedPage, page_size: matchedPageSize },
|
||||
});
|
||||
setMatchedRecords(res.data.items || []);
|
||||
setMatchedTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("获取已匹配列表失败", error);
|
||||
} finally {
|
||||
setMatchedLoading(false);
|
||||
}
|
||||
}, [taskId, matchedPage, matchedPageSize]);
|
||||
|
||||
function onMatchedPageChange(newPage: number, newPageSize: number) {
|
||||
setMatchedPage(newPage);
|
||||
if (newPageSize !== matchedPageSize) {
|
||||
setMatchedPageSize(newPageSize);
|
||||
}
|
||||
fetchMatchedRecords();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
fetchTaskDetail();
|
||||
fetchExceptions();
|
||||
fetchMatchedRecords();
|
||||
}
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions]);
|
||||
}, [taskId, fetchTaskDetail, fetchExceptions, fetchMatchedRecords]);
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
@@ -377,7 +454,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
{Object.entries(result.exceptions_by_type).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{typeLabels[type] || type}
|
||||
{typeLabels[type] || typeLabels[type.toLowerCase()] || typeLabels[type.toUpperCase()] || type}
|
||||
</span>
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
</div>
|
||||
@@ -484,7 +561,14 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exceptions.map((exc) => (
|
||||
<TableRow key={exc.id}>
|
||||
<TableRow
|
||||
key={exc.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
setSelectedException(exc);
|
||||
setExceptionDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{exc.employee_name}</div>
|
||||
@@ -493,7 +577,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{typeLabels[exc.exception_type] || exc.exception_type}
|
||||
{typeLabels[exc.exception_type] || typeLabels[exc.exception_type.toLowerCase()] || typeLabels[exc.exception_type.toUpperCase()] || exc.exception_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -527,22 +611,217 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">已匹配列表</CardTitle>
|
||||
<Badge variant="secondary">{task.matched_count} 条记录</Badge>
|
||||
<Badge variant="secondary">{matchedTotal} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-50 dark:bg-emerald-950/30 mx-auto mb-4 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">所有记录都匹配成功!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {task.matched_count} 条记录通过所有对账规则
|
||||
</p>
|
||||
<CardContent>
|
||||
{matchedTotal > 0 && (
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
total={matchedTotal}
|
||||
page={matchedPage}
|
||||
pageSize={matchedPageSize}
|
||||
onChange={onMatchedPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{matchedLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : matchedRecords.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">暂无已匹配记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead className="text-right">应发工资</TableHead>
|
||||
<TableHead className="text-right">社保</TableHead>
|
||||
<TableHead className="text-right">个税</TableHead>
|
||||
<TableHead className="text-right">实发工资</TableHead>
|
||||
<TableHead className="w-[60px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{matchedRecords.map((record, index) => (
|
||||
<TableRow
|
||||
key={`${record.employee_id}-${index}`}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
setSelectedMatched(record);
|
||||
setMatchedDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{record.employee_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{record.employee_id}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.salary_amount != null ? record.salary_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.social_security_amount != null ? record.social_security_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.tax_amount != null ? record.tax_amount.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{record.net_salary != null ? record.net_salary.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
|
||||
{/* 异常详情弹窗 */}
|
||||
<Dialog open={exceptionDetailOpen} onOpenChange={setExceptionDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>异常详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedException && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工姓名</p>
|
||||
<p className="font-medium">{selectedException.employee_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工ID</p>
|
||||
<p className="font-medium">{selectedException.employee_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">异常类型</p>
|
||||
<Badge variant="outline">
|
||||
{typeLabels[selectedException.exception_type] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||||
<Badge className={`${severityConfig[selectedException.severity].bg} ${severityConfig[selectedException.severity].text}`}>
|
||||
{severityConfig[selectedException.severity].label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-3">金额详情</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{selectedException.salary_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">工资</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.salary_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.social_security_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">社保</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.social_security_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.tax_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">个税</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.tax_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedException.bank_amount != null && (
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">银行</p>
|
||||
<p className="font-mono font-medium mt-1">{selectedException.bank_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedException.difference_amount != null && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-caption text-muted-foreground">差异金额</p>
|
||||
<p className={`text-2xl font-mono font-semibold mt-1 ${selectedException.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}`}>
|
||||
{selectedException.difference_amount > 0 ? "+" : ""}
|
||||
{selectedException.difference_amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground mb-1">描述</p>
|
||||
<p>{selectedException.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 已匹配记录明细弹窗 */}
|
||||
<Dialog open={matchedDetailOpen} onOpenChange={setMatchedDetailOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>已匹配记录明细</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedMatched && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工姓名</p>
|
||||
<p className="font-medium">{selectedMatched.employee_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">员工ID</p>
|
||||
<p className="font-medium">{selectedMatched.employee_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-medium mb-3">金额详情</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-muted-foreground">应发工资</span>
|
||||
<span className="font-mono font-medium">
|
||||
{selectedMatched.salary_amount != null ? selectedMatched.salary_amount.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-muted-foreground">社保</span>
|
||||
<span className="font-mono font-medium">
|
||||
{selectedMatched.social_security_amount != null ? selectedMatched.social_security_amount.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
|
||||
<span className="text-muted-foreground">个税</span>
|
||||
<span className="font-mono font-medium">
|
||||
{selectedMatched.tax_amount != null ? selectedMatched.tax_amount.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<span className="font-medium">实发工资</span>
|
||||
<span className="font-mono font-semibold text-lg">
|
||||
{selectedMatched.net_salary != null ? selectedMatched.net_salary.toFixed(2) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
Filter,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api/client";
|
||||
import ENDPOINTS from "@/lib/api/endpoints";
|
||||
@@ -59,24 +58,40 @@ export default function TasksPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string>("");
|
||||
const [availablePeriods] = useState(getAvailablePeriods);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = selectedPeriod ? { period: selectedPeriod } : {};
|
||||
const res = await api.get<Task[]>(ENDPOINTS.TASK.LIST, { params });
|
||||
setTasks(res.data);
|
||||
const params = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(selectedPeriod ? { period: selectedPeriod } : {})
|
||||
};
|
||||
const res = await api.get<{ items: Task[]; total: number }>(ENDPOINTS.TASK.LIST, { params });
|
||||
setTasks(res.data.items || []);
|
||||
setTotal(res.data.total || 0);
|
||||
} catch (error) {
|
||||
console.error("加载任务列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedPeriod]);
|
||||
}, [page, pageSize, selectedPeriod]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
function onPageChange(newPage: number, newPageSize: number) {
|
||||
setPage(newPage);
|
||||
if (newPageSize !== pageSize) {
|
||||
setPageSize(newPageSize);
|
||||
}
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
const getMatchRate = (task: Task) => {
|
||||
if (task.total_employees === 0) return "0%";
|
||||
const rate = Math.round((task.matched_count / task.total_employees) * 100);
|
||||
@@ -176,7 +191,16 @@ export default function TasksPage() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task, index) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
@@ -241,16 +265,8 @@ export default function TasksPage() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination hint */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2 text-caption text-muted-foreground">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>显示最近 50 条记录</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,10 +21,22 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
// 先检查 Content-Type,确保是 JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('GET proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +63,22 @@ export async function POST(
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
// 先检查 Content-Type,确保是 JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('POST proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +105,21 @@ export async function PUT(
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('PUT proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,10 +146,21 @@ export async function PATCH(
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('PATCH proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +185,20 @@ export async function DELETE(
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: { 'content-type': contentType || 'text/plain' }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
|
||||
console.error('DELETE proxy error:', error);
|
||||
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
+185
-110
@@ -3,84 +3,86 @@
|
||||
@import "@fontsource/geist/500.css";
|
||||
@import "@fontsource/geist/600.css";
|
||||
@import "@fontsource/geist/700.css";
|
||||
@import "@fontsource/ibm-plex-sans/400.css";
|
||||
@import "@fontsource/ibm-plex-sans/500.css";
|
||||
@import "@fontsource/ibm-plex-sans/600.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Design System Tokens */
|
||||
/* Liner Style Design System - Minimal, Clean, Refined */
|
||||
:root {
|
||||
/* Colors - Professional Blue-Gray Palette */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 220 14% 18%;
|
||||
/* Colors - Light Mode - Soft & Clean */
|
||||
--background: 0 0% 99%;
|
||||
--foreground: 220 10% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 14% 18%;
|
||||
--card-foreground: 220 10% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 14% 18%;
|
||||
--popover-foreground: 220 10% 15%;
|
||||
|
||||
/* Primary - Professional Blue */
|
||||
--primary: 221 83% 53%;
|
||||
/* Primary - Subtle Blue */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary - Cool Gray */
|
||||
--secondary: 220 13% 95%;
|
||||
--secondary-foreground: 220 14% 18%;
|
||||
/* Secondary - Ultra Light Gray */
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 220 10% 25%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 220 13% 95%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
/* Muted - Very Light */
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 8% 46%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 220 13% 95%;
|
||||
--accent-foreground: 220 14% 18%;
|
||||
/* Accent - Soft Highlight */
|
||||
--accent: 217 91% 97%;
|
||||
--accent-foreground: 217 91% 40%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Inputs */
|
||||
/* Borders - Ultra Fine */
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
/* Radius - Larger, Softer */
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.06), 0 2px 4px -2px rgb(0 0 0 / 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
||||
/* Shadows - Minimal */
|
||||
--shadow: 0 1px 2px 0 hsl(220 14% 96%);
|
||||
--shadow-sm: 0 0px 0px 1px hsl(220 13% 91%);
|
||||
--shadow-md: 0 2px 4px 0 hsl(220 14% 96%);
|
||||
--shadow-lg: 0 4px 8px 0 hsl(220 14% 96%);
|
||||
}
|
||||
|
||||
/* Dark Mode - Professional Dark */
|
||||
/* Dark Mode - Soft Dark */
|
||||
.dark {
|
||||
--background: 220 17% 10%;
|
||||
--foreground: 210 17% 95%;
|
||||
--card: 220 17% 12%;
|
||||
--card-foreground: 210 17% 95%;
|
||||
--popover: 220 17% 12%;
|
||||
--popover-foreground: 210 17% 95%;
|
||||
--background: 220 20% 7%;
|
||||
--foreground: 220 14% 96%;
|
||||
--card: 220 18% 9%;
|
||||
--card-foreground: 220 14% 96%;
|
||||
--popover: 220 18% 9%;
|
||||
--popover-foreground: 220 14% 96%;
|
||||
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 220 17% 10%;
|
||||
--primary: 217 91% 65%;
|
||||
--primary-foreground: 220 20% 7%;
|
||||
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 17% 95%;
|
||||
--secondary: 220 18% 14%;
|
||||
--secondary-foreground: 220 14% 96%;
|
||||
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--muted: 220 18% 14%;
|
||||
--muted-foreground: 220 8% 64%;
|
||||
|
||||
--accent: 217 33% 17%;
|
||||
--accent-foreground: 210 17% 95%;
|
||||
--accent: 217 33% 20%;
|
||||
--accent-foreground: 217 91% 75%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 17% 95%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 224 76% 48%;
|
||||
--border: 220 18% 16%;
|
||||
--input: 220 18% 16%;
|
||||
--ring: 217 91% 65%;
|
||||
|
||||
--shadow: 0 1px 2px 0 hsl(220 20% 4%);
|
||||
--shadow-sm: 0 0px 0px 1px hsl(220 18% 16%);
|
||||
--shadow-md: 0 2px 4px 0 hsl(220 20% 4%);
|
||||
--shadow-lg: 0 4px 8px 0 hsl(220 20% 4%);
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
@@ -89,12 +91,13 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Geist", "IBM Plex Sans", system-ui, sans-serif;
|
||||
font-family: "Geist", system-ui, -apple-system, sans-serif;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Smooth Scrolling */
|
||||
@@ -104,37 +107,37 @@ html {
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Focus Visible */
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline: 2px solid hsl(var(--ring) / 0.5);
|
||||
outline-offset: 2px;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
/* Scrollbar - Minimal */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* Animation Utilities */
|
||||
/* Animations - Subtle */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -143,7 +146,7 @@ html {
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -154,7 +157,7 @@ html {
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -165,7 +168,7 @@ html {
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -173,11 +176,6 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -185,78 +183,70 @@ html {
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Staggered Animation Delays */
|
||||
.stagger-1 { animation-delay: 50ms; }
|
||||
.stagger-2 { animation-delay: 100ms; }
|
||||
.stagger-3 { animation-delay: 150ms; }
|
||||
.stagger-4 { animation-delay: 200ms; }
|
||||
.stagger-5 { animation-delay: 250ms; }
|
||||
/* Staggered Animation */
|
||||
.stagger-1 { animation-delay: 30ms; }
|
||||
.stagger-2 { animation-delay: 60ms; }
|
||||
.stagger-3 { animation-delay: 90ms; }
|
||||
.stagger-4 { animation-delay: 120ms; }
|
||||
.stagger-5 { animation-delay: 150ms; }
|
||||
|
||||
/* Transition Utilities */
|
||||
/* Transitions - Smooth */
|
||||
.transition-base {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.transition-bounce {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
/* Hover Effects - Subtle Lift */
|
||||
.hover-lift {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.02);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
/* Button Active Effect */
|
||||
/* Button Press Effect */
|
||||
.btn-press:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Glass Effect */
|
||||
/* Glass Effect - Subtle */
|
||||
.glass {
|
||||
background: hsl(var(--background) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: hsl(var(--background) / 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@@ -275,38 +265,38 @@ html {
|
||||
.text-display {
|
||||
font-size: 3rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
letter-spacing: -0.03em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-heading-1 {
|
||||
font-size: 2.25rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-heading-2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-heading-3 {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-size: 1rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.text-body-small {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -314,4 +304,89 @@ html {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Liner-Style Card - Border only, no shadow */
|
||||
.liner-card {
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Liner-Style Button - Clean, minimal */
|
||||
.liner-btn {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.liner-btn:hover {
|
||||
background: hsl(var(--accent));
|
||||
border-color: hsl(var(--accent-foreground) / 0.2);
|
||||
}
|
||||
|
||||
.liner-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Liner-Style Input */
|
||||
.liner-input {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.liner-input:focus {
|
||||
border-color: hsl(var(--ring));
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.1);
|
||||
}
|
||||
|
||||
/* Liner-Style Badge */
|
||||
.liner-badge {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Liner-Style Table */
|
||||
.liner-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.liner-table th {
|
||||
background: hsl(var(--muted));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.liner-table td {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.liner-table tr:hover td {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.8));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Subtle Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, hsl(var(--border)), transparent);
|
||||
}
|
||||
Reference in New Issue
Block a user