5278190750
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务 - 后端: 成本分析服务、AI问答服务 - 后端: 科目映射CRUD API、分析API、QA API - 后端: 集成测试(认证/任务/凭证) 49个测试全部通过 - 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面 - 前端: AuthGuard认证守卫、Dashboard AI聊天功能 - 前端: Playwright E2E测试 16 passed, 1 skipped - 基础设施: Docker Compose、Nginx反向代理、.env.example - 文档: 用户手册、管理员手册、发布检查清单
585 lines
25 KiB
TypeScript
585 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { motion } from "motion/react";
|
||
import {
|
||
FileText,
|
||
AlertTriangle,
|
||
CheckCircle,
|
||
TrendingUp,
|
||
Clock,
|
||
ArrowRight,
|
||
Loader2,
|
||
Sparkles,
|
||
Send,
|
||
MessageSquare,
|
||
Receipt,
|
||
BarChart3,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useRouter } from "next/navigation";
|
||
import { api } from "@/lib/api/client";
|
||
import ENDPOINTS from "@/lib/api/endpoints";
|
||
|
||
interface TaskStats {
|
||
monthly_tasks: number;
|
||
pending_exceptions: number;
|
||
completed_tasks: number;
|
||
match_rate: number;
|
||
current_period: string;
|
||
}
|
||
|
||
interface Task {
|
||
id: number;
|
||
period: string;
|
||
status: string;
|
||
total_employees: number;
|
||
matched_count: number;
|
||
exception_count: number;
|
||
created_at: string;
|
||
completed_at: string | null;
|
||
}
|
||
|
||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||
PENDING: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||
PROCESSING: { label: "处理中", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" },
|
||
MAPPING_COMPLETED: { label: "映射完成", color: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300" },
|
||
COMPLETED: { label: "已完成", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300" },
|
||
FAILED: { label: "失败", color: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300" },
|
||
};
|
||
|
||
const container = {
|
||
hidden: { opacity: 0 },
|
||
show: {
|
||
opacity: 1,
|
||
transition: {
|
||
staggerChildren: 0.08,
|
||
delayChildren: 0.1,
|
||
},
|
||
},
|
||
};
|
||
|
||
const item = {
|
||
hidden: { opacity: 0, y: 12 },
|
||
show: {
|
||
opacity: 1,
|
||
y: 0,
|
||
transition: {
|
||
duration: 0.4,
|
||
ease: [0.16, 1, 0.3, 1] as const,
|
||
},
|
||
},
|
||
};
|
||
|
||
interface SuggestedQuestions {
|
||
questions: string[];
|
||
}
|
||
|
||
interface QAResponse {
|
||
answer: string;
|
||
data_points: string[];
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
const router = useRouter();
|
||
const [stats, setStats] = useState<TaskStats | null>(null);
|
||
const [recentTasks, setRecentTasks] = useState<Task[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [questions, setQuestions] = useState<string[]>([]);
|
||
const [askLoading, setAskLoading] = useState(false);
|
||
const [customQuestion, setCustomQuestion] = useState("");
|
||
const [chatHistory, setChatHistory] = useState<{ q: string; a: string }[]>([]);
|
||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
const loadDashboardData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
// 并行获取统计数据和任务列表
|
||
const [statsRes, tasksRes] = await Promise.all([
|
||
api.get<TaskStats>(ENDPOINTS.TASK.STATS),
|
||
api.get<{ items: Task[] }>(ENDPOINTS.TASK.LIST, { params: { page: 1, page_size: 5 } }),
|
||
]);
|
||
|
||
setStats(statsRes.data);
|
||
setRecentTasks(tasksRes.data.items || []);
|
||
} catch (error) {
|
||
console.error("加载数据失败:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadQuestions = useCallback(async () => {
|
||
try {
|
||
const res = await api.get<SuggestedQuestions>(`/api/qa/suggested-questions`, {
|
||
params: { task_id: 1, context: "" },
|
||
});
|
||
setQuestions(res.data.questions || []);
|
||
} catch (error) {
|
||
// 静默处理
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadDashboardData();
|
||
loadQuestions();
|
||
}, [loadDashboardData, loadQuestions]);
|
||
|
||
useEffect(() => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [chatHistory]);
|
||
|
||
async function askQuestion(question: string) {
|
||
if (!question) return;
|
||
setAskLoading(true);
|
||
try {
|
||
const res = await api.post<QAResponse>(`/api/qa/ask`, {
|
||
task_id: recentTasks[0]?.id || 1,
|
||
question,
|
||
context: "",
|
||
});
|
||
setChatHistory([...chatHistory, { q: question, a: res.data.answer }]);
|
||
} catch (error) {
|
||
setChatHistory([...chatHistory, { q: question, a: "暂时无法回答该问题,请稍后重试。" }]);
|
||
} finally {
|
||
setAskLoading(false);
|
||
}
|
||
}
|
||
|
||
const formatPeriod = (period: string) => {
|
||
const [year, month] = period.split("-");
|
||
return `${year}年${parseInt(month)}月`;
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||
{/* Header */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
|
||
className="mb-8"
|
||
>
|
||
<h1 className="text-heading-1 text-foreground mb-2">AI 工作台</h1>
|
||
<p className="text-muted-foreground text-body">
|
||
智能驱动的工资对账与成本分析平台
|
||
</p>
|
||
</motion.div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Stats Grid */}
|
||
<motion.div
|
||
variants={container}
|
||
initial="hidden"
|
||
animate="show"
|
||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"
|
||
>
|
||
<motion.div variants={item}>
|
||
<Card className="relative overflow-hidden hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||
本月任务
|
||
</p>
|
||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||
{stats?.monthly_tasks ?? 0}
|
||
</p>
|
||
<div className="flex items-center gap-1 mt-2">
|
||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||
<span className="text-caption text-muted-foreground">
|
||
{stats?.current_period ? formatPeriod(stats.current_period) : "-"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
|
||
<FileText className="w-5 h-5 text-blue-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-blue-50 dark:bg-blue-950/30 opacity-50" />
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="relative overflow-hidden hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||
待处理异常
|
||
</p>
|
||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||
{stats?.pending_exceptions ?? 0}
|
||
</p>
|
||
<div className="flex items-center gap-1 mt-2">
|
||
<AlertTriangle className="w-3 h-3 text-amber-500" />
|
||
<span className="text-caption text-muted-foreground">
|
||
需要人工处理
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
|
||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-amber-50 dark:bg-amber-950/30 opacity-50" />
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="relative overflow-hidden hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||
已完成
|
||
</p>
|
||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||
{stats?.completed_tasks ?? 0}
|
||
</p>
|
||
<div className="flex items-center gap-1 mt-2">
|
||
<CheckCircle className="w-3 h-3 text-emerald-500" />
|
||
<span className="text-caption text-muted-foreground">
|
||
本月累计
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-emerald-50 dark:bg-emerald-950/30 opacity-50" />
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="relative overflow-hidden hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">
|
||
匹配率
|
||
</p>
|
||
<p className="text-3xl font-semibold tracking-tight text-foreground">
|
||
{stats?.match_rate ?? 0}%
|
||
</p>
|
||
<div className="flex items-center gap-1 mt-2">
|
||
<TrendingUp className="w-3 h-3 text-violet-500" />
|
||
<span className="text-caption text-muted-foreground">
|
||
本月平均
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
|
||
<TrendingUp className="w-5 h-5 text-violet-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-violet-50 dark:bg-violet-950/30 opacity-50" />
|
||
</Card>
|
||
</motion.div>
|
||
</motion.div>
|
||
|
||
{/* AI Chat + Recent Tasks */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||
{/* AI Chat Panel */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
|
||
className="lg:col-span-2"
|
||
>
|
||
<Card className="h-full flex flex-col">
|
||
<CardContent className="p-5 flex flex-col h-full">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<div className="p-2 rounded-lg bg-primary/10">
|
||
<Sparkles className="w-5 h-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-heading-3 text-foreground">AI 助手</h2>
|
||
<p className="text-caption text-muted-foreground">基于真实数据回答您的问题</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chat History */}
|
||
<div className="flex-1 min-h-[200px] max-h-[400px] overflow-y-auto space-y-3 mb-4 pr-2">
|
||
{chatHistory.length === 0 && !askLoading && (
|
||
<div className="text-center py-8">
|
||
<MessageSquare className="w-8 h-8 text-muted-foreground/50 mx-auto mb-3" />
|
||
<p className="text-body text-muted-foreground">
|
||
向 AI 助手提问,获取基于对账数据的即时分析
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{chatHistory.map((chat, i) => (
|
||
<div key={i} className="space-y-2">
|
||
<div className="flex justify-end">
|
||
<div className="bg-primary/10 rounded-lg px-4 py-2 max-w-[80%]">
|
||
<p className="text-sm text-foreground">{chat.q}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-start">
|
||
<div className="bg-muted rounded-lg px-4 py-2 max-w-[80%]">
|
||
<div className="flex items-start gap-2">
|
||
<Sparkles className="w-3 h-3 text-primary mt-0.5 shrink-0" />
|
||
<p className="text-sm text-foreground/90">{chat.a}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{askLoading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-muted rounded-lg px-4 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||
<span className="text-sm text-muted-foreground">正在思考...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={chatEndRef} />
|
||
</div>
|
||
|
||
{/* Suggested Questions */}
|
||
{chatHistory.length === 0 && questions.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{questions.slice(0, 4).map((q, i) => (
|
||
<Button
|
||
key={i}
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => askQuestion(q)}
|
||
disabled={askLoading}
|
||
className="btn-press"
|
||
>
|
||
{q}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Input */}
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="输入您的问题..."
|
||
value={customQuestion}
|
||
onChange={(e) => setCustomQuestion(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && customQuestion) {
|
||
askQuestion(customQuestion);
|
||
setCustomQuestion("");
|
||
}
|
||
}}
|
||
className="flex-1 h-10 px-3 rounded-lg border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
/>
|
||
<Button
|
||
onClick={() => {
|
||
if (customQuestion) {
|
||
askQuestion(customQuestion);
|
||
setCustomQuestion("");
|
||
}
|
||
}}
|
||
disabled={askLoading || !customQuestion}
|
||
className="btn-press"
|
||
size="icon"
|
||
>
|
||
<Send className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Recent Tasks */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.35, ease: [0.16, 1, 0.3, 1] as const }}
|
||
>
|
||
<Card className="h-full">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-heading-3 text-foreground">最近任务</h2>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => router.push("/tasks")}
|
||
>
|
||
查看全部
|
||
<ArrowRight className="w-4 h-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
|
||
{recentTasks.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<div className="w-10 h-10 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||
</div>
|
||
<p className="text-caption text-muted-foreground mb-3">
|
||
暂无对账任务
|
||
</p>
|
||
<Button
|
||
size="sm"
|
||
className="btn-press"
|
||
onClick={() => router.push("/tasks/new")}
|
||
>
|
||
<Upload className="w-4 h-4 mr-1" />
|
||
新建任务
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{recentTasks.map((task) => (
|
||
<div
|
||
key={task.id}
|
||
className="p-3 rounded-lg border border-border hover:bg-accent cursor-pointer transition-colors"
|
||
onClick={() => router.push(`/tasks/${task.id}/result`)}
|
||
>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="font-medium text-sm text-foreground">
|
||
{formatPeriod(task.period)}
|
||
</span>
|
||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusLabels[task.status]?.color || statusLabels.PENDING.color}`}>
|
||
{statusLabels[task.status]?.label || task.status}
|
||
</span>
|
||
</div>
|
||
<p className="text-caption text-muted-foreground">
|
||
{task.total_employees} 人 · {task.matched_count} 匹配 · {task.exception_count} 异常
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
</div>
|
||
|
||
{/* Quick Actions */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
|
||
>
|
||
<h2 className="text-heading-3 text-foreground mb-4">快捷操作</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<Card
|
||
className="group cursor-pointer hover-lift"
|
||
onClick={() => router.push("/tasks/new")}
|
||
>
|
||
<CardContent className="p-5 flex items-start gap-4">
|
||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
|
||
<FileText className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium text-foreground mb-1 group-hover:text-primary transition-colors">
|
||
新建对账任务
|
||
</h3>
|
||
<p className="text-caption text-muted-foreground">
|
||
上传工资表、社保表进行对账
|
||
</p>
|
||
</div>
|
||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all self-center" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card
|
||
className="group cursor-pointer hover-lift"
|
||
onClick={() => router.push("/exceptions")}
|
||
>
|
||
<CardContent className="p-5 flex items-start gap-4">
|
||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-amber-500/10 transition-colors">
|
||
<AlertTriangle className="w-5 h-5 text-muted-foreground group-hover:text-amber-500 transition-colors" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium text-foreground mb-1 group-hover:text-amber-500 transition-colors">
|
||
查看异常
|
||
</h3>
|
||
<p className="text-caption text-muted-foreground">
|
||
处理待解决的异常记录
|
||
</p>
|
||
</div>
|
||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-amber-500 group-hover:translate-x-1 transition-all self-center" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card
|
||
className="group cursor-pointer hover-lift"
|
||
onClick={() => router.push("/analysis")}
|
||
>
|
||
<CardContent className="p-5 flex items-start gap-4">
|
||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-violet-500/10 transition-colors">
|
||
<BarChart3 className="w-5 h-5 text-muted-foreground group-hover:text-violet-500 transition-colors" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium text-foreground mb-1 group-hover:text-violet-500 transition-colors">
|
||
成本分析
|
||
</h3>
|
||
<p className="text-caption text-muted-foreground">
|
||
多维度人工成本分析
|
||
</p>
|
||
</div>
|
||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-violet-500 group-hover:translate-x-1 transition-all self-center" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card
|
||
className="group cursor-pointer hover-lift"
|
||
onClick={() => router.push("/vouchers")}
|
||
>
|
||
<CardContent className="p-5 flex items-start gap-4">
|
||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-emerald-500/10 transition-colors">
|
||
<Receipt className="w-5 h-5 text-muted-foreground group-hover:text-emerald-500 transition-colors" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium text-foreground mb-1 group-hover:text-emerald-500 transition-colors">
|
||
凭证管理
|
||
</h3>
|
||
<p className="text-caption text-muted-foreground">
|
||
生成和导出会计凭证
|
||
</p>
|
||
</div>
|
||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-emerald-500 group-hover:translate-x-1 transition-all self-center" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card
|
||
className="group cursor-pointer hover-lift"
|
||
onClick={() => router.push("/settings/rules")}
|
||
>
|
||
<CardContent className="p-5 flex items-start gap-4">
|
||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-violet-500/10 transition-colors">
|
||
<TrendingUp className="w-5 h-5 text-muted-foreground group-hover:text-violet-500 transition-colors" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium text-foreground mb-1 group-hover:text-violet-500 transition-colors">
|
||
对账规则
|
||
</h3>
|
||
<p className="text-caption text-muted-foreground">
|
||
配置对账规则和映射关系
|
||
</p>
|
||
</div>
|
||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-violet-500 group-hover:translate-x-1 transition-all self-center" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
} |