feat(ledger): add bookkeeping workbench and transaction views

- Add transactions_dirty table and zero-tolerance cleansing action

- Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier

- Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters

- Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
2026-09-06 23:23:29 +08:00
parent 1ccee1414c
commit c33857ffac
42 changed files with 3329 additions and 598 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
import { handlers } from "@/lib/auth";
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers;
export const { GET, POST } = handlers
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
import { redirect } from "next/navigation"
import { auth } from "@/lib/auth"
import { getDirtyTransactionsAction } from "@/lib/actions/bookkeeping"
import { getChannelsAction } from "@/lib/actions/channel"
import { AppSidebar } from "@/components/app-sidebar"
import { AppHeader } from "@/components/app-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { BookkeepingView } from "./bookkeeping-view"
export default async function BookkeepingPage() {
const session = await auth()
if (!session?.user) {
redirect("/login")
}
const user = session.user
const userName = user.name || "Fluxent 用户"
const userEmail = user.email || ""
const userAvatar = user.image || null
const [dirtyTxnsRes, channelsRes] = await Promise.all([
getDirtyTransactionsAction(),
getChannelsAction(),
])
const initialDirtyTransactions =
dirtyTxnsRes.success && dirtyTxnsRes.data ? dirtyTxnsRes.data : []
const initialChannels =
channelsRes.success && channelsRes.data ? channelsRes.data : []
return (
<SidebarProvider>
<AppSidebar
user={{
name: userName,
email: userEmail,
avatar: userAvatar,
}}
/>
<SidebarInset>
<AppHeader title="流水记账" />
<BookkeepingView
initialDirtyTransactions={initialDirtyTransactions}
initialChannels={initialChannels}
/>
</SidebarInset>
</SidebarProvider>
)
}
+3 -1
View File
@@ -473,7 +473,9 @@ export function ChannelsView({
</span>
</div>
<div className="flex items-center justify-between text-xs md:block">
<span className="text-muted-foreground md:hidden">类型</span>
<span className="text-muted-foreground md:hidden">
类型
</span>
<span>{channelTypeLabels[channel.channelType]}</span>
</div>
<div className="flex min-w-0 items-center justify-between gap-2 text-xs md:block">
+1 -5
View File
@@ -8,11 +8,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className="antialiased"
>
<html lang="en" suppressHydrationWarning className="antialiased">
<body>
<ThemeProvider>
<TooltipProvider>{children}</TooltipProvider>
+65 -63
View File
@@ -1,9 +1,9 @@
"use client";
"use client"
import * as React from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react";
import * as React from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"
import {
ArrowRightIcon,
CheckCircle2Icon,
@@ -16,10 +16,10 @@ import {
PieChartIcon,
TrendingUpIcon,
WalletIcon,
} from "lucide-react";
} from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -27,90 +27,90 @@ import {
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { FluxentLogo } from "@/components/fluxent-logo";
import { ThemeToggle } from "@/components/theme-toggle";
} from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { FluxentLogo } from "@/components/fluxent-logo"
import { ThemeToggle } from "@/components/theme-toggle"
interface LoginFormProps {
oidcEnabled: boolean;
oidcName: string;
oidcEnabled: boolean
oidcName: string
}
export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const authError = searchParams.get("error");
const registered = searchParams.get("registered");
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl") || "/"
const authError = searchParams.get("error")
const registered = searchParams.get("registered")
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [rememberMe, setRememberMe] = React.useState(false);
const [isPending, setIsPending] = React.useState(false);
const [isOidcPending, setIsOidcPending] = React.useState(false);
const [email, setEmail] = React.useState("")
const [password, setPassword] = React.useState("")
const [rememberMe, setRememberMe] = React.useState(false)
const [isPending, setIsPending] = React.useState(false)
const [isOidcPending, setIsOidcPending] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(() => {
if (authError === "CredentialsSignin") {
return "邮箱或密码错误,请核对后重试";
return "邮箱或密码错误,请核对后重试"
}
if (authError) {
return "认证过程中遇到问题,请重新登录";
return "认证过程中遇到问题,请重新登录"
}
return null;
});
return null
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
e.preventDefault()
setErrorMessage(null)
if (!email.trim()) {
setErrorMessage("请输入登录邮箱");
return;
setErrorMessage("请输入登录邮箱")
return
}
if (!password) {
setErrorMessage("请输入登录密码");
return;
setErrorMessage("请输入登录密码")
return
}
try {
setIsPending(true);
setIsPending(true)
const res = await signIn("credentials", {
email: email.trim(),
password,
redirect: false,
callbackUrl,
});
})
if (res?.error) {
if (res.error === "CredentialsSignin" || res.code === "credentials") {
setErrorMessage("账号或密码不正确,请重新检查");
setErrorMessage("账号或密码不正确,请重新检查")
} else {
setErrorMessage("登录失败,请稍后重试");
setErrorMessage("登录失败,请稍后重试")
}
return;
return
}
router.push(callbackUrl);
router.refresh();
router.push(callbackUrl)
router.refresh()
} catch {
setErrorMessage("网络异常,无法连接到认证服务器");
setErrorMessage("网络异常,无法连接到认证服务器")
} finally {
setIsPending(false);
setIsPending(false)
}
};
}
const handleOidcLogin = async () => {
try {
setIsOidcPending(true);
await signIn("oidc", { callbackUrl });
setIsOidcPending(true)
await signIn("oidc", { callbackUrl })
} catch {
setIsOidcPending(false);
setErrorMessage("SSO 单点登录发起失败,请稍后重试");
setIsOidcPending(false)
setErrorMessage("SSO 单点登录发起失败,请稍后重试")
}
};
}
return (
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
@@ -147,7 +147,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<div className="hidden flex-col gap-8 pr-4 lg:flex">
<div className="flex flex-col gap-3">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-border/80 bg-muted/60 px-3 py-1 text-xs text-muted-foreground backdrop-blur-xs">
<span className="inline-block size-1.5 rounded-full bg-primary animate-pulse" />
<span className="inline-block size-1.5 animate-pulse rounded-full bg-primary" />
全球多币种 • 全场景记账 • 实时汇率
</div>
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
@@ -155,7 +155,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<br />
让财务管理行云流水。
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-sm leading-relaxed text-muted-foreground">
无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent
提供银行级的精确记录与现代化的优雅交互体验。
</p>
@@ -170,7 +170,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground">
全币种与汇率追踪
</h3>
<p className="text-[11px] text-muted-foreground leading-normal">
<p className="text-[11px] leading-normal text-muted-foreground">
多币种入账与清算汇率对账,资产估值一目了然
</p>
</div>
@@ -182,7 +182,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground">
全渠道多账户管理
</h3>
<p className="text-[11px] text-muted-foreground leading-normal">
<p className="text-[11px] leading-normal text-muted-foreground">
银行账户、电子钱包与支付卡渠道统一调度
</p>
</div>
@@ -194,7 +194,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground">
复式记账与场景分类
</h3>
<p className="text-[11px] text-muted-foreground leading-normal">
<p className="text-[11px] leading-normal text-muted-foreground">
标准借贷分录与消费场景画像,财务合规严谨
</p>
</div>
@@ -206,7 +206,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground">
资产汇总与统计
</h3>
<p className="text-[11px] text-muted-foreground leading-normal">
<p className="text-[11px] leading-normal text-muted-foreground">
跨账户资金结构分布分析,收支报表一览无余
</p>
</div>
@@ -273,7 +273,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<div className="relative flex items-center justify-center">
<Separator className="w-full" />
<span className="absolute bg-card px-2 text-[11px] uppercase tracking-wider text-muted-foreground">
<span className="absolute bg-card px-2 text-[11px] tracking-wider text-muted-foreground uppercase">
或者使用邮箱密码
</span>
</div>
@@ -337,7 +337,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
/>
<label
htmlFor="rememberMe"
className="text-xs text-muted-foreground cursor-pointer select-none"
className="cursor-pointer text-xs text-muted-foreground select-none"
>
保持此设备的登录状态
</label>
@@ -373,7 +373,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
立即注册新账号
<ArrowRightIcon
data-icon="inline-end"
className="inline size-3 ml-0.5"
className="ml-0.5 inline size-3"
/>
</Link>
</p>
@@ -389,8 +389,10 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
{/* Footer */}
<footer className="relative z-10 flex h-12 items-center justify-center border-t border-border/40 px-6 text-center text-[11px] text-muted-foreground">
<span>© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。</span>
<span>
© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。
</span>
</footer>
</div>
);
)
}
+6 -6
View File
@@ -1,14 +1,14 @@
import { Suspense } from "react";
import { LoginForm } from "./login-form";
import { Suspense } from "react"
import { LoginForm } from "./login-form"
export const metadata = {
title: "登录 - Fluxent",
description: "Fluxent 多币种资产与全场景记账系统",
};
}
export default function LoginPage() {
const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true";
const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)";
const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true"
const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)"
return (
<Suspense
@@ -20,5 +20,5 @@ export default function LoginPage() {
>
<LoginForm oidcEnabled={oidcEnabled} oidcName={oidcName} />
</Suspense>
);
)
}
+22 -29
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { redirect } from "next/navigation"
import Link from "next/link"
import {
ArrowRightIcon,
CreditCardIcon,
@@ -9,35 +9,27 @@ import {
SlidersHorizontalIcon,
TrendingUpIcon,
WalletIcon,
} from "lucide-react";
} from "lucide-react"
import { auth } from "@/lib/auth";
import { AppSidebar } from "@/components/app-sidebar";
import { AppHeader } from "@/components/app-header";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar";
import { auth } from "@/lib/auth"
import { AppSidebar } from "@/components/app-sidebar"
import { AppHeader } from "@/components/app-header"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
export default async function HomePage() {
const session = await auth();
const session = await auth()
// 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障
if (!session?.user) {
redirect("/login");
redirect("/login")
}
const user = session.user;
const userName = user.name || "Fluxent 用户";
const userEmail = user.email || "";
const userAvatar = user.image || null;
const user = session.user
const userName = user.name || "Fluxent 用户"
const userEmail = user.email || ""
const userAvatar = user.image || null
return (
<SidebarProvider>
@@ -59,8 +51,9 @@ export default async function HomePage() {
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground md:text-3xl">
欢迎回来,{userName}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed">
这是您的 Fluxent 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
<p className="text-sm leading-relaxed text-muted-foreground">
这是您的 Fluxent
多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
@@ -150,7 +143,7 @@ export default async function HomePage() {
<h3 className="font-heading text-sm font-semibold text-foreground">
1. 添加账户与支付渠道
</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
<p className="text-xs leading-relaxed text-muted-foreground">
录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。
</p>
</div>
@@ -171,7 +164,7 @@ export default async function HomePage() {
<h3 className="font-heading text-sm font-semibold text-foreground">
2. 建立首笔复式流水
</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
<p className="text-xs leading-relaxed text-muted-foreground">
支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。
</p>
</div>
@@ -192,7 +185,7 @@ export default async function HomePage() {
<h3 className="font-heading text-sm font-semibold text-foreground">
3. 账户首选项与设置
</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
<p className="text-xs leading-relaxed text-muted-foreground">
配置个人偏好货币、导出记账数据或连接外部服务。
</p>
</div>
@@ -209,5 +202,5 @@ export default async function HomePage() {
</div>
</SidebarInset>
</SidebarProvider>
);
)
}
+4 -4
View File
@@ -1,10 +1,10 @@
import { Suspense } from "react";
import { RegisterForm } from "./register-form";
import { Suspense } from "react"
import { RegisterForm } from "./register-form"
export const metadata = {
title: "注册新账号 - Fluxent",
description: "创建您的 Fluxent 多币种资产与记账账户",
};
}
export default function RegisterPage() {
return (
@@ -17,5 +17,5 @@ export default function RegisterPage() {
>
<RegisterForm />
</Suspense>
);
)
}
+31 -29
View File
@@ -1,9 +1,9 @@
"use client";
"use client"
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useActionState } from "react";
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useActionState } from "react"
import {
ArrowLeftIcon,
CheckCircle2Icon,
@@ -13,11 +13,11 @@ import {
MailIcon,
SparklesIcon,
UserIcon,
} from "lucide-react";
} from "lucide-react"
import { registerAction, type RegisterState } from "@/lib/actions/auth";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { registerAction, type RegisterState } from "@/lib/actions/auth"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -25,45 +25,45 @@ import {
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { FluxentLogo } from "@/components/fluxent-logo";
import { ThemeToggle } from "@/components/theme-toggle";
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FluxentLogo } from "@/components/fluxent-logo"
import { ThemeToggle } from "@/components/theme-toggle"
const initialState: RegisterState = {};
const initialState: RegisterState = {}
export function RegisterForm() {
const router = useRouter();
const router = useRouter()
const [state, formAction, isPending] = useActionState(
registerAction,
initialState
);
)
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [password, setPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
// 当注册成功时,优雅倒计时或自动跳转
React.useEffect(() => {
if (state?.success) {
const timer = setTimeout(() => {
router.push("/login?registered=1");
}, 1500);
return () => clearTimeout(timer);
router.push("/login?registered=1")
}, 1500)
return () => clearTimeout(timer)
}
}, [state?.success, router]);
}, [state?.success, router])
// 密码复杂度提示计算
const hasMinLen = password.length >= 8;
const hasMinLen = password.length >= 8
const passwordsMatch = Boolean(
password && confirmPassword && password === confirmPassword
);
)
return (
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
{/* Decorative ambient gradients */}
<div className="pointer-events-none absolute -top-40 -left-40 size-[32rem] rounded-full bg-primary/5 blur-3xl" />
<div className="pointer-events-none absolute -bottom-40 -right-40 size-[34rem] rounded-full bg-primary/5 blur-3xl" />
<div className="pointer-events-none absolute -right-40 -bottom-40 size-[34rem] rounded-full bg-primary/5 blur-3xl" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.08),rgba(255,255,255,0))]" />
{/* Header */}
@@ -300,7 +300,7 @@ export function RegisterForm() {
>
<ArrowLeftIcon
data-icon="inline-start"
className="inline size-3 mr-0.5"
className="mr-0.5 inline size-3"
/>
返回直接登录
</Link>
@@ -312,8 +312,10 @@ export function RegisterForm() {
{/* Footer */}
<footer className="relative z-10 flex h-12 items-center justify-center border-t border-border/40 px-6 text-center text-[11px] text-muted-foreground">
<span>© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。</span>
<span>
© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。
</span>
</footer>
</div>
);
)
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation"
import { auth } from "@/lib/auth"
import { getOfficialTransactionsAction } from "@/lib/actions/transaction"
import { AppSidebar } from "@/components/app-sidebar"
import { AppHeader } from "@/components/app-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { TransactionsView } from "./transactions-view"
export default async function TransactionsPage() {
const session = await auth()
if (!session?.user) {
redirect("/login")
}
const user = session.user
const userName = user.name || "Fluxent 用户"
const userEmail = user.email || ""
const userAvatar = user.image || null
const txnsRes = await getOfficialTransactionsAction()
const initialTransactions =
txnsRes.success && txnsRes.data ? txnsRes.data : []
return (
<SidebarProvider>
<AppSidebar
user={{
name: userName,
email: userEmail,
avatar: userAvatar,
}}
/>
<SidebarInset>
<AppHeader title="交易记录" />
<TransactionsView initialTransactions={initialTransactions} />
</SidebarInset>
</SidebarProvider>
)
}
+776
View File
@@ -0,0 +1,776 @@
"use client"
import * as React from "react"
import Image from "next/image"
import { useRouter } from "next/navigation"
import Link from "next/link"
import {
ArrowRightLeftIcon,
BanknoteIcon,
CreditCardIcon,
FileTextIcon,
InboxIcon,
Loader2Icon,
MoreHorizontalIcon,
PlusIcon,
SearchIcon,
Trash2Icon,
WalletCardsIcon,
} from "lucide-react"
import {
deleteOfficialTransactionAction,
type TransactionWithChannelDetails,
} from "@/lib/actions/transaction"
import {
CARD_BRAND_LABELS,
getCardBrandLogoUrl,
normalizeCardBrand,
} from "@/lib/payment/card-brand"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface TransactionsViewProps {
initialTransactions: TransactionWithChannelDetails[]
}
const SCENE_MAP: Record<string, string> = {
PAYMENT: "消费支出",
ECOM_PAYMENT: "线上消费",
POS_PAYMENT: "线下刷卡",
MISC_IN: "日常收入",
TRANSFER: "转账",
ATM: "取现",
}
function getSceneBadge(scene: string, dcFlag: string) {
const label = SCENE_MAP[scene] || (dcFlag === "CREDIT" ? "收入" : "支出")
if (dcFlag === "CREDIT") {
return (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
>
{label}
</Badge>
)
}
return <Badge variant="secondary">{label}</Badge>
}
function renderChannelLogoOrIcon(
ch?: TransactionWithChannelDetails["channelDetails"][0]
) {
if (!ch) {
return <CreditCardIcon className="size-4 shrink-0 text-muted-foreground" />
}
if (ch.channelType === "PAYMENT_CARD") {
const brand = normalizeCardBrand(ch.cardBrand)
const logoUrl = getCardBrandLogoUrl(ch.cardBrand)
if (logoUrl && brand) {
return (
<Image
src={logoUrl}
alt={CARD_BRAND_LABELS[brand]}
width={18}
height={18}
className="size-4 shrink-0 object-contain"
/>
)
}
return <CreditCardIcon className="size-4 shrink-0 text-muted-foreground" />
}
if (ch.channelType === "E_WALLET") {
return <WalletCardsIcon className="size-4 shrink-0 text-muted-foreground" />
}
if (ch.channelType === "CASH") {
return <BanknoteIcon className="size-4 shrink-0 text-muted-foreground" />
}
return (
<ArrowRightLeftIcon className="size-4 shrink-0 text-muted-foreground" />
)
}
function formatDateHeader(dateVal: Date | string): string {
const d = new Date(dateVal)
if (isNaN(d.getTime())) return "未分类日期"
const y = d.getFullYear()
const m = d.getMonth() + 1
const day = d.getDate()
const weekDays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const week = weekDays[d.getDay()]
return `${y}年${m}月${day}日 ${week}`
}
function formatTime(dateVal: Date | string): string {
const d = new Date(dateVal)
if (isNaN(d.getTime())) return "--"
const hh = String(d.getHours()).padStart(2, "0")
const mm = String(d.getMinutes()).padStart(2, "0")
return `${hh}:${mm}`
}
function formatFullDateTime(dateVal?: Date | string | null): string {
if (!dateVal) return "--"
const d = new Date(dateVal)
if (isNaN(d.getTime())) return "--"
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
const hh = String(d.getHours()).padStart(2, "0")
const mm = String(d.getMinutes()).padStart(2, "0")
const ss = String(d.getSeconds()).padStart(2, "0")
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
export function TransactionsView({
initialTransactions,
}: TransactionsViewProps) {
const router = useRouter()
const [transactionsList, setTransactionsList] =
React.useState<TransactionWithChannelDetails[]>(initialTransactions)
const [searchQuery, setSearchQuery] = React.useState("")
const [dcFilter, setDcFilter] = React.useState<"ALL" | "DEBIT" | "CREDIT">(
"ALL"
)
// 查看分录详情弹窗
const [viewingTxn, setViewingTxn] =
React.useState<TransactionWithChannelDetails | null>(null)
// 删除确认弹窗
const [deletingTxn, setDeletingTxn] =
React.useState<TransactionWithChannelDetails | null>(null)
const [isDeleting, setIsDeleting] = React.useState(false)
// 检索过滤
const filteredTransactions = React.useMemo(() => {
const query = searchQuery.trim().toLowerCase()
return transactionsList.filter((item) => {
// 借贷过滤
if (dcFilter !== "ALL" && item.dcFlag !== dcFilter) {
return false
}
if (!query) return true
const chNames = (item.channelDetails || [])
.map((c) => c.displayName)
.join(" ")
const searchContent = [
item.merchantName,
item.cp,
item.description,
item.memo,
item.txnAmt,
item.txnCcy,
item.postingAmt,
item.postingCcy,
chNames,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
return searchContent.includes(query)
})
}, [transactionsList, searchQuery, dcFilter])
// 按自然日期分组
const groupedTransactions = React.useMemo(() => {
const groups: {
dateKey: string
dateLabel: string
items: TransactionWithChannelDetails[]
}[] = []
const groupMap = new Map<string, TransactionWithChannelDetails[]>()
filteredTransactions.forEach((txn) => {
const d = new Date(txn.txnDate)
const dateKey = isNaN(d.getTime())
? "unknown"
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
if (!groupMap.has(dateKey)) {
groupMap.set(dateKey, [])
}
groupMap.get(dateKey)!.push(txn)
})
groupMap.forEach((items, dateKey) => {
groups.push({
dateKey,
dateLabel:
dateKey === "unknown"
? "未分类日期"
: formatDateHeader(items[0].txnDate),
items,
})
})
return groups
}, [filteredTransactions])
const hasFilters = Boolean(searchQuery) || dcFilter !== "ALL"
const clearFilters = () => {
setSearchQuery("")
setDcFilter("ALL")
}
// 确认删除流水
const handleConfirmDelete = async () => {
if (!deletingTxn) return
setIsDeleting(true)
try {
const res = await deleteOfficialTransactionAction(deletingTxn.id)
if (res.success) {
setTransactionsList((prev) =>
prev.filter((item) => item.id !== deletingTxn.id)
)
setDeletingTxn(null)
router.refresh()
}
} finally {
setIsDeleting(false)
}
}
return (
<TooltipProvider>
<main className="flex min-w-0 flex-1 flex-col gap-4 p-4 md:p-6 lg:p-8">
{/* 页头控制与过滤检索区 */}
<div className="flex flex-col gap-4 border-b border-border/70 pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="flex items-center gap-2.5">
<h1 className="font-heading text-2xl font-semibold tracking-tight">
交易记录
</h1>
<Badge
variant="secondary"
className="px-2 py-0.5 text-xs font-normal"
>
已入账 {transactionsList.length} 笔
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
查看已清洗入账的正式总账流水与多币种对账
</p>
</div>
<Button
render={<Link href="/bookkeeping" />}
size="default"
className="w-full shadow-xs sm:w-auto"
>
<PlusIcon data-icon="inline-start" />
记一笔
</Button>
</div>
{/* 筛选控制器 */}
<div className="flex flex-col gap-2.5 md:flex-row md:items-center">
<div className="relative min-w-0 flex-1 md:max-w-md">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索商户、对手方、说明、备注或金额"
className="pl-8"
/>
</div>
<Select
value={dcFilter}
onValueChange={(val) =>
setDcFilter((val ?? "ALL") as "ALL" | "DEBIT" | "CREDIT")
}
>
<SelectTrigger className="w-full md:w-32">
<SelectValue>
{dcFilter === "ALL"
? "全部方向"
: dcFilter === "DEBIT"
? "仅支出"
: "仅收入"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="ALL">全部方向</SelectItem>
<SelectItem value="DEBIT">仅支出</SelectItem>
<SelectItem value="CREDIT">仅收入</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
清除筛选
</Button>
)}
</div>
{/* 流水列表区 */}
{groupedTransactions.length > 0 ? (
<div className="flex flex-col gap-6">
{groupedTransactions.map((group) => (
<section key={group.dateKey} className="flex flex-col gap-2">
{/* 日期分组标题 */}
<div className="flex items-center gap-2 px-1">
<h2 className="text-xs font-semibold tracking-wide text-muted-foreground">
{group.dateLabel}
</h2>
<span className="text-[11px] text-muted-foreground/80">
({group.items.length} 笔)
</span>
</div>
{/* 分组流水卡片容器 */}
<div className="overflow-hidden rounded-lg border border-border/70 bg-card shadow-xs">
{/* PC 列表表头 */}
<div className="hidden grid-cols-[minmax(200px,2fr)_minmax(120px,1.2fr)_minmax(140px,1.2fr)_minmax(140px,1.2fr)_80px_48px] items-center gap-3 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground md:grid">
<span>商户与描述</span>
<span>结算渠道</span>
<span className="text-right">发生金额 (原币)</span>
<span className="text-right">入账金额 (折算)</span>
<span className="text-center">场景</span>
<span />
</div>
<div className="divide-y divide-border/60">
{group.items.map((txn) => {
const ch = txn.channelDetails?.[0]
const isMultiCurrency =
txn.postingCcy &&
txn.postingCcy !== txn.txnCcy &&
txn.postingAmt
// 汇率计算显示
let rateDisplay: string | null = null
if (isMultiCurrency) {
const originalAmt = parseFloat(txn.txnAmt)
const postAmt = parseFloat(txn.postingAmt!)
if (
!isNaN(originalAmt) &&
!isNaN(postAmt) &&
originalAmt > 0
) {
const rate = postAmt / originalAmt
rateDisplay = `1 ${txn.txnCcy} ≈ ${rate.toFixed(4)} ${txn.postingCcy}`
}
}
const actionMenu = (
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="打开操作菜单"
/>
}
/>
}
>
<MoreHorizontalIcon />
</TooltipTrigger>
<TooltipContent>更多操作</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setViewingTxn(txn)}
>
<FileTextIcon data-icon="inline-start" />
查看明细
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingTxn(txn)}
>
<Trash2Icon data-icon="inline-start" />
删除流水
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
return (
<div
key={txn.id}
className="flex flex-col gap-2 p-3 transition-colors hover:bg-muted/30 md:grid md:grid-cols-[minmax(200px,2fr)_minmax(120px,1.2fr)_minmax(140px,1.2fr)_minmax(140px,1.2fr)_80px_48px] md:items-center md:gap-3 md:px-4 md:py-3"
>
{/* 移动端顶栏 (商户 + 更多操作在最右侧) / PC 端商户描述 */}
<div className="flex min-w-0 items-center justify-between gap-2 md:block">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground md:hidden">
{renderChannelLogoOrIcon(ch)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{txn.merchantName ||
txn.cp ||
txn.description ||
"未命名交易"}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground md:mt-0.5">
<span className="font-mono text-[11px]">
{formatTime(txn.txnDate)}
</span>
{txn.description && txn.merchantName && (
<>
<span>•</span>
<span className="max-w-[200px] truncate">
{txn.description}
</span>
</>
)}
</div>
</div>
</div>
{/* 移动端自然流右侧菜单 */}
<div className="shrink-0 md:hidden">
{actionMenu}
</div>
</div>
{/* 结算渠道 */}
<div className="hidden min-w-0 items-center gap-2 text-xs md:flex">
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{renderChannelLogoOrIcon(ch)}
</div>
<span className="truncate text-muted-foreground">
{ch?.displayName || "未关联渠道"}
</span>
</div>
{/* 发生金额 (原币) */}
<div className="flex items-baseline justify-between md:block md:text-right">
<span className="text-xs text-muted-foreground md:hidden">
发生金额
</span>
<div className="flex items-baseline gap-1 md:justify-end">
<span
className={`font-mono text-base font-semibold tracking-tight ${
txn.dcFlag === "CREDIT"
? "text-emerald-600 dark:text-emerald-400"
: "text-foreground"
}`}
>
{txn.dcFlag === "CREDIT" ? "+" : "-"}
{txn.txnAmt}
</span>
<span className="font-mono text-[11px] text-muted-foreground uppercase">
{txn.txnCcy}
</span>
</div>
</div>
{/* 入账金额 (折算) */}
<div className="flex items-baseline justify-between text-xs md:block md:text-right">
<span className="text-muted-foreground md:hidden">
入账折算
</span>
<div className="flex flex-col items-end gap-0.5">
{isMultiCurrency ? (
<>
<div className="flex items-baseline gap-1 font-mono font-medium">
<span>
{txn.dcFlag === "CREDIT" ? "+" : "-"}
{txn.postingAmt}
</span>
<span className="text-[10px] text-muted-foreground uppercase">
{txn.postingCcy}
</span>
</div>
{rateDisplay && (
<span className="text-[10px] text-muted-foreground">
{rateDisplay}
</span>
)}
</>
) : (
<span className="font-mono text-muted-foreground">
等额入账
</span>
)}
</div>
</div>
{/* 场景徽标 */}
<div className="flex items-center justify-between md:justify-center">
<span className="text-xs text-muted-foreground md:hidden">
场景类型
</span>
{getSceneBadge(txn.txnScene, txn.dcFlag)}
</div>
{/* PC 端操作按钮 */}
<div className="hidden md:flex md:justify-end">
{actionMenu}
</div>
</div>
)
})}
</div>
</div>
</section>
))}
</div>
) : (
/* 空状态展示 */
<div className="flex min-h-[380px] flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 p-8 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<InboxIcon className="size-6" />
</div>
<h3 className="mt-4 text-sm font-semibold">
{hasFilters ? "未找到符合条件的流水" : "暂无已入账的交易流水"}
</h3>
<p className="mt-1.5 max-w-sm text-xs text-muted-foreground">
{hasFilters
? "调整关键词或重置筛选条件后再试。"
: "可在「流水记账」中快速录入并确认清洗入账,入账后的总账流水将完整汇总于此。"}
</p>
<div className="mt-4 flex gap-2">
{hasFilters ? (
<Button variant="outline" size="sm" onClick={clearFilters}>
清除筛选
</Button>
) : (
<Button render={<Link href="/bookkeeping" />} size="sm">
<PlusIcon data-icon="inline-start" />
前往记账
</Button>
)}
</div>
</div>
)}
{/* 分录明细弹窗 */}
<Dialog
open={Boolean(viewingTxn)}
onOpenChange={(open) => !open && setViewingTxn(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>交易分录明细</DialogTitle>
<DialogDescription>
查看该笔总账交易的原始要素、渠道及折算入账数据
</DialogDescription>
</DialogHeader>
{viewingTxn && (
<div className="flex flex-col gap-3.5 py-1 text-xs">
<div className="grid grid-cols-2 gap-3 rounded-lg border bg-muted/20 p-3">
<div>
<span className="text-muted-foreground">流水标识</span>
<p className="mt-0.5 truncate font-mono text-[11px] select-all">
{viewingTxn.id}
</p>
</div>
<div>
<span className="text-muted-foreground">版本号</span>
<p className="mt-0.5 font-mono font-medium">
v{viewingTxn.version}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-muted-foreground">交易时间</span>
<p className="mt-0.5 font-medium">
{formatFullDateTime(viewingTxn.txnDate)}
</p>
</div>
<div>
<span className="text-muted-foreground">借贷方向</span>
<p className="mt-0.5 font-medium">
{viewingTxn.dcFlag === "CREDIT"
? "收入 (贷方)"
: "支出 (借方)"}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-muted-foreground">原始发生额</span>
<p className="mt-0.5 font-mono text-sm font-bold">
{viewingTxn.txnAmt} {viewingTxn.txnCcy}
</p>
</div>
<div>
<span className="text-muted-foreground">最终入账额</span>
<p className="mt-0.5 font-mono text-sm font-bold text-primary">
{viewingTxn.postingAmt || viewingTxn.txnAmt}{" "}
{viewingTxn.postingCcy || viewingTxn.txnCcy}
</p>
</div>
</div>
{(viewingTxn.commAmt ||
viewingTxn.surchargeAmt ||
viewingTxn.discAmt) && (
<div className="space-y-1 rounded-md border bg-muted/10 p-2.5">
<span className="font-semibold text-muted-foreground">
费用与优惠细目
</span>
{viewingTxn.commAmt && (
<div className="flex justify-between">
<span className="text-muted-foreground">手续费</span>
<span className="font-mono">
{viewingTxn.commAmt}{" "}
{viewingTxn.commCcy || viewingTxn.txnCcy}
</span>
</div>
)}
{viewingTxn.surchargeAmt && (
<div className="flex justify-between">
<span className="text-muted-foreground">附加费</span>
<span className="font-mono">
{viewingTxn.surchargeAmt}{" "}
{viewingTxn.surchargeCcy || viewingTxn.txnCcy}
</span>
</div>
)}
{viewingTxn.discAmt && (
<div className="flex justify-between">
<span className="text-muted-foreground">优惠折扣</span>
<span className="font-mono text-emerald-600">
-{viewingTxn.discAmt}{" "}
{viewingTxn.discCcy || viewingTxn.txnCcy}
</span>
</div>
)}
</div>
)}
<div className="space-y-1">
<span className="text-muted-foreground">结算渠道</span>
<p className="font-medium">
{viewingTxn.channelDetails?.[0]?.displayName ||
"未关联渠道"}
</p>
</div>
{viewingTxn.merchantName && (
<div className="space-y-1">
<span className="text-muted-foreground">商户名称</span>
<p className="font-medium">{viewingTxn.merchantName}</p>
</div>
)}
{viewingTxn.description && (
<div className="space-y-1">
<span className="text-muted-foreground">交易说明</span>
<p className="text-muted-foreground">
{viewingTxn.description}
</p>
</div>
)}
{viewingTxn.memo && (
<div className="space-y-1">
<span className="text-muted-foreground">备注信息</span>
<p className="text-muted-foreground">{viewingTxn.memo}</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingTxn(null)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认弹窗 */}
<Dialog
open={Boolean(deletingTxn)}
onOpenChange={(open) => !open && setDeletingTxn(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除总账流水</DialogTitle>
<DialogDescription>
将删除「
{deletingTxn?.merchantName ||
deletingTxn?.cp ||
deletingTxn?.description ||
`金额 ${deletingTxn?.txnAmt} ${deletingTxn?.txnCcy}`}
」的正式总账记录。该操作不可撤回,确认要删除吗?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeletingTxn(null)}
disabled={isDeleting}
>
取消
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting && (
<Loader2Icon
data-icon="inline-start"
className="animate-spin"
/>
)}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</TooltipProvider>
)
}