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
+24 -21
View File
@@ -8,32 +8,35 @@ This version has breaking changes — APIs, conventions, and file structure may
## Commands ## Commands
- Use `pnpm`. The available checks are `pnpm lint`, `pnpm typecheck`, and `pnpm build`; run `pnpm typecheck && pnpm build` after application changes. There is no committed test suite or CI workflow. - Use `pnpm`. Primary checks are `pnpm lint`, `pnpm typecheck`, and `pnpm build`; run `pnpm typecheck && pnpm build` to verify changes. There is no committed test suite.
- Add UI primitives only through `pnpm dlx shadcn@latest add <component> --yes`. Before using a shadcn component, run `pnpm dlx shadcn@latest docs <component>` and read the referenced docs. - Add UI primitives only via `pnpm dlx shadcn@latest add <component> --yes`. Read the referenced docs via `pnpm dlx shadcn@latest docs <component>` before using.
- Formatting is Prettier with Tailwind sorting (`pnpm format`). It uses double quotes, no semicolons, 2 spaces, LF, and 80-column wrapping. - Format with `pnpm format` (Prettier + Tailwind plugin: double quotes, no semicolons, 2 spaces, LF, 80-column wrap).
## App And Auth ## App And Auth
- This is a single Next.js App Router application; routes live under `app/`. Authenticated pages compose `SidebarProvider`, `AppSidebar`, and `SidebarInset` themselves. - Next.js 16 App Router application under `app/`. Authenticated pages compose `SidebarProvider`, `AppSidebar`, and `SidebarInset` themselves.
- Next.js 16 uses `proxy.ts`, not `middleware.ts`. Keep `proxy.ts` edge-safe: it imports only `lib/auth/config.ts`, never database or password-hashing code. - Next.js 16 uses `proxy.ts`, NOT `middleware.ts`. Keep `proxy.ts` edge-safe: import only `lib/auth/config.ts`, never database or password-hashing code.
- Login, registration, and `/api/auth` are public. All other routes are protected by `authConfig`; server pages and actions must still validate the session themselves. - Public routes: `/login`, `/register`, `/api/auth/*`. All other routes are protected by `proxy.ts`; server pages and server actions must still validate sessions via `auth()`.
- `lib/auth/index.ts` owns Node-side Auth.js providers and database work. OIDC is configuration-driven and disabled unless `AUTH_OIDC_ENABLED=true` with a complete issuer/client configuration. - `lib/auth/index.ts` owns Node-side Auth.js providers and database operations. OIDC is disabled unless `AUTH_OIDC_ENABLED=true` with full issuer/client credentials configured.
- Sidebar active state must derive from `usePathname()` (`/` exact match, child routes prefix match). Do not add navigation links until their route exists.
## Database ## Database And Ledger Architecture
- PostgreSQL access is Drizzle + `postgres`; the schema source of truth is `lib/db/schema.ts`. Drizzle Kit loads `DATABASE_URL` from `.env.local` and uses `drizzle.config.ts`. - PostgreSQL access via Drizzle ORM + `postgres`; schema truth is `lib/db/schema.ts`. Drizzle Kit loads `DATABASE_URL` from `.env.local` via `drizzle.config.ts`.
- Use `pnpm drizzle-kit push --force` only when a schema change is intended; it can apply destructive changes. `pnpm tsx lib/db/reset.ts` drops `transactions`, `channels`, `accounts`, `user_accounts`, and `users` and must never be run casually. - Schema sync: `pnpm drizzle-kit push --force`. Never execute `pnpm tsx lib/db/reset.ts` unless explicitly instructed (drops all data tables).
- Business tables are user-scoped. Every read, update, or delete in `lib/actions/` must obtain `auth().user.id` and constrain the query with that `user_id`; validate that referenced account/channel IDs belong to the same user. - Multi-tenancy & safety: Every action in `lib/actions/` must enforce `userId = session.user.id` and filter out soft-deleted records with `isNull(table.deletedAt)`. Verify that referenced accounts or channels belong to the same user.
- Keep soft deletion (`deleted_at`) semantics in reads and mutations. Account and channel management actions already follow this model. - Two-tier transaction ledger:
- Fast/draft entry stores records in `transactions_dirty`.
- Cleansing action (`cleanseTransactionsAction` in `lib/actions/bookkeeping.ts`) performs atomic zero-tolerance validation before moving items into `transactions`.
- Card brands: Stored as lowercase machine keys (`visa`, `mastercard`, `unionpay`, `amex`, `diners`, `discover`, `jcb`). Always use `normalizeCardBrand()` from `lib/payment/card-brand.ts` and local SVGs under `/payment-logos/`.
## UI And Copy ## UI And Copy
- The project uses shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, and semantic CSS variables from `app/globals.css`. - Tech stack: shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, semantic CSS variables from `app/globals.css`.
- Prefer stock shadcn composition and variants over custom styling. Use `className` only for necessary layout, responsiveness, truncation, or stable dimensions; do not override component padding, margins, colors, typography, or default Dialog/Footer behavior without a verified need. - Base UI Select & Combobox:
- Forms use `FieldGroup`, `Field`, and `FieldLabel`; use `FieldSet`/`FieldLegend` only when the grouping adds user-facing meaning. Put `SelectItem` inside `SelectGroup`. - `<SelectValue />`: When `<SelectItem>` contains complex JSX/icons and no plain string children, Base UI falls back to rendering raw values (e.g. UUIDs). Provide plain text `children` or explicit formatting logic.
- Errors and callouts use `Alert`; destructive errors use `Alert variant="destructive"` with `AlertTitle` and `AlertDescription`, never a hand-styled `div`. - `<Combobox>`: When `items` is an array of objects, always provide both `itemToStringValue` (string serialization for search matching and value keying) and `itemToStringLabel` (input display string) to avoid `[object Object]` display bugs.
- Follow the default Dialog composition: `DialogHeader`, form/content, then `DialogFooter` as a direct `DialogContent` child. Do not add `p-0`, custom negative margins, fixed heights, sticky footers, or isolated scroll containers unless the task explicitly requires them. - Form composition: Use `FieldGroup`, `Field`, and `FieldLabel`. Put `SelectItem` inside `SelectGroup`. Use `FieldSet`/`FieldLegend` only when grouping adds visible user meaning.
- Use `Button`, `Badge`, `Empty`, `Separator`, `Tooltip`, and other installed primitives instead of recreating them. Use `data-icon="inline-start"` or `data-icon="inline-end"` for icons in buttons. - Dialog composition: Follow standard structure (`DialogHeader`, form/content, `DialogFooter` directly inside `DialogContent`). Do not add `p-0`, negative margins, sticky footers, or manual scroll wrappers without a verified requirement.
- Use `gap-*`, never `space-x-*` or `space-y-*`; use semantic tokens instead of raw color palettes or manual `dark:` overrides. - Styling: Use `gap-*`, NEVER `space-x-*` or `space-y-*`. Use semantic color tokens, avoid hardcoded palettes or manual `dark:` overrides. Buttons with icons use `data-icon="inline-start"` or `data-icon="inline-end"`.
- Product-facing UI copy is Chinese only. Do not add English parentheticals to menus, labels, options, or headings; retain user-entered business values such as currency codes and card brands verbatim. - Product copy is Chinese only: Do not add English parentheticals to labels, menus, options, or headings (e.g., no "账户 (Accounts)"). Retain user-entered values and standard business tokens (e.g., currency codes `CNY`, card brand names `Visa`) verbatim.
- Sidebar active state must derive from `usePathname()` with exact matching for `/` and route-boundary matching for child paths. Do not add navigation links until their route exists.
+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> </span>
</div> </div>
<div className="flex items-center justify-between text-xs md:block"> <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> <span>{channelTypeLabels[channel.channelType]}</span>
</div> </div>
<div className="flex min-w-0 items-center justify-between gap-2 text-xs md:block"> <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 children: React.ReactNode
}>) { }>) {
return ( return (
<html <html lang="en" suppressHydrationWarning className="antialiased">
lang="en"
suppressHydrationWarning
className="antialiased"
>
<body> <body>
<ThemeProvider> <ThemeProvider>
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider>{children}</TooltipProvider>
+65 -63
View File
@@ -1,9 +1,9 @@
"use client"; "use client"
import * as React from "react"; import * as React from "react"
import Link from "next/link"; import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react"
import { import {
ArrowRightIcon, ArrowRightIcon,
CheckCircle2Icon, CheckCircle2Icon,
@@ -16,10 +16,10 @@ import {
PieChartIcon, PieChartIcon,
TrendingUpIcon, TrendingUpIcon,
WalletIcon, WalletIcon,
} from "lucide-react"; } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
@@ -27,90 +27,90 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator"
import { FluxentLogo } from "@/components/fluxent-logo"; import { FluxentLogo } from "@/components/fluxent-logo"
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle"
interface LoginFormProps { interface LoginFormProps {
oidcEnabled: boolean; oidcEnabled: boolean
oidcName: string; oidcName: string
} }
export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) { export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
const router = useRouter(); const router = useRouter()
const searchParams = useSearchParams(); const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl") || "/"; const callbackUrl = searchParams.get("callbackUrl") || "/"
const authError = searchParams.get("error"); const authError = searchParams.get("error")
const registered = searchParams.get("registered"); const registered = searchParams.get("registered")
const [email, setEmail] = React.useState(""); const [email, setEmail] = React.useState("")
const [password, setPassword] = React.useState(""); const [password, setPassword] = React.useState("")
const [rememberMe, setRememberMe] = React.useState(false); const [rememberMe, setRememberMe] = React.useState(false)
const [isPending, setIsPending] = React.useState(false); const [isPending, setIsPending] = React.useState(false)
const [isOidcPending, setIsOidcPending] = React.useState(false); const [isOidcPending, setIsOidcPending] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(() => { const [errorMessage, setErrorMessage] = React.useState<string | null>(() => {
if (authError === "CredentialsSignin") { if (authError === "CredentialsSignin") {
return "邮箱或密码错误,请核对后重试"; return "邮箱或密码错误,请核对后重试"
} }
if (authError) { if (authError) {
return "认证过程中遇到问题,请重新登录"; return "认证过程中遇到问题,请重新登录"
} }
return null; return null
}); })
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
setErrorMessage(null); setErrorMessage(null)
if (!email.trim()) { if (!email.trim()) {
setErrorMessage("请输入登录邮箱"); setErrorMessage("请输入登录邮箱")
return; return
} }
if (!password) { if (!password) {
setErrorMessage("请输入登录密码"); setErrorMessage("请输入登录密码")
return; return
} }
try { try {
setIsPending(true); setIsPending(true)
const res = await signIn("credentials", { const res = await signIn("credentials", {
email: email.trim(), email: email.trim(),
password, password,
redirect: false, redirect: false,
callbackUrl, callbackUrl,
}); })
if (res?.error) { if (res?.error) {
if (res.error === "CredentialsSignin" || res.code === "credentials") { if (res.error === "CredentialsSignin" || res.code === "credentials") {
setErrorMessage("账号或密码不正确,请重新检查"); setErrorMessage("账号或密码不正确,请重新检查")
} else { } else {
setErrorMessage("登录失败,请稍后重试"); setErrorMessage("登录失败,请稍后重试")
} }
return; return
} }
router.push(callbackUrl); router.push(callbackUrl)
router.refresh(); router.refresh()
} catch { } catch {
setErrorMessage("网络异常,无法连接到认证服务器"); setErrorMessage("网络异常,无法连接到认证服务器")
} finally { } finally {
setIsPending(false); setIsPending(false)
} }
}; }
const handleOidcLogin = async () => { const handleOidcLogin = async () => {
try { try {
setIsOidcPending(true); setIsOidcPending(true)
await signIn("oidc", { callbackUrl }); await signIn("oidc", { callbackUrl })
} catch { } catch {
setIsOidcPending(false); setIsOidcPending(false)
setErrorMessage("SSO 单点登录发起失败,请稍后重试"); setErrorMessage("SSO 单点登录发起失败,请稍后重试")
} }
}; }
return ( return (
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background"> <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="hidden flex-col gap-8 pr-4 lg:flex">
<div className="flex flex-col gap-3"> <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"> <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> </div>
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground sm:text-4xl"> <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 /> <br />
让财务管理行云流水。 让财务管理行云流水。
</h1> </h1>
<p className="text-muted-foreground text-sm leading-relaxed"> <p className="text-sm leading-relaxed text-muted-foreground">
无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent 无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent
提供银行级的精确记录与现代化的优雅交互体验。 提供银行级的精确记录与现代化的优雅交互体验。
</p> </p>
@@ -170,7 +170,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground"> <h3 className="text-xs font-semibold text-foreground">
全币种与汇率追踪 全币种与汇率追踪
</h3> </h3>
<p className="text-[11px] text-muted-foreground leading-normal"> <p className="text-[11px] leading-normal text-muted-foreground">
多币种入账与清算汇率对账,资产估值一目了然 多币种入账与清算汇率对账,资产估值一目了然
</p> </p>
</div> </div>
@@ -182,7 +182,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground"> <h3 className="text-xs font-semibold text-foreground">
全渠道多账户管理 全渠道多账户管理
</h3> </h3>
<p className="text-[11px] text-muted-foreground leading-normal"> <p className="text-[11px] leading-normal text-muted-foreground">
银行账户、电子钱包与支付卡渠道统一调度 银行账户、电子钱包与支付卡渠道统一调度
</p> </p>
</div> </div>
@@ -194,7 +194,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground"> <h3 className="text-xs font-semibold text-foreground">
复式记账与场景分类 复式记账与场景分类
</h3> </h3>
<p className="text-[11px] text-muted-foreground leading-normal"> <p className="text-[11px] leading-normal text-muted-foreground">
标准借贷分录与消费场景画像,财务合规严谨 标准借贷分录与消费场景画像,财务合规严谨
</p> </p>
</div> </div>
@@ -206,7 +206,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<h3 className="text-xs font-semibold text-foreground"> <h3 className="text-xs font-semibold text-foreground">
资产汇总与统计 资产汇总与统计
</h3> </h3>
<p className="text-[11px] text-muted-foreground leading-normal"> <p className="text-[11px] leading-normal text-muted-foreground">
跨账户资金结构分布分析,收支报表一览无余 跨账户资金结构分布分析,收支报表一览无余
</p> </p>
</div> </div>
@@ -273,7 +273,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
<div className="relative flex items-center justify-center"> <div className="relative flex items-center justify-center">
<Separator className="w-full" /> <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> </span>
</div> </div>
@@ -337,7 +337,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
/> />
<label <label
htmlFor="rememberMe" htmlFor="rememberMe"
className="text-xs text-muted-foreground cursor-pointer select-none" className="cursor-pointer text-xs text-muted-foreground select-none"
> >
保持此设备的登录状态 保持此设备的登录状态
</label> </label>
@@ -373,7 +373,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
立即注册新账号 立即注册新账号
<ArrowRightIcon <ArrowRightIcon
data-icon="inline-end" data-icon="inline-end"
className="inline size-3 ml-0.5" className="ml-0.5 inline size-3"
/> />
</Link> </Link>
</p> </p>
@@ -389,8 +389,10 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
{/* Footer */} {/* 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"> <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> </footer>
</div> </div>
); )
} }
+6 -6
View File
@@ -1,14 +1,14 @@
import { Suspense } from "react"; import { Suspense } from "react"
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form"
export const metadata = { export const metadata = {
title: "登录 - Fluxent", title: "登录 - Fluxent",
description: "Fluxent 多币种资产与全场景记账系统", description: "Fluxent 多币种资产与全场景记账系统",
}; }
export default function LoginPage() { export default function LoginPage() {
const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true"; const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true"
const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)"; const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)"
return ( return (
<Suspense <Suspense
@@ -20,5 +20,5 @@ export default function LoginPage() {
> >
<LoginForm oidcEnabled={oidcEnabled} oidcName={oidcName} /> <LoginForm oidcEnabled={oidcEnabled} oidcName={oidcName} />
</Suspense> </Suspense>
); )
} }
+22 -29
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation"
import Link from "next/link"; import Link from "next/link"
import { import {
ArrowRightIcon, ArrowRightIcon,
CreditCardIcon, CreditCardIcon,
@@ -9,35 +9,27 @@ import {
SlidersHorizontalIcon, SlidersHorizontalIcon,
TrendingUpIcon, TrendingUpIcon,
WalletIcon, WalletIcon,
} from "lucide-react"; } from "lucide-react"
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth"
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar"
import { AppHeader } from "@/components/app-header"; import { AppHeader } from "@/components/app-header"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
Card, import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar";
export default async function HomePage() { export default async function HomePage() {
const session = await auth(); const session = await auth()
// 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障 // 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login")
} }
const user = session.user; const user = session.user
const userName = user.name || "Fluxent 用户"; const userName = user.name || "Fluxent 用户"
const userEmail = user.email || ""; const userEmail = user.email || ""
const userAvatar = user.image || null; const userAvatar = user.image || null
return ( return (
<SidebarProvider> <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"> <h1 className="font-heading text-2xl font-bold tracking-tight text-foreground md:text-3xl">
欢迎回来,{userName} 欢迎回来,{userName}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm leading-relaxed text-muted-foreground">
这是您的 Fluxent 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。 这是您的 Fluxent
多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-3"> <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"> <h3 className="font-heading text-sm font-semibold text-foreground">
1. 添加账户与支付渠道 1. 添加账户与支付渠道
</h3> </h3>
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs leading-relaxed text-muted-foreground">
录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。 录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。
</p> </p>
</div> </div>
@@ -171,7 +164,7 @@ export default async function HomePage() {
<h3 className="font-heading text-sm font-semibold text-foreground"> <h3 className="font-heading text-sm font-semibold text-foreground">
2. 建立首笔复式流水 2. 建立首笔复式流水
</h3> </h3>
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs leading-relaxed text-muted-foreground">
支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。 支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。
</p> </p>
</div> </div>
@@ -192,7 +185,7 @@ export default async function HomePage() {
<h3 className="font-heading text-sm font-semibold text-foreground"> <h3 className="font-heading text-sm font-semibold text-foreground">
3. 账户首选项与设置 3. 账户首选项与设置
</h3> </h3>
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs leading-relaxed text-muted-foreground">
配置个人偏好货币、导出记账数据或连接外部服务。 配置个人偏好货币、导出记账数据或连接外部服务。
</p> </p>
</div> </div>
@@ -209,5 +202,5 @@ export default async function HomePage() {
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
); )
} }
+4 -4
View File
@@ -1,10 +1,10 @@
import { Suspense } from "react"; import { Suspense } from "react"
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form"
export const metadata = { export const metadata = {
title: "注册新账号 - Fluxent", title: "注册新账号 - Fluxent",
description: "创建您的 Fluxent 多币种资产与记账账户", description: "创建您的 Fluxent 多币种资产与记账账户",
}; }
export default function RegisterPage() { export default function RegisterPage() {
return ( return (
@@ -17,5 +17,5 @@ export default function RegisterPage() {
> >
<RegisterForm /> <RegisterForm />
</Suspense> </Suspense>
); )
} }
+31 -29
View File
@@ -1,9 +1,9 @@
"use client"; "use client"
import * as React from "react"; import * as React from "react"
import Link from "next/link"; import Link from "next/link"
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation"
import { useActionState } from "react"; import { useActionState } from "react"
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CheckCircle2Icon, CheckCircle2Icon,
@@ -13,11 +13,11 @@ import {
MailIcon, MailIcon,
SparklesIcon, SparklesIcon,
UserIcon, UserIcon,
} from "lucide-react"; } from "lucide-react"
import { registerAction, type RegisterState } from "@/lib/actions/auth"; import { registerAction, type RegisterState } from "@/lib/actions/auth"
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
@@ -25,45 +25,45 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card"
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label"
import { FluxentLogo } from "@/components/fluxent-logo"; import { FluxentLogo } from "@/components/fluxent-logo"
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle"
const initialState: RegisterState = {}; const initialState: RegisterState = {}
export function RegisterForm() { export function RegisterForm() {
const router = useRouter(); const router = useRouter()
const [state, formAction, isPending] = useActionState( const [state, formAction, isPending] = useActionState(
registerAction, registerAction,
initialState initialState
); )
const [password, setPassword] = React.useState(""); const [password, setPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState(""); const [confirmPassword, setConfirmPassword] = React.useState("")
// 当注册成功时,优雅倒计时或自动跳转 // 当注册成功时,优雅倒计时或自动跳转
React.useEffect(() => { React.useEffect(() => {
if (state?.success) { if (state?.success) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
router.push("/login?registered=1"); router.push("/login?registered=1")
}, 1500); }, 1500)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [state?.success, router]); }, [state?.success, router])
// 密码复杂度提示计算 // 密码复杂度提示计算
const hasMinLen = password.length >= 8; const hasMinLen = password.length >= 8
const passwordsMatch = Boolean( const passwordsMatch = Boolean(
password && confirmPassword && password === confirmPassword password && confirmPassword && password === confirmPassword
); )
return ( return (
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background"> <div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
{/* Decorative ambient gradients */} {/* 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 -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))]" /> <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 */} {/* Header */}
@@ -300,7 +300,7 @@ export function RegisterForm() {
> >
<ArrowLeftIcon <ArrowLeftIcon
data-icon="inline-start" data-icon="inline-start"
className="inline size-3 mr-0.5" className="mr-0.5 inline size-3"
/> />
返回直接登录 返回直接登录
</Link> </Link>
@@ -312,8 +312,10 @@ export function RegisterForm() {
{/* Footer */} {/* 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"> <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> </footer>
</div> </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>
)
}
+2 -4
View File
@@ -7,9 +7,7 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb" } from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { import { SidebarTrigger } from "@/components/ui/sidebar"
SidebarTrigger,
} from "@/components/ui/sidebar"
import { ThemeToggle } from "@/components/theme-toggle" import { ThemeToggle } from "@/components/theme-toggle"
export function AppHeader({ title }: { title: string }) { export function AppHeader({ title }: { title: string }) {
@@ -17,7 +15,7 @@ export function AppHeader({ title }: { title: string }) {
return ( return (
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex min-w-0 h-5 items-center gap-2"> <div className="flex h-5 min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2" /> <Separator orientation="vertical" className="mr-2" />
<Breadcrumb> <Breadcrumb>
+26 -14
View File
@@ -1,16 +1,18 @@
"use client"; "use client"
import * as React from "react"; import * as React from "react"
import Link from "next/link"; import Link from "next/link"
import { import {
CreditCardIcon, CreditCardIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
PenToolIcon,
ReceiptTextIcon,
WalletCardsIcon, WalletCardsIcon,
} from "lucide-react"; } from "lucide-react"
import { FluxentLogo } from "@/components/fluxent-logo"; import { FluxentLogo } from "@/components/fluxent-logo"
import { NavMain, type NavMainItem } from "@/components/nav-main"; import { NavMain, type NavMainItem } from "@/components/nav-main"
import { NavUser, type NavUserData } from "@/components/nav-user"; import { NavUser, type NavUserData } from "@/components/nav-user"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -20,7 +22,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar"
const navMainItems: NavMainItem[] = [ const navMainItems: NavMainItem[] = [
{ {
@@ -28,6 +30,16 @@ const navMainItems: NavMainItem[] = [
url: "/", url: "/",
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
}, },
{
title: "流水记账",
url: "/bookkeeping",
icon: PenToolIcon,
},
{
title: "交易记录",
url: "/transactions",
icon: ReceiptTextIcon,
},
{ {
title: "资金账户", title: "资金账户",
url: "/accounts", url: "/accounts",
@@ -38,21 +50,21 @@ const navMainItems: NavMainItem[] = [
url: "/channels", url: "/channels",
icon: CreditCardIcon, icon: CreditCardIcon,
}, },
]; ]
export function AppSidebar({ export function AppSidebar({
user, user,
...props ...props
}: { }: {
user: NavUserData; user: NavUserData
} & React.ComponentProps<typeof Sidebar>) { } & React.ComponentProps<typeof Sidebar>) {
const { isMobile, setOpenMobile } = useSidebar(); const { isMobile, setOpenMobile } = useSidebar()
const handleLogoNavigation = () => { const handleLogoNavigation = () => {
if (isMobile) { if (isMobile) {
setOpenMobile(false); setOpenMobile(false)
} }
}; }
return ( return (
<Sidebar variant="inset" {...props}> <Sidebar variant="inset" {...props}>
@@ -89,5 +101,5 @@ export function AppSidebar({
<NavUser user={user} /> <NavUser user={user} />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
); )
} }
+5 -5
View File
@@ -1,12 +1,12 @@
import React from "react"; import React from "react"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
export function FluxentLogo({ export function FluxentLogo({
className, className,
size = 36, size = 36,
}: { }: {
className?: string; className?: string
size?: number; size?: number
}) { }) {
return ( return (
<div <div
@@ -44,5 +44,5 @@ export function FluxentLogo({
<circle cx="15" cy="20" r="1.5" fill="currentColor" fillOpacity="0.8" /> <circle cx="15" cy="20" r="1.5" fill="currentColor" fillOpacity="0.8" />
</svg> </svg>
</div> </div>
); )
} }
+18 -17
View File
@@ -1,8 +1,8 @@
"use client"; "use client"
import Link from "next/link"; import Link from "next/link"
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation"
import { type LucideIcon } from "lucide-react"; import { type LucideIcon } from "lucide-react"
import { import {
SidebarGroup, SidebarGroup,
@@ -11,24 +11,24 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar"
export interface NavMainItem { export interface NavMainItem {
title: string; title: string
url: string; url: string
icon: LucideIcon; icon: LucideIcon
badge?: string; badge?: string
} }
export function NavMain({ items }: { items: NavMainItem[] }) { export function NavMain({ items }: { items: NavMainItem[] }) {
const pathname = usePathname(); const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar(); const { isMobile, setOpenMobile } = useSidebar()
const handleNavigation = () => { const handleNavigation = () => {
if (isMobile) { if (isMobile) {
setOpenMobile(false); setOpenMobile(false)
} }
}; }
return ( return (
<SidebarGroup> <SidebarGroup>
@@ -37,14 +37,15 @@ export function NavMain({ items }: { items: NavMainItem[] }) {
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon
return ( return (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton <SidebarMenuButton
isActive={ isActive={
item.url === "/" item.url === "/"
? pathname === "/" ? pathname === "/"
: pathname === item.url || pathname.startsWith(`${item.url}/`) : pathname === item.url ||
pathname.startsWith(`${item.url}/`)
} }
tooltip={item.title} tooltip={item.title}
render={<Link href={item.url} onClick={handleNavigation} />} render={<Link href={item.url} onClick={handleNavigation} />}
@@ -58,9 +59,9 @@ export function NavMain({ items }: { items: NavMainItem[] }) {
)} )}
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
); )
})} })}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); )
} }
+17 -17
View File
@@ -1,8 +1,8 @@
"use client"; "use client"
import Link from "next/link"; import Link from "next/link"
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation"
import { type LucideIcon } from "lucide-react"; import { type LucideIcon } from "lucide-react"
import { import {
SidebarGroup, SidebarGroup,
@@ -11,13 +11,13 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar"
export interface NavSecondaryItem { export interface NavSecondaryItem {
title: string; title: string
url: string; url: string
icon: LucideIcon; icon: LucideIcon
badge?: React.ReactNode; badge?: React.ReactNode
} }
export function NavSecondary({ export function NavSecondary({
@@ -25,23 +25,23 @@ export function NavSecondary({
className, className,
...props ...props
}: { }: {
items: NavSecondaryItem[]; items: NavSecondaryItem[]
} & React.ComponentProps<typeof SidebarGroup>) { } & React.ComponentProps<typeof SidebarGroup>) {
const pathname = usePathname(); const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar(); const { isMobile, setOpenMobile } = useSidebar()
const handleNavigation = () => { const handleNavigation = () => {
if (isMobile) { if (isMobile) {
setOpenMobile(false); setOpenMobile(false)
} }
}; }
return ( return (
<SidebarGroup className={className} {...props}> <SidebarGroup className={className} {...props}>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon
return ( return (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton <SidebarMenuButton
@@ -56,10 +56,10 @@ export function NavSecondary({
<span>{item.title}</span> <span>{item.title}</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
); )
})} })}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
); )
} }
+16 -22
View File
@@ -1,4 +1,4 @@
"use client"; "use client"
import { import {
ChevronsUpDownIcon, ChevronsUpDownIcon,
@@ -6,14 +6,10 @@ import {
Settings2Icon, Settings2Icon,
SparklesIcon, SparklesIcon,
UserCogIcon, UserCogIcon,
} from "lucide-react"; } from "lucide-react"
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react"
import { import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -22,29 +18,27 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu"
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar"
export interface NavUserData { export interface NavUserData {
name: string; name: string
email: string; email: string
avatar?: string | null; avatar?: string | null
} }
export function NavUser({ user }: { user: NavUserData }) { export function NavUser({ user }: { user: NavUserData }) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar()
const initials = (user.name || user.email || "U") const initials = (user.name || user.email || "U").slice(0, 2).toUpperCase()
.slice(0, 2)
.toUpperCase();
const handleSignOut = () => { const handleSignOut = () => {
signOut({ callbackUrl: "/login" }); signOut({ callbackUrl: "/login" })
}; }
return ( return (
<SidebarMenu> <SidebarMenu>
@@ -62,7 +56,7 @@ export function NavUser({ user }: { user: NavUserData }) {
{user.avatar ? ( {user.avatar ? (
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={user.avatar} alt={user.name} />
) : null} ) : null}
<AvatarFallback className="rounded-lg font-medium text-xs"> <AvatarFallback className="rounded-lg text-xs font-medium">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -89,7 +83,7 @@ export function NavUser({ user }: { user: NavUserData }) {
{user.avatar ? ( {user.avatar ? (
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={user.avatar} alt={user.name} />
) : null} ) : null}
<AvatarFallback className="rounded-lg font-medium text-xs"> <AvatarFallback className="rounded-lg text-xs font-medium">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -140,5 +134,5 @@ export function NavUser({ user }: { user: NavUserData }) {
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
); )
} }
+11 -12
View File
@@ -1,17 +1,17 @@
"use client"; "use client"
import * as React from "react"; import * as React from "react"
import { useTheme } from "next-themes"; import { useTheme } from "next-themes"
import { MoonIcon, SunIcon } from "lucide-react"; import { MoonIcon, SunIcon } from "lucide-react"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
export function ThemeToggle() { export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme()
const mounted = React.useSyncExternalStore( const mounted = React.useSyncExternalStore(
() => () => {}, () => () => {},
() => true, () => true,
() => false () => false
); )
if (!mounted) { if (!mounted) {
return ( return (
@@ -23,10 +23,10 @@ export function ThemeToggle() {
> >
<span className="size-4" /> <span className="size-4" />
</Button> </Button>
); )
} }
const isDark = resolvedTheme === "dark"; const isDark = resolvedTheme === "dark"
return ( return (
<Button <Button
@@ -35,7 +35,7 @@ export function ThemeToggle() {
onClick={() => setTheme(isDark ? "light" : "dark")} onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label="Toggle color theme" aria-label="Toggle color theme"
title={isDark ? "切换为浅色模式" : "切换为深色模式"} title={isDark ? "切换为浅色模式" : "切换为深色模式"}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground transition-colors hover:text-foreground"
> >
{isDark ? ( {isDark ? (
<SunIcon data-icon="inline-start" /> <SunIcon data-icon="inline-start" />
@@ -43,6 +43,5 @@ export function ThemeToggle() {
<MoonIcon data-icon="inline-start" /> <MoonIcon data-icon="inline-start" />
)} )}
</Button> </Button>
); )
} }
+2 -5
View File
@@ -84,9 +84,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? ( {children ?? <ChevronRightIcon />}
<ChevronRightIcon />
)}
</li> </li>
) )
} }
@@ -106,8 +104,7 @@ function BreadcrumbEllipsis({
)} )}
{...props} {...props}
> >
<MoreHorizontalIcon <MoreHorizontalIcon />
/>
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) )
+8 -8
View File
@@ -1,9 +1,9 @@
"use client"; "use client"
import * as React from "react"; import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
function Checkbox({ function Checkbox({
className, className,
@@ -13,7 +13,7 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input bg-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-primary data-[checked]:border-primary data-[checked]:text-primary-foreground", "peer size-4 shrink-0 rounded-[4px] border border-input bg-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground",
className className
)} )}
{...props} {...props}
@@ -22,7 +22,7 @@ function Checkbox({
<CheckIcon data-icon="inline-start" className="size-3.5 stroke-[2.5]" /> <CheckIcon data-icon="inline-start" className="size-3.5 stroke-[2.5]" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
); )
} }
export { Checkbox }; export { Checkbox }
+4 -1
View File
@@ -110,7 +110,10 @@ function ComboboxContent({
<ComboboxPrimitive.Popup <ComboboxPrimitive.Popup
data-slot="combobox-content" data-slot="combobox-content"
data-chips={!!anchor} data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} {...props}
/> />
</ComboboxPrimitive.Positioner> </ComboboxPrimitive.Positioner>
+1 -2
View File
@@ -70,8 +70,7 @@ function DialogContent({
/> />
} }
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
+10 -6
View File
@@ -40,7 +40,10 @@ function DropdownMenuContent({
> >
<MenuPrimitive.Popup <MenuPrimitive.Popup
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} {...props}
/> />
</MenuPrimitive.Positioner> </MenuPrimitive.Positioner>
@@ -134,7 +137,10 @@ function DropdownMenuSubContent({
return ( return (
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align} align={align}
alignOffset={alignOffset} alignOffset={alignOffset}
side={side} side={side}
@@ -169,8 +175,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator" data-slot="dropdown-menu-checkbox-item-indicator"
> >
<MenuPrimitive.CheckboxItemIndicator> <MenuPrimitive.CheckboxItemIndicator>
<CheckIcon <CheckIcon />
/>
</MenuPrimitive.CheckboxItemIndicator> </MenuPrimitive.CheckboxItemIndicator>
</span> </span>
{children} {children}
@@ -210,8 +215,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator" data-slot="dropdown-menu-radio-item-indicator"
> >
<MenuPrimitive.RadioItemIndicator> <MenuPrimitive.RadioItemIndicator>
<CheckIcon <CheckIcon />
/>
</MenuPrimitive.RadioItemIndicator> </MenuPrimitive.RadioItemIndicator>
</span> </span>
{children} {children}
+6 -5
View File
@@ -82,7 +82,10 @@ function SelectContent({
<SelectPrimitive.Popup <SelectPrimitive.Popup
data-slot="select-content" data-slot="select-content"
data-align-trigger={alignItemWithTrigger} data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
@@ -161,8 +164,7 @@ function SelectScrollUpButton({
)} )}
{...props} {...props}
> >
<ChevronUpIcon <ChevronUpIcon />
/>
</SelectPrimitive.ScrollUpArrow> </SelectPrimitive.ScrollUpArrow>
) )
} }
@@ -180,8 +182,7 @@ function SelectScrollDownButton({
)} )}
{...props} {...props}
> >
<ChevronDownIcon <ChevronDownIcon />
/>
</SelectPrimitive.ScrollDownArrow> </SelectPrimitive.ScrollDownArrow>
) )
} }
+1 -2
View File
@@ -70,8 +70,7 @@ function SheetContent({
/> />
} }
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}
+4 -4
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit"
import * as dotenv from "dotenv"; import * as dotenv from "dotenv"
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" })
export default defineConfig({ export default defineConfig({
schema: "./lib/db/schema.ts", schema: "./lib/db/schema.ts",
@@ -12,4 +12,4 @@ export default defineConfig({
}, },
strict: true, strict: true,
verbose: true, verbose: true,
}); })
+6 -6
View File
@@ -1,15 +1,15 @@
import * as React from "react"; import * as React from "react"
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768
export function useIsMobile() { export function useIsMobile() {
return React.useSyncExternalStore( return React.useSyncExternalStore(
(callback) => { (callback) => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
mql.addEventListener("change", callback); mql.addEventListener("change", callback)
return () => mql.removeEventListener("change", callback); return () => mql.removeEventListener("change", callback)
}, },
() => window.innerWidth < MOBILE_BREAKPOINT, () => window.innerWidth < MOBILE_BREAKPOINT,
() => false () => false
); )
} }
+121 -72
View File
@@ -1,18 +1,18 @@
"use server"; "use server"
import { z } from "zod"; import { z } from "zod"
import { and, desc, eq, isNull } from "drizzle-orm"; import { and, desc, eq, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth"
import { db } from "@/lib/db"; import { db } from "@/lib/db"
import { accounts, channels, type Account } from "@/lib/db/schema"; import { accounts, channels, type Account } from "@/lib/db/schema"
async function requireUser() { async function requireUser() {
const session = await auth(); const session = await auth()
if (!session?.user?.id) { if (!session?.user?.id) {
throw new Error("请先登录"); throw new Error("请先登录")
} }
return session.user.id; return session.user.id
} }
const accountSchema = z.object({ const accountSchema = z.object({
@@ -34,47 +34,50 @@ const accountSchema = z.object({
// 现金账户字段 // 现金账户字段
location: z.string().max(150).optional().nullable(), location: z.string().max(150).optional().nullable(),
}); })
export type AccountInput = z.infer<typeof accountSchema>; export type AccountInput = z.infer<typeof accountSchema>
export type AccountWithChannels = Account & { export type AccountWithChannels = Account & {
channelCount: number; channelCount: number
}; }
export async function getAccountsAction(): Promise<{ export async function getAccountsAction(): Promise<{
success: boolean; success: boolean
data?: AccountWithChannels[]; data?: AccountWithChannels[]
error?: string; error?: string
}> { }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const userAccounts = await db const userAccounts = await db
.select() .select()
.from(accounts) .from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt))) .where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.orderBy(desc(accounts.createdAt)); .orderBy(desc(accounts.createdAt))
const userChannels = await db const userChannels = await db
.select({ id: channels.id, refAccounts: channels.refAccounts }) .select({ id: channels.id, refAccounts: channels.refAccounts })
.from(channels) .from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt))); .where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const result: AccountWithChannels[] = userAccounts.map((acc) => { const result: AccountWithChannels[] = userAccounts.map((acc) => {
const count = userChannels.filter((ch) => const count = userChannels.filter(
Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id) (ch) => Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
).length; ).length
return { return {
...acc, ...acc,
channelCount: count, channelCount: count,
}; }
}); })
return { success: true, data: result }; return { success: true, data: result }
} catch (err) { } catch (err) {
console.error("getAccountsAction error:", err); console.error("getAccountsAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "获取账户失败" }; return {
success: false,
error: err instanceof Error ? err.message : "获取账户失败",
}
} }
} }
@@ -82,13 +85,16 @@ export async function createAccountAction(
data: AccountInput data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> { ): Promise<{ success: boolean; data?: Account; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const parsed = accountSchema.safeParse(data); const parsed = accountSchema.safeParse(data)
if (!parsed.success) { if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" }; return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
} }
const val = parsed.data; const val = parsed.data
const [newAccount] = await db const [newAccount] = await db
.insert(accounts) .insert(accounts)
.values({ .values({
@@ -96,8 +102,13 @@ export async function createAccountAction(
name: val.name.trim(), name: val.name.trim(),
accountType: val.accountType, accountType: val.accountType,
balanceType: val.balanceType, balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null, primaryCurrency: val.primaryCurrency
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null, ? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null, remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null, ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null, issuerName: val.issuerName?.trim() || null,
@@ -107,15 +118,18 @@ export async function createAccountAction(
location: val.location?.trim() || null, location: val.location?.trim() || null,
isActive: true, isActive: true,
}) })
.returning(); .returning()
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/"); revalidatePath("/")
return { success: true, data: newAccount }; return { success: true, data: newAccount }
} catch (err) { } catch (err) {
console.error("createAccountAction error:", err); console.error("createAccountAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "创建账户失败" }; return {
success: false,
error: err instanceof Error ? err.message : "创建账户失败",
}
} }
} }
@@ -124,21 +138,29 @@ export async function updateAccountAction(
data: AccountInput data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> { ): Promise<{ success: boolean; data?: Account; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const parsed = accountSchema.safeParse(data); const parsed = accountSchema.safeParse(data)
if (!parsed.success) { if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" }; return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
} }
const val = parsed.data; const val = parsed.data
const [updated] = await db const [updated] = await db
.update(accounts) .update(accounts)
.set({ .set({
name: val.name.trim(), name: val.name.trim(),
accountType: val.accountType, accountType: val.accountType,
balanceType: val.balanceType, balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null, primaryCurrency: val.primaryCurrency
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null, ? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null, remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null, ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null, issuerName: val.issuerName?.trim() || null,
@@ -148,20 +170,29 @@ export async function updateAccountAction(
location: val.location?.trim() || null, location: val.location?.trim() || null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt))) .where(
.returning(); and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!updated) { if (!updated) {
return { success: false, error: "未找到该账户或无权修改" }; return { success: false, error: "未找到该账户或无权修改" }
} }
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/"); revalidatePath("/")
return { success: true, data: updated }; return { success: true, data: updated }
} catch (err) { } catch (err) {
console.error("updateAccountAction error:", err); console.error("updateAccountAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "更新账户失败" }; return {
success: false,
error: err instanceof Error ? err.message : "更新账户失败",
}
} }
} }
@@ -170,20 +201,29 @@ export async function toggleAccountActiveAction(
isActive: boolean isActive: boolean
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
await db await db
.update(accounts) .update(accounts)
.set({ .set({
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt))); .where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
revalidatePath("/accounts"); revalidatePath("/accounts")
return { success: true }; return { success: true }
} catch (err) { } catch (err) {
console.error("toggleAccountActiveAction error:", err); console.error("toggleAccountActiveAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" }; return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
} }
} }
@@ -191,7 +231,7 @@ export async function deleteAccountAction(
id: string id: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
// 软删除 // 软删除
const [deleted] = await db const [deleted] = await db
.update(accounts) .update(accounts)
@@ -199,19 +239,28 @@ export async function deleteAccountAction(
deletedAt: new Date(), deletedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt))) .where(
.returning(); and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!deleted) { if (!deleted) {
return { success: false, error: "账户不存在或已删除" }; return { success: false, error: "账户不存在或已删除" }
} }
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/"); revalidatePath("/")
return { success: true }; return { success: true }
} catch (err) { } catch (err) {
console.error("deleteAccountAction error:", err); console.error("deleteAccountAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "删除账户失败" }; return {
success: false,
error: err instanceof Error ? err.message : "删除账户失败",
}
} }
} }
+30 -26
View File
@@ -1,10 +1,10 @@
"use server"; "use server"
import { z } from "zod"; import { z } from "zod"
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm"
import { db } from "@/lib/db"; import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"; import { users } from "@/lib/db/schema"
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password"
const registerSchema = z const registerSchema = z
.object({ .object({
@@ -13,16 +13,20 @@ const registerSchema = z
password: z.string().min(8, "密码长度至少需要 8 个字符"), password: z.string().min(8, "密码长度至少需要 8 个字符"),
confirmPassword: z.string().min(1, "请确认密码"), confirmPassword: z.string().min(1, "请确认密码"),
}) })
.refine((data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword, { .refine(
message: "两次输入的密码不一致", (data: { password: string; confirmPassword: string }) =>
path: ["confirmPassword"], data.password === data.confirmPassword,
}); {
message: "两次输入的密码不一致",
path: ["confirmPassword"],
}
)
export type RegisterState = { export type RegisterState = {
success?: boolean; success?: boolean
error?: string; error?: string
fieldErrors?: Record<string, string[]>; fieldErrors?: Record<string, string[]>
}; }
export async function registerAction( export async function registerAction(
prevState: RegisterState | null, prevState: RegisterState | null,
@@ -33,18 +37,18 @@ export async function registerAction(
email: formData.get("email"), email: formData.get("email"),
password: formData.get("password"), password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"), confirmPassword: formData.get("confirmPassword"),
}; }
const parsed = registerSchema.safeParse(rawData); const parsed = registerSchema.safeParse(rawData)
if (!parsed.success) { if (!parsed.success) {
return { return {
success: false, success: false,
fieldErrors: parsed.error.flatten().fieldErrors, fieldErrors: parsed.error.flatten().fieldErrors,
}; }
} }
const { name, email, password } = parsed.data; const { name, email, password } = parsed.data
const normalizedEmail = email.toLowerCase().trim(); const normalizedEmail = email.toLowerCase().trim()
try { try {
// 检查邮箱是否已被注册 // 检查邮箱是否已被注册
@@ -52,17 +56,17 @@ export async function registerAction(
.select() .select()
.from(users) .from(users)
.where(eq(users.email, normalizedEmail)) .where(eq(users.email, normalizedEmail))
.limit(1); .limit(1)
if (existing) { if (existing) {
return { return {
success: false, success: false,
error: "该邮箱已被注册,请直接登录", error: "该邮箱已被注册,请直接登录",
}; }
} }
// 使用 Argon2 哈希密码 // 使用 Argon2 哈希密码
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password)
// 插入新用户 // 插入新用户
await db.insert(users).values({ await db.insert(users).values({
@@ -70,16 +74,16 @@ export async function registerAction(
email: normalizedEmail, email: normalizedEmail,
passwordHash, passwordHash,
isActive: true, isActive: true,
}); })
return { return {
success: true, success: true,
}; }
} catch (err) { } catch (err) {
console.error("Registration error:", err); console.error("Registration error:", err)
return { return {
success: false, success: false,
error: "注册失败,请稍后重试", error: "注册失败,请稍后重试",
}; }
} }
} }
+436
View File
@@ -0,0 +1,436 @@
"use server"
import { z } from "zod"
import { and, desc, eq, inArray, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import {
channels,
transactions,
transactionsDirty,
type TransactionDirty,
} from "@/lib/db/schema"
async function requireUser() {
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录")
}
return session.user.id
}
const dirtyTransactionSchema = z.object({
txnDate: z.string().min(1, "请选择交易日期"),
txnAmt: z.string().min(1, "请输入交易金额"),
txnCcy: z.string().min(1, "请输入交易币种").max(10),
postingAmt: z.string().optional().nullable(),
postingCcy: z.string().max(10).optional().nullable(),
commAmt: z.string().optional().nullable(),
commCcy: z.string().max(10).optional().nullable(),
surchargeAmt: z.string().optional().nullable(),
surchargeCcy: z.string().max(10).optional().nullable(),
discAmt: z.string().optional().nullable(),
discCcy: z.string().max(10).optional().nullable(),
dcFlag: z.enum(["DEBIT", "CREDIT"] as const).default("DEBIT"),
refChannels: z.array(z.string().uuid()).default([]),
txnScene: z.string().min(1).default("PAYMENT"),
merchantName: z.string().max(255).optional().nullable(),
description: z.string().max(500).optional().nullable(),
memo: z.string().max(500).optional().nullable(),
})
export type DirtyTransactionInput = z.infer<typeof dirtyTransactionSchema>
export async function getDirtyTransactionsAction(): Promise<{
success: boolean
data?: TransactionDirty[]
error?: string
}> {
try {
const userId = await requireUser()
const rows = await db
.select()
.from(transactionsDirty)
.where(
and(
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt)
)
)
.orderBy(
desc(transactionsDirty.txnDate),
desc(transactionsDirty.createdAt)
)
return { success: true, data: rows }
} catch (err) {
console.error("getDirtyTransactionsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取待清洗交易失败",
}
}
}
export async function createDirtyTransactionAction(
data: DirtyTransactionInput
): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> {
try {
const userId = await requireUser()
const parsed = dirtyTransactionSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data
const [row] = await db
.insert(transactionsDirty)
.values({
userId,
txnDate: new Date(val.txnDate),
txnAmt: val.txnAmt.trim(),
txnCcy: val.txnCcy.trim().toUpperCase(),
postingAmt: val.postingAmt?.trim() || null,
postingCcy: val.postingCcy?.trim()
? val.postingCcy.trim().toUpperCase()
: null,
commAmt: val.commAmt?.trim() || null,
commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null,
surchargeAmt: val.surchargeAmt?.trim() || null,
surchargeCcy: val.surchargeCcy?.trim()
? val.surchargeCcy.trim().toUpperCase()
: null,
discAmt: val.discAmt?.trim() || null,
discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null,
dcFlag: val.dcFlag,
refChannels: val.refChannels,
txnScene: val.txnScene,
merchantName: val.merchantName?.trim() || null,
description: val.description?.trim() || null,
memo: val.memo?.trim() || null,
})
.returning()
revalidatePath("/bookkeeping")
return { success: true, data: row }
} catch (err) {
console.error("createDirtyTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "暂存交易失败",
}
}
}
export async function updateDirtyTransactionAction(
id: string,
data: DirtyTransactionInput
): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> {
try {
const userId = await requireUser()
const parsed = dirtyTransactionSchema.safeParse(data)
if (!parsed.success) {
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data
const [row] = await db
.update(transactionsDirty)
.set({
txnDate: new Date(val.txnDate),
txnAmt: val.txnAmt.trim(),
txnCcy: val.txnCcy.trim().toUpperCase(),
postingAmt: val.postingAmt?.trim() || null,
postingCcy: val.postingCcy?.trim()
? val.postingCcy.trim().toUpperCase()
: null,
commAmt: val.commAmt?.trim() || null,
commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null,
surchargeAmt: val.surchargeAmt?.trim() || null,
surchargeCcy: val.surchargeCcy?.trim()
? val.surchargeCcy.trim().toUpperCase()
: null,
discAmt: val.discAmt?.trim() || null,
discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null,
dcFlag: val.dcFlag,
refChannels: val.refChannels,
txnScene: val.txnScene,
merchantName: val.merchantName?.trim() || null,
description: val.description?.trim() || null,
memo: val.memo?.trim() || null,
updatedAt: new Date(),
})
.where(
and(
eq(transactionsDirty.id, id),
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt)
)
)
.returning()
if (!row) {
return { success: false, error: "未找到该待清洗交易" }
}
revalidatePath("/bookkeeping")
return { success: true, data: row }
} catch (err) {
console.error("updateDirtyTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新交易失败",
}
}
}
export async function deleteDirtyTransactionAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser()
const [deleted] = await db
.delete(transactionsDirty)
.where(
and(eq(transactionsDirty.id, id), eq(transactionsDirty.userId, userId))
)
.returning()
if (!deleted) {
return { success: false, error: "交易不存在或已被删除" }
}
revalidatePath("/bookkeeping")
return { success: true }
} catch (err) {
console.error("deleteDirtyTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除失败",
}
}
}
/**
* 校验单笔待清洗交易的合规性与会计平衡
*/
function validateDirtyTransaction(
item: TransactionDirty,
userChannelIds: Set<string>
): string | null {
// 1. 金额必须为有效正数
const amt = parseFloat(item.txnAmt)
if (isNaN(amt) || amt <= 0) {
return `交易金额「${item.txnAmt}」无效,必须为大于0的数值`
}
// 2. 币种不能为空
if (!item.txnCcy || item.txnCcy.trim() === "") {
return "交易币种不能为空"
}
// 3. 必须绑定属于当前用户的支付渠道
if (!Array.isArray(item.refChannels) || item.refChannels.length === 0) {
return "未指定支付渠道,无法过账"
}
for (const chId of item.refChannels) {
if (!userChannelIds.has(chId)) {
return "绑定的支付渠道无效或已被删除"
}
}
// 4. 入账金额与币种必须同时存在或同时为空
const hasPostingAmt =
item.postingAmt !== null && item.postingAmt.trim() !== ""
const hasPostingCcy =
item.postingCcy !== null && item.postingCcy.trim() !== ""
if (hasPostingAmt !== hasPostingCcy) {
return "入账金额与入账币种必须同时提供"
}
// 5. 若为同币种入账,执行会计恒等式平衡检验
if (hasPostingAmt && item.postingCcy === item.txnCcy) {
const pAmt = parseFloat(item.postingAmt!)
const comm =
item.commAmt && item.commCcy === item.txnCcy
? parseFloat(item.commAmt)
: 0
const surcharge =
item.surchargeAmt && item.surchargeCcy === item.txnCcy
? parseFloat(item.surchargeAmt)
: 0
const disc =
item.discAmt && item.discCcy === item.txnCcy
? parseFloat(item.discAmt)
: 0
let expected = amt + surcharge - disc
if (item.dcFlag === "DEBIT") {
expected += comm
} else {
expected -= comm
}
// 允许 0.01 的浮点微差
if (Math.abs(pAmt - expected) > 0.015) {
return `会计恒等式不平衡:实际入账 ${pAmt} 与计算期望值 ${expected.toFixed(2)} 不符`
}
}
return null
}
/**
* 全量原子阻断清洗动作 (All-or-Nothing)
* 只要有哪怕一笔交易存在问题,就全量阻断合并,返回详尽错误清单,绝不污染正式账本。
*/
export async function cleanseTransactionsAction(
specificIds?: string[]
): Promise<{
success: boolean
cleansedCount?: number
errors?: { id: string; name: string; error: string }[]
error?: string
}> {
try {
const userId = await requireUser()
// 1. 获取目标待清洗交易
const queryConditions = [
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt),
]
if (specificIds && specificIds.length > 0) {
queryConditions.push(inArray(transactionsDirty.id, specificIds))
}
const dirtyList = await db
.select()
.from(transactionsDirty)
.where(and(...queryConditions))
if (dirtyList.length === 0) {
return { success: false, error: "当前暂无待清洗的交易记录" }
}
// 2. 获取用户的所有有效渠道 ID 用于归属校验
const userChannels = await db
.select({ id: channels.id })
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const userChannelIdSet = new Set(userChannels.map((c) => c.id))
// 3. 执行严格的前置全量校验网关 (Pre-flight Validation Gate)
const validationErrors: { id: string; name: string; error: string }[] = []
for (const item of dirtyList) {
const err = validateDirtyTransaction(item, userChannelIdSet)
if (err) {
const identifier =
item.merchantName ||
item.description ||
`交易(${item.txnAmt} ${item.txnCcy})`
validationErrors.push({
id: item.id,
name: identifier,
error: err,
})
}
}
// 4. 零容忍阻断:只要有一项不合格,立即全量中止,不发生任何写入与删除!
if (validationErrors.length > 0) {
return {
success: false,
error: `批次中存在 ${validationErrors.length} 笔未平账或要素不全的交易,全量阻断合并!请修正后再试。`,
errors: validationErrors,
}
}
// 5. 100% 校验通过,开启原子事务迁移至正式表
const idsToCleanse = dirtyList.map((d) => d.id)
await db.transaction(async (tx) => {
// 5.1 批量插入正式 transactions 表
await tx.insert(transactions).values(
dirtyList.map((item) => {
// 若未填入账信息且为单币种,自动补足
const pAmt = item.postingAmt || item.txnAmt
const pCcy = item.postingCcy || item.txnCcy
return {
id: item.id,
userId: item.userId,
version: item.version,
refTransactions: item.refTransactions,
txnDate: item.txnDate,
clearingDate: item.clearingDate,
postingDate: item.postingDate,
txnAmt: item.txnAmt,
txnCcy: item.txnCcy,
postingAmt: pAmt,
postingCcy: pCcy,
commAmt: item.commAmt,
commCcy: item.commCcy,
surchargeAmt: item.surchargeAmt,
surchargeCcy: item.surchargeCcy,
discAmt: item.discAmt,
discCcy: item.discCcy,
fxRates: item.fxRates,
dcFlag: item.dcFlag,
refChannels: item.refChannels,
cp: item.cp,
acqInst: item.acqInst,
clearingNetwork: item.clearingNetwork,
txnSts: item.txnSts,
description: item.description,
memo: item.memo,
ext: item.ext,
rawDescription: item.rawDescription,
rawData: item.rawData,
txnScene: item.txnScene,
merchantName: item.merchantName,
orderId: item.orderId,
geo: item.geo,
}
})
)
// 5.2 从 transactions_dirty 表彻底清除已清洗记录
await tx
.delete(transactionsDirty)
.where(
and(
eq(transactionsDirty.userId, userId),
inArray(transactionsDirty.id, idsToCleanse)
)
)
})
// 6. 成功,全量刷新路由缓存
revalidatePath("/bookkeeping")
revalidatePath("/transactions")
revalidatePath("/accounts")
revalidatePath("/")
return {
success: true,
cleansedCount: idsToCleanse.length,
}
} catch (err) {
console.error("cleanseTransactionsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "清洗合并事务失败",
}
}
}
+135 -87
View File
@@ -1,34 +1,40 @@
"use server"; "use server"
import { z } from "zod"; import { z } from "zod"
import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { and, desc, eq, inArray, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth"
import { db } from "@/lib/db"; import { db } from "@/lib/db"
import { import { accounts, channels, type Channel } from "@/lib/db/schema"
accounts,
channels,
type Channel,
} from "@/lib/db/schema";
async function requireUser() { async function requireUser() {
const session = await auth(); const session = await auth()
if (!session?.user?.id) { if (!session?.user?.id) {
throw new Error("请先登录"); throw new Error("请先登录")
} }
return session.user.id; return session.user.id
} }
const channelSchema = z.object({ const channelSchema = z.object({
channelType: z.enum(["PAYMENT_CARD", "E_WALLET", "CASH", "TRANSFER"] as const), channelType: z.enum([
refAccounts: z.array(z.string().uuid("无效的账户标识")).min(1, "请至少关联一个资金账户"), "PAYMENT_CARD",
"E_WALLET",
"CASH",
"TRANSFER",
] as const),
refAccounts: z
.array(z.string().uuid("无效的账户标识"))
.min(1, "请至少关联一个资金账户"),
desc: z.string().max(500).optional().nullable(), desc: z.string().max(500).optional().nullable(),
ext: z.string().optional().nullable(), ext: z.string().optional().nullable(),
// 支付卡特定字段 // 支付卡特定字段
region: z.string().max(10).optional().nullable(), region: z.string().max(10).optional().nullable(),
issuerName: z.string().max(100).optional().nullable(), issuerName: z.string().max(100).optional().nullable(),
cardType: z.enum(["CREDIT", "DEBIT"] as const).optional().nullable(), cardType: z
.enum(["CREDIT", "DEBIT"] as const)
.optional()
.nullable(),
cardNumberFull: z.string().max(100).optional().nullable(), cardNumberFull: z.string().max(100).optional().nullable(),
cardNumberSuffix: z.string().max(10).optional().nullable(), cardNumberSuffix: z.string().max(10).optional().nullable(),
cardBrand: z.string().max(32).optional().nullable(), cardBrand: z.string().max(32).optional().nullable(),
@@ -37,56 +43,62 @@ const channelSchema = z.object({
platform: z.string().max(64).optional().nullable(), platform: z.string().max(64).optional().nullable(),
platformAccountId: z.string().max(100).optional().nullable(), platformAccountId: z.string().max(100).optional().nullable(),
subChannel: z.string().max(64).optional().nullable(), subChannel: z.string().max(64).optional().nullable(),
subChannelType: z.enum(["CREDIT", "DEBIT"] as const).optional().nullable(), subChannelType: z
}); .enum(["CREDIT", "DEBIT"] as const)
.optional()
.nullable(),
})
export type ChannelInput = z.infer<typeof channelSchema>; export type ChannelInput = z.infer<typeof channelSchema>
export type ChannelWithAccountNames = Channel & { export type ChannelWithAccountNames = Channel & {
linkedAccounts: { id: string; name: string }[]; linkedAccounts: { id: string; name: string }[]
}; }
export async function getChannelsAction(): Promise<{ export async function getChannelsAction(): Promise<{
success: boolean; success: boolean
data?: ChannelWithAccountNames[]; data?: ChannelWithAccountNames[]
error?: string; error?: string
}> { }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const userChannels = await db const userChannels = await db
.select() .select()
.from(channels) .from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt))) .where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
.orderBy(desc(channels.createdAt)); .orderBy(desc(channels.createdAt))
const userAccounts = await db const userAccounts = await db
.select({ id: accounts.id, name: accounts.name }) .select({ id: accounts.id, name: accounts.name })
.from(accounts) .from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt))); .where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name])); const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]))
const result: ChannelWithAccountNames[] = userChannels.map((ch) => { const result: ChannelWithAccountNames[] = userChannels.map((ch) => {
const linked: { id: string; name: string }[] = []; const linked: { id: string; name: string }[] = []
if (Array.isArray(ch.refAccounts)) { if (Array.isArray(ch.refAccounts)) {
for (const accId of ch.refAccounts) { for (const accId of ch.refAccounts) {
const name = accountMap.get(accId); const name = accountMap.get(accId)
if (name) { if (name) {
linked.push({ id: accId, name }); linked.push({ id: accId, name })
} }
} }
} }
return { return {
...ch, ...ch,
linkedAccounts: linked, linkedAccounts: linked,
}; }
}); })
return { success: true, data: result }; return { success: true, data: result }
} catch (err) { } catch (err) {
console.error("getChannelsAction error:", err); console.error("getChannelsAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "获取渠道失败" }; return {
success: false,
error: err instanceof Error ? err.message : "获取渠道失败",
}
} }
} }
@@ -94,13 +106,16 @@ export async function createChannelAction(
data: ChannelInput data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> { ): Promise<{ success: boolean; data?: Channel; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const parsed = channelSchema.safeParse(data); const parsed = channelSchema.safeParse(data)
if (!parsed.success) { if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" }; return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
} }
const val = parsed.data; const val = parsed.data
// 校验关联的所有账户必须属于当前登录用户 // 校验关联的所有账户必须属于当前登录用户
const userOwnedAccounts = await db const userOwnedAccounts = await db
@@ -112,16 +127,16 @@ export async function createChannelAction(
inArray(accounts.id, val.refAccounts), inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt) isNull(accounts.deletedAt)
) )
); )
if (userOwnedAccounts.length !== val.refAccounts.length) { if (userOwnedAccounts.length !== val.refAccounts.length) {
return { success: false, error: "关联的部分账户不存在或已被删除" }; return { success: false, error: "关联的部分账户不存在或已被删除" }
} }
// 自动提取或补全卡号后4位 // 自动提取或补全卡号后4位
let suffix = val.cardNumberSuffix?.trim() || null; let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) { if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
suffix = val.cardNumberFull.slice(-4); suffix = val.cardNumberFull.slice(-4)
} }
const [newChannel] = await db const [newChannel] = await db
@@ -144,15 +159,18 @@ export async function createChannelAction(
subChannel: val.subChannel?.trim() || null, subChannel: val.subChannel?.trim() || null,
subChannelType: val.subChannelType || null, subChannelType: val.subChannelType || null,
}) })
.returning(); .returning()
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/"); revalidatePath("/")
return { success: true, data: newChannel }; return { success: true, data: newChannel }
} catch (err) { } catch (err) {
console.error("createChannelAction error:", err); console.error("createChannelAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "创建渠道失败" }; return {
success: false,
error: err instanceof Error ? err.message : "创建渠道失败",
}
} }
} }
@@ -161,13 +179,16 @@ export async function updateChannelAction(
data: ChannelInput data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> { ): Promise<{ success: boolean; data?: Channel; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const parsed = channelSchema.safeParse(data); const parsed = channelSchema.safeParse(data)
if (!parsed.success) { if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" }; return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
} }
const val = parsed.data; const val = parsed.data
const userOwnedAccounts = await db const userOwnedAccounts = await db
.select({ id: accounts.id }) .select({ id: accounts.id })
@@ -178,15 +199,15 @@ export async function updateChannelAction(
inArray(accounts.id, val.refAccounts), inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt) isNull(accounts.deletedAt)
) )
); )
if (userOwnedAccounts.length !== val.refAccounts.length) { if (userOwnedAccounts.length !== val.refAccounts.length) {
return { success: false, error: "关联的部分账户不存在或已被删除" }; return { success: false, error: "关联的部分账户不存在或已被删除" }
} }
let suffix = val.cardNumberSuffix?.trim() || null; let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) { if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
suffix = val.cardNumberFull.slice(-4); suffix = val.cardNumberFull.slice(-4)
} }
const [updated] = await db const [updated] = await db
@@ -208,20 +229,29 @@ export async function updateChannelAction(
subChannelType: val.subChannelType || null, subChannelType: val.subChannelType || null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt))) .where(
.returning(); and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
.returning()
if (!updated) { if (!updated) {
return { success: false, error: "渠道不存在或无权修改" }; return { success: false, error: "渠道不存在或无权修改" }
} }
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/"); revalidatePath("/")
return { success: true, data: updated }; return { success: true, data: updated }
} catch (err) { } catch (err) {
console.error("updateChannelAction error:", err); console.error("updateChannelAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "更新渠道失败" }; return {
success: false,
error: err instanceof Error ? err.message : "更新渠道失败",
}
} }
} }
@@ -230,22 +260,31 @@ export async function toggleChannelActiveAction(
isActive: boolean isActive: boolean
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
await db await db
.update(channels) .update(channels)
.set({ .set({
isActive, isActive,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt))); .where(
and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/"); revalidatePath("/")
return { success: true }; return { success: true }
} catch (err) { } catch (err) {
console.error("toggleChannelActiveAction error:", err); console.error("toggleChannelActiveAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" }; return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
} }
} }
@@ -253,26 +292,35 @@ export async function deleteChannelAction(
id: string id: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const userId = await requireUser(); const userId = await requireUser()
const [deleted] = await db const [deleted] = await db
.update(channels) .update(channels)
.set({ .set({
deletedAt: new Date(), deletedAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt))) .where(
.returning(); and(
eq(channels.id, id),
eq(channels.userId, userId),
isNull(channels.deletedAt)
)
)
.returning()
if (!deleted) { if (!deleted) {
return { success: false, error: "渠道不存在或已删除" }; return { success: false, error: "渠道不存在或已删除" }
} }
revalidatePath("/channels"); revalidatePath("/channels")
revalidatePath("/accounts"); revalidatePath("/accounts")
revalidatePath("/"); revalidatePath("/")
return { success: true }; return { success: true }
} catch (err) { } catch (err) {
console.error("deleteChannelAction error:", err); console.error("deleteChannelAction error:", err)
return { success: false, error: err instanceof Error ? err.message : "删除渠道失败" }; return {
success: false,
error: err instanceof Error ? err.message : "删除渠道失败",
}
} }
} }
+119
View File
@@ -0,0 +1,119 @@
"use server"
import { and, desc, eq, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { channels, transactions, type Transaction } from "@/lib/db/schema"
async function requireUser() {
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录")
}
return session.user.id
}
export type TransactionWithChannelDetails = Transaction & {
channelDetails: {
id: string
cardBrand: string | null
channelType: string
displayName: string
region: string | null
cardNumberSuffix: string | null
}[]
}
export async function getOfficialTransactionsAction(): Promise<{
success: boolean
data?: TransactionWithChannelDetails[]
error?: string
}> {
try {
const userId = await requireUser()
const officialTxns = await db
.select()
.from(transactions)
.where(
and(eq(transactions.userId, userId), isNull(transactions.deletedAt))
)
.orderBy(desc(transactions.txnDate), desc(transactions.createdAt))
const userChannels = await db
.select()
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const channelMap = new Map(userChannels.map((c) => [c.id, c]))
const result: TransactionWithChannelDetails[] = officialTxns.map((t) => {
const chDetails: TransactionWithChannelDetails["channelDetails"] = []
if (Array.isArray(t.refChannels)) {
for (const chId of t.refChannels) {
const ch = channelMap.get(chId)
if (ch) {
const name = ch.desc || ch.issuerName || ch.platform || "渠道"
chDetails.push({
id: ch.id,
cardBrand: ch.cardBrand,
channelType: ch.channelType,
displayName: name,
region: ch.region,
cardNumberSuffix: ch.cardNumberSuffix,
})
}
}
}
return {
...t,
channelDetails: chDetails,
}
})
return { success: true, data: result }
} catch (err) {
console.error("getOfficialTransactionsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取交易记录失败",
}
}
}
export async function deleteOfficialTransactionAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser()
const [deleted] = await db
.update(transactions)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(transactions.id, id),
eq(transactions.userId, userId),
isNull(transactions.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "交易不存在或已删除" }
}
revalidatePath("/transactions")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteOfficialTransactionAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除交易失败",
}
}
}
+13 -13
View File
@@ -1,4 +1,4 @@
import type { NextAuthConfig } from "next-auth"; import type { NextAuthConfig } from "next-auth"
export const authConfig = { export const authConfig = {
pages: { pages: {
@@ -7,37 +7,37 @@ export const authConfig = {
}, },
callbacks: { callbacks: {
authorized({ auth, request: { nextUrl } }) { authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user; const isLoggedIn = !!auth?.user
const pathname = nextUrl.pathname; const pathname = nextUrl.pathname
const publicPaths = ["/login", "/register", "/api/auth"]; const publicPaths = ["/login", "/register", "/api/auth"]
const isPublic = publicPaths.some( const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith("/api/auth/") (path) => pathname === path || pathname.startsWith("/api/auth/")
); )
// 已登录用户在登录/注册页时重定向到首页 // 已登录用户在登录/注册页时重定向到首页
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) { if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return Response.redirect(new URL("/", nextUrl)); return Response.redirect(new URL("/", nextUrl))
} }
// 访问受保护页面必须已登录 // 访问受保护页面必须已登录
if (!isLoggedIn && !isPublic) { if (!isLoggedIn && !isPublic) {
return false; return false
} }
return true; return true
}, },
jwt({ token, user }) { jwt({ token, user }) {
if (user?.id) { if (user?.id) {
token.id = user.id; token.id = user.id
} }
return token; return token
}, },
session({ session, token }) { session({ session, token }) {
if (session.user && token.id) { if (session.user && token.id) {
session.user.id = token.id as string; session.user.id = token.id as string
} }
return session; return session
}, },
}, },
providers: [], providers: [],
} satisfies NextAuthConfig; } satisfies NextAuthConfig
+34 -34
View File
@@ -1,11 +1,11 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials"
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm"
import { db } from "@/lib/db"; import { db } from "@/lib/db"
import { users, userAccounts } from "@/lib/db/schema"; import { users, userAccounts } from "@/lib/db/schema"
import { verifyPassword } from "./password"; import { verifyPassword } from "./password"
import { authConfig } from "./config"; import { authConfig } from "./config"
import type { Provider } from "next-auth/providers"; import type { Provider } from "next-auth/providers"
// 动态构建 Providers 列表 // 动态构建 Providers 列表
const providers: Provider[] = [ const providers: Provider[] = [
@@ -17,25 +17,25 @@ const providers: Provider[] = [
}, },
async authorize(credentials) { async authorize(credentials) {
if (!credentials?.email || !credentials?.password) { if (!credentials?.email || !credentials?.password) {
return null; return null
} }
const email = String(credentials.email).toLowerCase().trim(); const email = String(credentials.email).toLowerCase().trim()
const password = String(credentials.password); const password = String(credentials.password)
const [user] = await db const [user] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.email, email)) .where(eq(users.email, email))
.limit(1); .limit(1)
if (!user || !user.passwordHash || !user.isActive) { if (!user || !user.passwordHash || !user.isActive) {
return null; return null
} }
const isValid = await verifyPassword(password, user.passwordHash); const isValid = await verifyPassword(password, user.passwordHash)
if (!isValid) { if (!isValid) {
return null; return null
} }
return { return {
@@ -43,16 +43,16 @@ const providers: Provider[] = [
name: user.name, name: user.name,
email: user.email, email: user.email,
image: user.avatar, image: user.avatar,
}; }
}, },
}), }),
]; ]
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商 // 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
const isOidcEnabled = const isOidcEnabled =
process.env.AUTH_OIDC_ENABLED === "true" && process.env.AUTH_OIDC_ENABLED === "true" &&
Boolean(process.env.AUTH_OIDC_ISSUER) && Boolean(process.env.AUTH_OIDC_ISSUER) &&
Boolean(process.env.AUTH_OIDC_CLIENT_ID); Boolean(process.env.AUTH_OIDC_CLIENT_ID)
if (isOidcEnabled) { if (isOidcEnabled) {
providers.push({ providers.push({
@@ -72,7 +72,7 @@ if (isOidcEnabled) {
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email", scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
}, },
}, },
}); })
} }
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
@@ -86,13 +86,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig.callbacks, ...authConfig.callbacks,
async signIn({ user, account }) { async signIn({ user, account }) {
if (!account || account.type === "credentials") { if (!account || account.type === "credentials") {
return true; return true
} }
// 处理 OIDC / OAuth 登录与本地用户的关联或新建 // 处理 OIDC / OAuth 登录与本地用户的关联或新建
const email = user.email?.toLowerCase().trim(); const email = user.email?.toLowerCase().trim()
if (!email) { if (!email) {
return false; return false
} }
// 1. 查询用户是否已存在 // 1. 查询用户是否已存在
@@ -100,7 +100,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
.select() .select()
.from(users) .from(users)
.where(eq(users.email, email)) .where(eq(users.email, email))
.limit(1); .limit(1)
if (!existingUser) { if (!existingUser) {
// 创建新用户 // 创建新用户
@@ -112,20 +112,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
avatar: user.image || null, avatar: user.image || null,
isActive: true, isActive: true,
}) })
.returning(); .returning()
existingUser = newUser; existingUser = newUser
} }
user.id = existingUser.id; user.id = existingUser.id
// 2. 查询是否已记录该 provider 的账户绑定 // 2. 查询是否已记录该 provider 的账户绑定
const [existingAccount] = await db const [existingAccount] = await db
.select() .select()
.from(userAccounts) .from(userAccounts)
.where( .where(eq(userAccounts.providerAccountId, account.providerAccountId))
eq(userAccounts.providerAccountId, account.providerAccountId) .limit(1)
)
.limit(1);
if (!existingAccount) { if (!existingAccount) {
await db.insert(userAccounts).values({ await db.insert(userAccounts).values({
@@ -134,14 +132,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providerAccountId: account.providerAccountId, providerAccountId: account.providerAccountId,
refreshToken: account.refresh_token, refreshToken: account.refresh_token,
accessToken: account.access_token, accessToken: account.access_token,
expiresAt: account.expires_at ? new Date(account.expires_at * 1000) : null, expiresAt: account.expires_at
? new Date(account.expires_at * 1000)
: null,
tokenType: account.token_type, tokenType: account.token_type,
scope: account.scope, scope: account.scope,
idToken: account.id_token, idToken: account.id_token,
}); })
} }
return true; return true
}, },
}, },
}); })
+9 -6
View File
@@ -1,4 +1,4 @@
import { hash, verify } from "@node-rs/argon2"; import { hash, verify } from "@node-rs/argon2"
// 遵循 OWASP 密码哈希安全推荐配置 // 遵循 OWASP 密码哈希安全推荐配置
const ARGON2_OPTIONS = { const ARGON2_OPTIONS = {
@@ -6,16 +6,19 @@ const ARGON2_OPTIONS = {
timeCost: 2, timeCost: 2,
outputLen: 32, outputLen: 32,
parallelism: 1, parallelism: 1,
}; }
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return await hash(password, ARGON2_OPTIONS); return await hash(password, ARGON2_OPTIONS)
} }
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> { export async function verifyPassword(
password: string,
passwordHash: string
): Promise<boolean> {
try { try {
return await verify(passwordHash, password); return await verify(passwordHash, password)
} catch { } catch {
return false; return false
} }
} }
+11 -9
View File
@@ -1,15 +1,17 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"; import postgres from "postgres"
import * as schema from "./schema"; import * as schema from "./schema"
const connectionString = process.env.DATABASE_URL || "postgresql://postgres:Aa110011@localhost:5432/fluxent"; const connectionString =
process.env.DATABASE_URL ||
"postgresql://postgres:Aa110011@localhost:5432/fluxent"
// 在 Next.js 开发环境下避免热重载创建重复连接池 // 在 Next.js 开发环境下避免热重载创建重复连接池
const globalForDb = globalThis as unknown as { const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined; conn: postgres.Sql | undefined
}; }
const client = globalForDb.conn ?? postgres(connectionString, { max: 10 }); const client = globalForDb.conn ?? postgres(connectionString, { max: 10 })
if (process.env.NODE_ENV !== "production") globalForDb.conn = client; if (process.env.NODE_ENV !== "production") globalForDb.conn = client
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema })
+4 -1
View File
@@ -41,7 +41,10 @@ async function main() {
` `
console.log(`Normalized ${updated} channel card brands.`) console.log(`Normalized ${updated} channel card brands.`)
console.log("Stored card brands:", brands.map((row) => row.card_brand)) console.log(
"Stored card brands:",
brands.map((row) => row.card_brand)
)
} }
main() main()
+15 -15
View File
@@ -1,22 +1,22 @@
import postgres from "postgres"; import postgres from "postgres"
import * as dotenv from "dotenv"; import * as dotenv from "dotenv"
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" })
const sql = postgres(process.env.DATABASE_URL!); const sql = postgres(process.env.DATABASE_URL!)
async function resetAndInit() { async function resetAndInit() {
console.log("Dropping old tables..."); console.log("Dropping old tables...")
await sql`DROP TABLE IF EXISTS transactions CASCADE`; await sql`DROP TABLE IF EXISTS transactions CASCADE`
await sql`DROP TABLE IF EXISTS channels CASCADE`; await sql`DROP TABLE IF EXISTS channels CASCADE`
await sql`DROP TABLE IF EXISTS accounts CASCADE`; await sql`DROP TABLE IF EXISTS accounts CASCADE`
await sql`DROP TABLE IF EXISTS user_accounts CASCADE`; await sql`DROP TABLE IF EXISTS user_accounts CASCADE`
await sql`DROP TABLE IF EXISTS users CASCADE`; await sql`DROP TABLE IF EXISTS users CASCADE`
console.log("Old tables dropped."); console.log("Old tables dropped.")
await sql.end(); await sql.end()
} }
resetAndInit().catch((err) => { resetAndInit().catch((err) => {
console.error(err); console.error(err)
process.exit(1); process.exit(1)
}); })
+111 -39
View File
@@ -8,8 +8,8 @@ import {
jsonb, jsonb,
uniqueIndex, uniqueIndex,
index, index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 基础时间戳辅助 // 基础时间戳辅助
@@ -22,7 +22,7 @@ export const timestamps = {
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)
.notNull(), .notNull(),
deletedAt: timestamp("deleted_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }),
}; }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1. 用户与认证表 // 1. 用户与认证表
@@ -42,13 +42,11 @@ export const users = pgTable(
isActive: boolean("is_active").default(true).notNull(), isActive: boolean("is_active").default(true).notNull(),
...timestamps, ...timestamps,
}, },
(t) => [ (t) => [uniqueIndex("users_email_unique").on(t.email)]
uniqueIndex("users_email_unique").on(t.email), )
]
);
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert; export type NewUser = typeof users.$inferInsert
/** /**
* 用户认证授权表 (支持 OIDC / OAuth 账号关联) * 用户认证授权表 (支持 OIDC / OAuth 账号关联)
@@ -61,7 +59,9 @@ export const userAccounts = pgTable(
.references(() => users.id, { onDelete: "cascade" }) .references(() => users.id, { onDelete: "cascade" })
.notNull(), .notNull(),
provider: varchar("provider", { length: 64 }).notNull(), // 如 'oidc' provider: varchar("provider", { length: 64 }).notNull(), // 如 'oidc'
providerAccountId: varchar("provider_account_id", { length: 255 }).notNull(), providerAccountId: varchar("provider_account_id", {
length: 255,
}).notNull(),
refreshToken: text("refresh_token"), refreshToken: text("refresh_token"),
accessToken: text("access_token"), accessToken: text("access_token"),
expiresAt: timestamp("expires_at", { withTimezone: true }), expiresAt: timestamp("expires_at", { withTimezone: true }),
@@ -77,14 +77,14 @@ export const userAccounts = pgTable(
), ),
index("user_accounts_user_id_idx").on(t.userId), index("user_accounts_user_id_idx").on(t.userId),
] ]
); )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. 资金账户表 (Accounts) // 2. 资金账户表 (Accounts)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type AccountType = "BANK" | "E_WALLET" | "CASH"; export type AccountType = "BANK" | "E_WALLET" | "CASH"
export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY"; export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY"
export const accounts = pgTable( export const accounts = pgTable(
"accounts", "accounts",
@@ -94,7 +94,9 @@ export const accounts = pgTable(
.references(() => users.id, { onDelete: "cascade" }) .references(() => users.id, { onDelete: "cascade" })
.notNull(), .notNull(),
name: varchar("name", { length: 150 }).notNull(), name: varchar("name", { length: 150 }).notNull(),
accountType: varchar("account_type", { length: 32 }).$type<AccountType>().notNull(), accountType: varchar("account_type", { length: 32 })
.$type<AccountType>()
.notNull(),
balanceType: varchar("balance_type", { length: 32 }) balanceType: varchar("balance_type", { length: 32 })
.$type<BalanceType>() .$type<BalanceType>()
.default("ASSET") .default("ASSET")
@@ -119,20 +121,18 @@ export const accounts = pgTable(
...timestamps, ...timestamps,
}, },
(t) => [ (t) => [index("accounts_user_id_idx").on(t.userId)]
index("accounts_user_id_idx").on(t.userId), )
]
);
export type Account = typeof accounts.$inferSelect; export type Account = typeof accounts.$inferSelect
export type NewAccount = typeof accounts.$inferInsert; export type NewAccount = typeof accounts.$inferInsert
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 3. 渠道表 (Channels) // 3. 渠道表 (Channels)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"; export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"
export type PaymentInstrumentType = "CREDIT" | "DEBIT"; export type PaymentInstrumentType = "CREDIT" | "DEBIT"
export const channels = pgTable( export const channels = pgTable(
"channels", "channels",
@@ -141,7 +141,9 @@ export const channels = pgTable(
userId: uuid("user_id") userId: uuid("user_id")
.references(() => users.id, { onDelete: "cascade" }) .references(() => users.id, { onDelete: "cascade" })
.notNull(), .notNull(),
channelType: varchar("channel_type", { length: 32 }).$type<ChannelType>().notNull(), channelType: varchar("channel_type", { length: 32 })
.$type<ChannelType>()
.notNull(),
refAccounts: jsonb("ref_accounts").$type<string[]>().notNull(), // 关联的账户 UUID 列表 refAccounts: jsonb("ref_accounts").$type<string[]>().notNull(), // 关联的账户 UUID 列表
isActive: boolean("is_active").default(true).notNull(), isActive: boolean("is_active").default(true).notNull(),
desc: text("desc"), desc: text("desc"),
@@ -150,7 +152,9 @@ export const channels = pgTable(
// 支付卡渠道字段 // 支付卡渠道字段
region: varchar("region", { length: 10 }), // 发卡地如 HK, CN region: varchar("region", { length: 10 }), // 发卡地如 HK, CN
issuerName: varchar("issuer_name", { length: 100 }), issuerName: varchar("issuer_name", { length: 100 }),
cardType: varchar("card_type", { length: 32 }).$type<PaymentInstrumentType>(), cardType: varchar("card_type", {
length: 32,
}).$type<PaymentInstrumentType>(),
cardNumberFull: varchar("card_number_full", { length: 100 }), cardNumberFull: varchar("card_number_full", { length: 100 }),
cardNumberSuffix: varchar("card_number_suffix", { length: 10 }), cardNumberSuffix: varchar("card_number_suffix", { length: 10 }),
cardBrand: varchar("card_brand", { length: 32 }), cardBrand: varchar("card_brand", { length: 32 }),
@@ -159,29 +163,29 @@ export const channels = pgTable(
platform: varchar("platform", { length: 64 }), platform: varchar("platform", { length: 64 }),
platformAccountId: varchar("platform_account_id", { length: 100 }), platformAccountId: varchar("platform_account_id", { length: 100 }),
subChannel: varchar("sub_channel", { length: 64 }), subChannel: varchar("sub_channel", { length: 64 }),
subChannelType: varchar("sub_channel_type", { length: 32 }).$type<PaymentInstrumentType>(), subChannelType: varchar("sub_channel_type", {
length: 32,
}).$type<PaymentInstrumentType>(),
...timestamps, ...timestamps,
}, },
(t) => [ (t) => [index("channels_user_id_idx").on(t.userId)]
index("channels_user_id_idx").on(t.userId), )
]
);
export type Channel = typeof channels.$inferSelect; export type Channel = typeof channels.$inferSelect
export type NewChannel = typeof channels.$inferInsert; export type NewChannel = typeof channels.$inferInsert
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 4. 交易记账表 (Transactions) // 4. 交易记账表 (Transactions)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED"; export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED"
export type DcFlag = "DEBIT" | "CREDIT"; export type DcFlag = "DEBIT" | "CREDIT"
export interface FxRateItem { export interface FxRateItem {
fromCcy: string; fromCcy: string
toCcy: string; toCcy: string
rate: string; rate: string
} }
export const transactions = pgTable( export const transactions = pgTable(
@@ -238,7 +242,75 @@ export const transactions = pgTable(
index("transactions_user_id_idx").on(t.userId), index("transactions_user_id_idx").on(t.userId),
index("transactions_txn_date_idx").on(t.txnDate), index("transactions_txn_date_idx").on(t.txnDate),
] ]
); )
export type Transaction = typeof transactions.$inferSelect; export type Transaction = typeof transactions.$inferSelect
export type NewTransaction = typeof transactions.$inferInsert; export type NewTransaction = typeof transactions.$inferInsert
// ---------------------------------------------------------------------------
// 5. 待清洗暂存交易表 (Transactions Dirty)
// ---------------------------------------------------------------------------
export const transactionsDirty = pgTable(
"transactions_dirty",
{
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
version: varchar("version", { length: 16 }).default("0").notNull(),
refTransactions: jsonb("ref_transactions").$type<string[]>(),
txnDate: timestamp("txn_date", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
clearingDate: timestamp("clearing_date", { withTimezone: true }),
postingDate: timestamp("posting_date", { withTimezone: true }),
// 金额与币种(草稿阶段)
txnAmt: varchar("txn_amt", { length: 32 }).notNull(),
txnCcy: varchar("txn_ccy", { length: 10 }).notNull(),
postingAmt: varchar("posting_amt", { length: 32 }),
postingCcy: varchar("posting_ccy", { length: 10 }),
commAmt: varchar("comm_amt", { length: 32 }),
commCcy: varchar("comm_ccy", { length: 10 }),
surchargeAmt: varchar("surcharge_amt", { length: 32 }),
surchargeCcy: varchar("surcharge_ccy", { length: 10 }),
discAmt: varchar("disc_amt", { length: 32 }),
discCcy: varchar("disc_ccy", { length: 10 }),
fxRates: jsonb("fx_rates").$type<FxRateItem[]>(),
dcFlag: varchar("dc_flag", { length: 16 })
.$type<DcFlag>()
.default("DEBIT")
.notNull(),
refChannels: jsonb("ref_channels").$type<string[]>().default([]).notNull(),
cp: varchar("cp", { length: 255 }),
acqInst: varchar("acq_inst", { length: 150 }),
clearingNetwork: varchar("clearing_network", { length: 100 }),
txnSts: varchar("txn_sts", { length: 32 })
.$type<TransactionStatus>()
.default("COMPLETED")
.notNull(),
description: text("description"),
memo: text("memo"),
ext: text("ext"),
rawDescription: text("raw_description"),
rawData: jsonb("raw_data"),
// 场景类型
txnScene: varchar("txn_scene", { length: 64 }).default("PAYMENT").notNull(),
merchantName: varchar("merchant_name", { length: 255 }),
orderId: varchar("order_id", { length: 150 }),
geo: varchar("geo", { length: 64 }),
...timestamps,
},
(t) => [
index("transactions_dirty_user_id_idx").on(t.userId),
index("transactions_dirty_txn_date_idx").on(t.txnDate),
]
)
export type TransactionDirty = typeof transactionsDirty.$inferSelect
export type NewTransactionDirty = typeof transactionsDirty.$inferInsert
+6 -6
View File
@@ -1,12 +1,12 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth"
import { authConfig } from "@/lib/auth/config"; import { authConfig } from "@/lib/auth/config"
const { auth } = NextAuth(authConfig); const { auth } = NextAuth(authConfig)
export const proxy = auth; export const proxy = auth
export default auth; export default auth
export const config = { export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}; }