diff --git a/AGENTS.md b/AGENTS.md
index f909a03..fa45268 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,32 +8,35 @@ This version has breaking changes — APIs, conventions, and file structure may
## 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.
-- Add UI primitives only through `pnpm dlx shadcn@latest add --yes`. Before using a shadcn component, run `pnpm dlx shadcn@latest docs ` and read the referenced docs.
-- Formatting is Prettier with Tailwind sorting (`pnpm format`). It uses double quotes, no semicolons, 2 spaces, LF, and 80-column wrapping.
+- 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 via `pnpm dlx shadcn@latest add --yes`. Read the referenced docs via `pnpm dlx shadcn@latest docs ` before using.
+- Format with `pnpm format` (Prettier + Tailwind plugin: double quotes, no semicolons, 2 spaces, LF, 80-column wrap).
## 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 uses `proxy.ts`, not `middleware.ts`. Keep `proxy.ts` edge-safe: it imports 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.
-- `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.
+- 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: import only `lib/auth/config.ts`, never database or password-hashing code.
+- 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 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`.
-- 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.
-- 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.
-- Keep soft deletion (`deleted_at`) semantics in reads and mutations. Account and channel management actions already follow this model.
+- 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`.
+- Schema sync: `pnpm drizzle-kit push --force`. Never execute `pnpm tsx lib/db/reset.ts` unless explicitly instructed (drops all data tables).
+- 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.
+- 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
-- The project uses shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, and 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.
-- Forms use `FieldGroup`, `Field`, and `FieldLabel`; use `FieldSet`/`FieldLegend` only when the grouping adds user-facing meaning. Put `SelectItem` inside `SelectGroup`.
-- Errors and callouts use `Alert`; destructive errors use `Alert variant="destructive"` with `AlertTitle` and `AlertDescription`, never a hand-styled `div`.
-- 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.
-- 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.
-- Use `gap-*`, never `space-x-*` or `space-y-*`; use semantic tokens instead of raw color palettes or manual `dark:` overrides.
-- 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.
-- 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.
+- Tech stack: shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, semantic CSS variables from `app/globals.css`.
+- Base UI Select & Combobox:
+ - ``: When `` 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.
+ - ``: 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.
+- Form composition: Use `FieldGroup`, `Field`, and `FieldLabel`. Put `SelectItem` inside `SelectGroup`. Use `FieldSet`/`FieldLegend` only when grouping adds visible user meaning.
+- 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.
+- 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 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.
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index c55a45e..866b2be 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -1,3 +1,3 @@
-import { handlers } from "@/lib/auth";
+import { handlers } from "@/lib/auth"
-export const { GET, POST } = handlers;
+export const { GET, POST } = handlers
diff --git a/app/bookkeeping/bookkeeping-view.tsx b/app/bookkeeping/bookkeeping-view.tsx
new file mode 100644
index 0000000..82b3c04
--- /dev/null
+++ b/app/bookkeeping/bookkeeping-view.tsx
@@ -0,0 +1,1122 @@
+"use client"
+
+import * as React from "react"
+import Image from "next/image"
+import { useRouter } from "next/navigation"
+import {
+ AlertCircleIcon,
+ ArrowDownLeftIcon,
+ ArrowRightLeftIcon,
+ ArrowUpRightIcon,
+ BanknoteIcon,
+ CreditCardIcon,
+ InboxIcon,
+ Loader2Icon,
+ PenLineIcon,
+ PlusCircleIcon,
+ RotateCcwIcon,
+ SparklesIcon,
+ Trash2Icon,
+ WalletCardsIcon,
+} from "lucide-react"
+
+import {
+ createDirtyTransactionAction,
+ updateDirtyTransactionAction,
+ deleteDirtyTransactionAction,
+ cleanseTransactionsAction,
+ type DirtyTransactionInput,
+} from "@/lib/actions/bookkeeping"
+import type { ChannelWithAccountNames } from "@/lib/actions/channel"
+import type { TransactionDirty } from "@/lib/db/schema"
+import {
+ CARD_BRAND_LABELS,
+ getCardBrandLogoUrl,
+ normalizeCardBrand,
+} from "@/lib/payment/card-brand"
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+interface BookkeepingViewProps {
+ initialDirtyTransactions: TransactionDirty[]
+ initialChannels: ChannelWithAccountNames[]
+}
+
+const TXN_SCENES = [
+ { value: "PAYMENT", label: "消费支出" },
+ { value: "MISC_IN", label: "日常收入" },
+ { value: "TRANSFER", label: "转账" },
+ { value: "ATM", label: "取现" },
+] as const
+
+function getChannelIdentifier(channel: ChannelWithAccountNames): string {
+ if (channel.channelType === "PAYMENT_CARD") {
+ let suffix = channel.cardNumberSuffix?.trim()
+ if (
+ !suffix &&
+ channel.cardNumberFull &&
+ channel.cardNumberFull.length >= 4
+ ) {
+ suffix = channel.cardNumberFull.slice(-4)
+ }
+ return suffix ? `•••• ${suffix}` : ""
+ }
+ if (channel.channelType === "E_WALLET") {
+ return channel.platformAccountId?.trim() || ""
+ }
+ return channel.subChannel?.trim() || ""
+}
+
+function channelName(channel: ChannelWithAccountNames) {
+ if (channel.channelType === "PAYMENT_CARD") {
+ const brand = normalizeCardBrand(channel.cardBrand)
+ const brandName = brand
+ ? ` ${CARD_BRAND_LABELS[brand]}`
+ : channel.cardBrand
+ ? ` ${channel.cardBrand}`
+ : ""
+ return `${channel.issuerName || "支付卡"}${brandName}`
+ }
+ if (channel.channelType === "E_WALLET") return channel.platform || "电子钱包"
+ return channel.channelType === "CASH" ? "现金渠道" : "转账渠道"
+}
+
+function channelPrimaryTitle(channel: ChannelWithAccountNames) {
+ return (
+ channel.desc ||
+ channel.platformAccountId ||
+ channel.subChannel ||
+ channelName(channel)
+ )
+}
+
+function channelDisplayLabel(channel: ChannelWithAccountNames) {
+ const primary = channelPrimaryTitle(channel)
+ const secondary = channelName(channel)
+ const identifier = getChannelIdentifier(channel)
+
+ // 拼接回显标签,确保卡号末四位或账号标识清晰可见
+ let label = primary
+ if (secondary && secondary !== primary) {
+ label = `${primary} · ${secondary}`
+ }
+ if (identifier && !label.includes(identifier)) {
+ label = `${label} ${identifier}`
+ }
+ return label
+}
+
+function formatChannelName(channel: ChannelWithAccountNames) {
+ if (channel.channelType === "PAYMENT_CARD") {
+ const brand = channel.cardBrand ? ` ${channel.cardBrand}` : ""
+ const suffix = channel.cardNumberSuffix
+ ? ` (${channel.cardNumberSuffix})`
+ : ""
+ return `${channel.issuerName || "支付卡"}${brand}${suffix}`
+ }
+ if (channel.channelType === "E_WALLET") {
+ return channel.platform
+ ? `${channel.platform}${channel.platformAccountId ? ` - ${channel.platformAccountId}` : ""}`
+ : "电子钱包"
+ }
+ if (channel.channelType === "CASH") {
+ return channel.desc || "现金渠道"
+ }
+ return channel.desc || "转账渠道"
+}
+
+function renderChannelIcon(channel: ChannelWithAccountNames) {
+ if (channel.channelType === "PAYMENT_CARD") {
+ const brand = normalizeCardBrand(channel.cardBrand)
+ const logoUrl = getCardBrandLogoUrl(channel.cardBrand)
+ if (logoUrl && brand) {
+ return (
+
+ )
+ }
+ return
+ }
+ if (channel.channelType === "E_WALLET") {
+ return
+ }
+ if (channel.channelType === "CASH") {
+ return
+ }
+ return (
+
+ )
+}
+
+function getSceneBadge(scene: string) {
+ switch (scene) {
+ case "PAYMENT":
+ return 消费支出
+ case "MISC_IN":
+ return (
+
+ 日常收入
+
+ )
+ case "TRANSFER":
+ return 转账
+ case "ATM":
+ return 取现
+ default:
+ return {scene}
+ }
+}
+
+function formatDateForInput(dateVal?: Date | string | null): string {
+ if (!dateVal) {
+ const now = new Date()
+ const offset = now.getTimezoneOffset() * 60000
+ const localISODate = new Date(now.getTime() - offset).toISOString()
+ return localISODate.slice(0, 16)
+ }
+ const d = new Date(dateVal)
+ if (isNaN(d.getTime())) {
+ const now = new Date()
+ const offset = now.getTimezoneOffset() * 60000
+ return new Date(now.getTime() - offset).toISOString().slice(0, 16)
+ }
+ const offset = d.getTimezoneOffset() * 60000
+ const localISODate = new Date(d.getTime() - offset).toISOString()
+ return localISODate.slice(0, 16)
+}
+
+function formatDisplayDate(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")
+ return `${y}-${m}-${day} ${hh}:${mm}`
+}
+
+export function BookkeepingView({
+ initialDirtyTransactions,
+ initialChannels,
+}: BookkeepingViewProps) {
+ const router = useRouter()
+
+ const [dirtyList, setDirtyList] = React.useState(
+ initialDirtyTransactions
+ )
+ const [channels] = React.useState(initialChannels)
+
+ // 移动端 Tab: "form" | "list"
+ const [mobileTab, setMobileTab] = React.useState<"form" | "list">("form")
+
+ // 编辑态,选中的待清洗记录
+ const [selectedTxnId, setSelectedTxnId] = React.useState(null)
+
+ // 表单状态
+ const [txnScene, setTxnScene] = React.useState("PAYMENT")
+ const [dcFlag, setDcFlag] = React.useState<"DEBIT" | "CREDIT">("DEBIT")
+ const [txnAmt, setTxnAmt] = React.useState("")
+ const [txnCcy, setTxnCcy] = React.useState("CNY")
+ const [channelId, setChannelId] = React.useState(
+ initialChannels[0]?.id || ""
+ )
+ const [txnDate, setTxnDate] = React.useState(() =>
+ formatDateForInput()
+ )
+ const [merchantName, setMerchantName] = React.useState("")
+ const [description, setDescription] = React.useState("")
+ const [memo, setMemo] = React.useState("")
+
+ // 折算与入账扩展字段
+ const [postingAmt, setPostingAmt] = React.useState("")
+ const [postingCcy, setPostingCcy] = React.useState("")
+ const [commAmt, setCommAmt] = React.useState("")
+ const [commCcy, setCommCcy] = React.useState("")
+
+ // 交互与执行状态
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isCleansing, setIsCleansing] = React.useState(false)
+ const [formError, setFormError] = React.useState(null)
+
+ // 清洗阻断错误清单与全局提示
+ const [cleanseErrors, setCleanseErrors] = React.useState<
+ { id: string; name: string; error: string }[]
+ >([])
+ const [cleanseGlobalError, setCleanseGlobalError] = React.useState<
+ string | null
+ >(null)
+
+ // 单条删除执行状态
+ const [deletingId, setDeletingId] = React.useState(null)
+
+ // 渠道映射 Map 方便快速索引
+ const channelMap = React.useMemo(() => {
+ return new Map(channels.map((c) => [c.id, c]))
+ }, [channels])
+
+ // 阻断错误 ID 映射 Map
+ const errorMap = React.useMemo(() => {
+ const map = new Map()
+ cleanseErrors.forEach((e) => {
+ map.set(e.id, e.error)
+ })
+ return map
+ }, [cleanseErrors])
+
+ // 重置表单为默认新建态
+ const resetForm = React.useCallback(() => {
+ setSelectedTxnId(null)
+ setTxnScene("PAYMENT")
+ setDcFlag("DEBIT")
+ setTxnAmt("")
+ setTxnCcy("CNY")
+ setChannelId(channels[0]?.id || "")
+ setTxnDate(formatDateForInput())
+ setMerchantName("")
+ setDescription("")
+ setMemo("")
+ setPostingAmt("")
+ setPostingCcy("")
+ setCommAmt("")
+ setCommCcy("")
+ setFormError(null)
+ }, [channels])
+
+ // 点击待清洗记录载入编辑
+ const handleSelectTxn = React.useCallback((item: TransactionDirty) => {
+ setSelectedTxnId(item.id)
+ setTxnScene(item.txnScene || "PAYMENT")
+ setDcFlag(item.dcFlag === "CREDIT" ? "CREDIT" : "DEBIT")
+ setTxnAmt(item.txnAmt || "")
+ setTxnCcy(item.txnCcy || "CNY")
+ const ch = Array.isArray(item.refChannels) && item.refChannels[0]
+ setChannelId(ch || "")
+ setTxnDate(formatDateForInput(item.txnDate))
+ setMerchantName(item.merchantName || "")
+ setDescription(item.description || "")
+ setMemo(item.memo || "")
+ setPostingAmt(item.postingAmt || "")
+ setPostingCcy(item.postingCcy || "")
+ setCommAmt(item.commAmt || "")
+ setCommCcy(item.commCcy || "")
+ setFormError(null)
+
+ // 移动端切换到表单视口
+ setMobileTab("form")
+ }, [])
+
+ // 场景与借贷方向联动快捷设值
+ const handleSceneChange = (scene: string) => {
+ setTxnScene(scene)
+ if (scene === "MISC_IN") {
+ setDcFlag("CREDIT")
+ } else if (scene === "PAYMENT" || scene === "ATM") {
+ setDcFlag("DEBIT")
+ }
+ }
+
+ // 提交暂存或修改
+ const handleSubmitForm = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setFormError(null)
+
+ const amtNum = parseFloat(txnAmt)
+ if (!txnAmt || isNaN(amtNum) || amtNum <= 0) {
+ setFormError("请输入有效的交易金额(大于0的数值)")
+ return
+ }
+
+ if (!txnCcy.trim()) {
+ setFormError("请输入交易币种(如 CNY, USD, HKD 等)")
+ return
+ }
+
+ if (!channelId) {
+ setFormError("请选择渠道")
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const payload: DirtyTransactionInput = {
+ txnDate: new Date(txnDate).toISOString(),
+ txnAmt: txnAmt.trim(),
+ txnCcy: txnCcy.trim().toUpperCase(),
+ postingAmt: postingAmt.trim() ? postingAmt.trim() : null,
+ postingCcy: postingCcy.trim() ? postingCcy.trim().toUpperCase() : null,
+ commAmt: commAmt.trim() ? commAmt.trim() : null,
+ commCcy: commCcy.trim() ? commCcy.trim().toUpperCase() : null,
+ dcFlag,
+ refChannels: [channelId],
+ txnScene,
+ merchantName: merchantName.trim() || null,
+ description: description.trim() || null,
+ memo: memo.trim() || null,
+ }
+
+ if (selectedTxnId) {
+ // 编辑模式
+ const res = await updateDirtyTransactionAction(selectedTxnId, payload)
+ if (!res.success || !res.data) {
+ setFormError(res.error || "更新暂存交易失败")
+ return
+ }
+
+ // 更新本地列表
+ setDirtyList((prev) =>
+ prev.map((item) => (item.id === selectedTxnId ? res.data! : item))
+ )
+ // 清理当前项可能存在的阻断错误提示
+ setCleanseErrors((prev) =>
+ prev.filter((err) => err.id !== selectedTxnId)
+ )
+ resetForm()
+ router.refresh()
+ } else {
+ // 新建模式
+ const res = await createDirtyTransactionAction(payload)
+ if (!res.success || !res.data) {
+ setFormError(res.error || "暂存交易失败")
+ return
+ }
+
+ // 插入到列表首位
+ setDirtyList((prev) => [res.data!, ...prev])
+ resetForm()
+ router.refresh()
+ }
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : "提交异常,请重试")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 删除单笔流水(无需二次确认,直接删除)
+ const handleDeleteTxn = async (id: string) => {
+ if (deletingId) return
+ setDeletingId(id)
+ try {
+ const res = await deleteDirtyTransactionAction(id)
+ if (res.success) {
+ setDirtyList((prev) => prev.filter((item) => item.id !== id))
+ setCleanseErrors((prev) => prev.filter((err) => err.id !== id))
+ if (selectedTxnId === id) {
+ resetForm()
+ }
+ router.refresh()
+ } else {
+ setCleanseGlobalError(res.error || "删除暂存流水失败")
+ }
+ } finally {
+ setDeletingId(null)
+ }
+ }
+
+ // 执行全量原子阻断清洗入账
+ const handleCleanseAll = async () => {
+ if (dirtyList.length === 0) return
+ setIsCleansing(true)
+ setCleanseGlobalError(null)
+ setCleanseErrors([])
+
+ try {
+ const res = await cleanseTransactionsAction()
+ if (res.success) {
+ setDirtyList([])
+ resetForm()
+ router.refresh()
+ } else {
+ setCleanseGlobalError(
+ res.error || "存在未平衡或不合规的交易记录,全量阻断合并!"
+ )
+ if (res.errors && res.errors.length > 0) {
+ setCleanseErrors(res.errors)
+ }
+ }
+ } catch (err) {
+ setCleanseGlobalError(
+ err instanceof Error ? err.message : "清洗请求异常,合并已阻断"
+ )
+ } finally {
+ setIsCleansing(false)
+ }
+ }
+
+ return (
+
+
+ {/* 全局阻断错误报告 (标准 Alert variant="destructive") */}
+ {cleanseGlobalError && (
+
+
+
+ 清洗阻断报告:数据未满足入账标准,原子合并已完全中止
+
+
+ {cleanseGlobalError}
+ {cleanseErrors.length > 0 && (
+
+
+ 阻断交易明细(已在列表中标红高亮,请修正):
+
+
+ {cleanseErrors.map((err) => (
+ -
+ 「{err.name}」:{" "}
+ {err.error}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* 页头控制区 */}
+
+
+
+
+ 流水记账
+
+
+ 待清洗 {dirtyList.length} 笔
+
+
+
+ 录入与核对暂存流水,确认无误后完成清洗入账
+
+
+
+
+
+
+
+
+ {/* 移动端视图切换分段器 (md: 及以下) */}
+
+
+
+
+
+ {/* PC 端左右分栏工作台 (lg: 5:7 黄金比例双栏工作台) */}
+
+ {/* 左栏:录入 / 联动编辑工作区 (5/12) */}
+
+
+
+
+ {selectedTxnId ? (
+ <>
+
+
编辑暂存流水
+ >
+ ) : (
+ <>
+
+
新建暂存流水
+ >
+ )}
+
+ {selectedTxnId && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/* 右栏:待清洗流水密集列表 (7/12) */}
+
+
+
+
+
待清洗流水列表
+
+ 共 {dirtyList.length} 笔待处理
+
+
+ {dirtyList.length > 0 && (
+
+ 点击任意条目即可在左栏编辑
+
+ )}
+
+
+ {dirtyList.length > 0 ? (
+
+ {/* PC 列表表头 */}
+
+ 交易要素 / 渠道
+ 发生金额
+ 场景
+
+
+
+
+ {dirtyList.map((item) => {
+ const isSelected = selectedTxnId === item.id
+ const hasError = errorMap.has(item.id)
+ const errorMsg = errorMap.get(item.id)
+ const primaryChannelId =
+ Array.isArray(item.refChannels) && item.refChannels[0]
+ const ch = primaryChannelId
+ ? channelMap.get(primaryChannelId)
+ : null
+
+ return (
+
handleSelectTxn(item)}
+ className={`group relative flex cursor-pointer flex-col gap-2 p-3 transition-colors md:grid md:grid-cols-[1fr_auto_80px_40px] md:items-center md:gap-3 md:px-4 md:py-3 ${
+ isSelected
+ ? "border-l-4 border-l-primary bg-primary/5"
+ : "hover:bg-muted/40"
+ } ${
+ hasError
+ ? "border-destructive/40 bg-destructive/5"
+ : ""
+ }`}
+ >
+ {/* 交易主体信息 */}
+
+
+ {ch ? (
+ renderChannelIcon(ch)
+ ) : (
+
+ )}
+
+
+
+
+ {item.merchantName ||
+ item.description ||
+ "未命名交易"}
+
+ {hasError && (
+
+ 待修正
+
+ )}
+
+
+
+ {formatDisplayDate(item.txnDate)}
+
+ •
+
+ {ch
+ ? formatChannelName(ch)
+ : "未关联有效渠道"}
+
+ {item.description && item.merchantName && (
+ <>
+ •
+
+ {item.description}
+
+ >
+ )}
+
+ {/* 行内错误提示 */}
+ {hasError && (
+
+ {errorMsg}
+
+ )}
+
+
+
+ {/* 金额 */}
+
+
+ 发生金额
+
+
+
+ {item.dcFlag === "CREDIT" ? "+" : "-"}
+ {item.txnAmt}
+
+
+ {item.txnCcy}
+
+
+
+
+ {/* 场景徽标 */}
+
+
+ 场景
+
+ {getSceneBadge(item.txnScene)}
+
+
+ {/* 操作:删除 */}
+
e.stopPropagation()}
+ >
+
+ handleDeleteTxn(item.id)}
+ />
+ }
+ >
+ {deletingId === item.id ? (
+
+ ) : (
+
+ )}
+
+ 删除暂存流水
+
+
+
+ )
+ })}
+
+
+ ) : (
+ /* 空状态 */
+
+
+
+
+
+ 暂无待清洗的暂存流水
+
+
+ 当前没有待清洗的暂存流水,可通过左侧表单开始录入。全部暂存数据校验合格后,可一键完成清洗入账。
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/app/bookkeeping/page.tsx b/app/bookkeeping/page.tsx
new file mode 100644
index 0000000..1ed9709
--- /dev/null
+++ b/app/bookkeeping/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/channels/channels-view.tsx b/app/channels/channels-view.tsx
index 943701c..4e22e65 100644
--- a/app/channels/channels-view.tsx
+++ b/app/channels/channels-view.tsx
@@ -473,7 +473,9 @@ export function ChannelsView({
- 类型
+
+ 类型
+
{channelTypeLabels[channel.channelType]}
diff --git a/app/layout.tsx b/app/layout.tsx
index db6da1d..b374404 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -8,11 +8,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
-
+
{children}
diff --git a/app/login/login-form.tsx b/app/login/login-form.tsx
index 1bae28a..958ba72 100644
--- a/app/login/login-form.tsx
+++ b/app/login/login-form.tsx
@@ -1,9 +1,9 @@
-"use client";
+"use client"
-import * as React from "react";
-import Link from "next/link";
-import { useRouter, useSearchParams } from "next/navigation";
-import { signIn } from "next-auth/react";
+import * as React from "react"
+import Link from "next/link"
+import { useRouter, useSearchParams } from "next/navigation"
+import { signIn } from "next-auth/react"
import {
ArrowRightIcon,
CheckCircle2Icon,
@@ -16,10 +16,10 @@ import {
PieChartIcon,
TrendingUpIcon,
WalletIcon,
-} from "lucide-react";
+} from "lucide-react"
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -27,90 +27,90 @@ import {
CardFooter,
CardHeader,
CardTitle,
-} from "@/components/ui/card";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import { FluxentLogo } from "@/components/fluxent-logo";
-import { ThemeToggle } from "@/components/theme-toggle";
+} from "@/components/ui/card"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+import { FluxentLogo } from "@/components/fluxent-logo"
+import { ThemeToggle } from "@/components/theme-toggle"
interface LoginFormProps {
- oidcEnabled: boolean;
- oidcName: string;
+ oidcEnabled: boolean
+ oidcName: string
}
export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const callbackUrl = searchParams.get("callbackUrl") || "/";
- const authError = searchParams.get("error");
- const registered = searchParams.get("registered");
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const callbackUrl = searchParams.get("callbackUrl") || "/"
+ const authError = searchParams.get("error")
+ const registered = searchParams.get("registered")
- const [email, setEmail] = React.useState("");
- const [password, setPassword] = React.useState("");
- const [rememberMe, setRememberMe] = React.useState(false);
- const [isPending, setIsPending] = React.useState(false);
- const [isOidcPending, setIsOidcPending] = React.useState(false);
+ const [email, setEmail] = React.useState("")
+ const [password, setPassword] = React.useState("")
+ const [rememberMe, setRememberMe] = React.useState(false)
+ const [isPending, setIsPending] = React.useState(false)
+ const [isOidcPending, setIsOidcPending] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState(() => {
if (authError === "CredentialsSignin") {
- return "邮箱或密码错误,请核对后重试";
+ return "邮箱或密码错误,请核对后重试"
}
if (authError) {
- return "认证过程中遇到问题,请重新登录";
+ return "认证过程中遇到问题,请重新登录"
}
- return null;
- });
+ return null
+ })
const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setErrorMessage(null);
+ e.preventDefault()
+ setErrorMessage(null)
if (!email.trim()) {
- setErrorMessage("请输入登录邮箱");
- return;
+ setErrorMessage("请输入登录邮箱")
+ return
}
if (!password) {
- setErrorMessage("请输入登录密码");
- return;
+ setErrorMessage("请输入登录密码")
+ return
}
try {
- setIsPending(true);
+ setIsPending(true)
const res = await signIn("credentials", {
email: email.trim(),
password,
redirect: false,
callbackUrl,
- });
+ })
if (res?.error) {
if (res.error === "CredentialsSignin" || res.code === "credentials") {
- setErrorMessage("账号或密码不正确,请重新检查");
+ setErrorMessage("账号或密码不正确,请重新检查")
} else {
- setErrorMessage("登录失败,请稍后重试");
+ setErrorMessage("登录失败,请稍后重试")
}
- return;
+ return
}
- router.push(callbackUrl);
- router.refresh();
+ router.push(callbackUrl)
+ router.refresh()
} catch {
- setErrorMessage("网络异常,无法连接到认证服务器");
+ setErrorMessage("网络异常,无法连接到认证服务器")
} finally {
- setIsPending(false);
+ setIsPending(false)
}
- };
+ }
const handleOidcLogin = async () => {
try {
- setIsOidcPending(true);
- await signIn("oidc", { callbackUrl });
+ setIsOidcPending(true)
+ await signIn("oidc", { callbackUrl })
} catch {
- setIsOidcPending(false);
- setErrorMessage("SSO 单点登录发起失败,请稍后重试");
+ setIsOidcPending(false)
+ setErrorMessage("SSO 单点登录发起失败,请稍后重试")
}
- };
+ }
return (
@@ -147,7 +147,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
-
+
全球多币种 • 全场景记账 • 实时汇率
@@ -155,7 +155,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
让财务管理行云流水。
-
+
无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent
提供银行级的精确记录与现代化的优雅交互体验。
@@ -170,7 +170,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
全币种与汇率追踪
-
+
多币种入账与清算汇率对账,资产估值一目了然
@@ -182,7 +182,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
全渠道多账户管理
-
+
银行账户、电子钱包与支付卡渠道统一调度
@@ -194,7 +194,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
复式记账与场景分类
-
+
标准借贷分录与消费场景画像,财务合规严谨
@@ -206,7 +206,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
资产汇总与统计
-
+
跨账户资金结构分布分析,收支报表一览无余
@@ -273,7 +273,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
-
+
或者使用邮箱密码
@@ -337,7 +337,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
/>
@@ -373,7 +373,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
立即注册新账号
@@ -389,8 +389,10 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
{/* Footer */}
- );
+ )
}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 0a6b82c..58cd77c 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,14 +1,14 @@
-import { Suspense } from "react";
-import { LoginForm } from "./login-form";
+import { Suspense } from "react"
+import { LoginForm } from "./login-form"
export const metadata = {
title: "登录 - Fluxent",
description: "Fluxent 多币种资产与全场景记账系统",
-};
+}
export default function LoginPage() {
- const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true";
- const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)";
+ const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true"
+ const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)"
return (
- );
+ )
}
diff --git a/app/page.tsx b/app/page.tsx
index 23a1758..d4372a1 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,5 @@
-import { redirect } from "next/navigation";
-import Link from "next/link";
+import { redirect } from "next/navigation"
+import Link from "next/link"
import {
ArrowRightIcon,
CreditCardIcon,
@@ -9,35 +9,27 @@ import {
SlidersHorizontalIcon,
TrendingUpIcon,
WalletIcon,
-} from "lucide-react";
+} from "lucide-react"
-import { auth } from "@/lib/auth";
-import { AppSidebar } from "@/components/app-sidebar";
-import { AppHeader } from "@/components/app-header";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import {
- SidebarInset,
- SidebarProvider,
-} from "@/components/ui/sidebar";
+import { auth } from "@/lib/auth"
+import { AppSidebar } from "@/components/app-sidebar"
+import { AppHeader } from "@/components/app-header"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
export default async function HomePage() {
- const session = await auth();
+ const session = await auth()
// 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障
if (!session?.user) {
- redirect("/login");
+ redirect("/login")
}
- const user = session.user;
- const userName = user.name || "Fluxent 用户";
- const userEmail = user.email || "";
- const userAvatar = user.image || null;
+ const user = session.user
+ const userName = user.name || "Fluxent 用户"
+ const userEmail = user.email || ""
+ const userAvatar = user.image || null
return (
@@ -59,8 +51,9 @@ export default async function HomePage() {
欢迎回来,{userName}
-
- 这是您的 Fluxent 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
+
+ 这是您的 Fluxent
+ 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
@@ -150,7 +143,7 @@ export default async function HomePage() {
1. 添加账户与支付渠道
-
+
录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。
@@ -171,7 +164,7 @@ export default async function HomePage() {
2. 建立首笔复式流水
-
+
支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。
@@ -192,7 +185,7 @@ export default async function HomePage() {
3. 账户首选项与设置
-
+
配置个人偏好货币、导出记账数据或连接外部服务。
@@ -209,5 +202,5 @@ export default async function HomePage() {
- );
+ )
}
diff --git a/app/register/page.tsx b/app/register/page.tsx
index 35c9d2d..cff5acf 100644
--- a/app/register/page.tsx
+++ b/app/register/page.tsx
@@ -1,10 +1,10 @@
-import { Suspense } from "react";
-import { RegisterForm } from "./register-form";
+import { Suspense } from "react"
+import { RegisterForm } from "./register-form"
export const metadata = {
title: "注册新账号 - Fluxent",
description: "创建您的 Fluxent 多币种资产与记账账户",
-};
+}
export default function RegisterPage() {
return (
@@ -17,5 +17,5 @@ export default function RegisterPage() {
>
- );
+ )
}
diff --git a/app/register/register-form.tsx b/app/register/register-form.tsx
index d4ca9ac..d393e14 100644
--- a/app/register/register-form.tsx
+++ b/app/register/register-form.tsx
@@ -1,9 +1,9 @@
-"use client";
+"use client"
-import * as React from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useActionState } from "react";
+import * as React from "react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { useActionState } from "react"
import {
ArrowLeftIcon,
CheckCircle2Icon,
@@ -13,11 +13,11 @@ import {
MailIcon,
SparklesIcon,
UserIcon,
-} from "lucide-react";
+} from "lucide-react"
-import { registerAction, type RegisterState } from "@/lib/actions/auth";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
+import { registerAction, type RegisterState } from "@/lib/actions/auth"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -25,45 +25,45 @@ import {
CardFooter,
CardHeader,
CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { FluxentLogo } from "@/components/fluxent-logo";
-import { ThemeToggle } from "@/components/theme-toggle";
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { FluxentLogo } from "@/components/fluxent-logo"
+import { ThemeToggle } from "@/components/theme-toggle"
-const initialState: RegisterState = {};
+const initialState: RegisterState = {}
export function RegisterForm() {
- const router = useRouter();
+ const router = useRouter()
const [state, formAction, isPending] = useActionState(
registerAction,
initialState
- );
+ )
- const [password, setPassword] = React.useState("");
- const [confirmPassword, setConfirmPassword] = React.useState("");
+ const [password, setPassword] = React.useState("")
+ const [confirmPassword, setConfirmPassword] = React.useState("")
// 当注册成功时,优雅倒计时或自动跳转
React.useEffect(() => {
if (state?.success) {
const timer = setTimeout(() => {
- router.push("/login?registered=1");
- }, 1500);
- return () => clearTimeout(timer);
+ router.push("/login?registered=1")
+ }, 1500)
+ return () => clearTimeout(timer)
}
- }, [state?.success, router]);
+ }, [state?.success, router])
// 密码复杂度提示计算
- const hasMinLen = password.length >= 8;
+ const hasMinLen = password.length >= 8
const passwordsMatch = Boolean(
password && confirmPassword && password === confirmPassword
- );
+ )
return (
{/* Decorative ambient gradients */}
-
+
{/* Header */}
@@ -300,7 +300,7 @@ export function RegisterForm() {
>
返回直接登录
@@ -312,8 +312,10 @@ export function RegisterForm() {
{/* Footer */}
- );
+ )
}
diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx
new file mode 100644
index 0000000..67b4fa1
--- /dev/null
+++ b/app/transactions/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/transactions/transactions-view.tsx b/app/transactions/transactions-view.tsx
new file mode 100644
index 0000000..14e777b
--- /dev/null
+++ b/app/transactions/transactions-view.tsx
@@ -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 = {
+ 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 (
+
+ {label}
+
+ )
+ }
+ return {label}
+}
+
+function renderChannelLogoOrIcon(
+ ch?: TransactionWithChannelDetails["channelDetails"][0]
+) {
+ if (!ch) {
+ return
+ }
+
+ if (ch.channelType === "PAYMENT_CARD") {
+ const brand = normalizeCardBrand(ch.cardBrand)
+ const logoUrl = getCardBrandLogoUrl(ch.cardBrand)
+ if (logoUrl && brand) {
+ return (
+
+ )
+ }
+ return
+ }
+ if (ch.channelType === "E_WALLET") {
+ return
+ }
+ if (ch.channelType === "CASH") {
+ return
+ }
+ return (
+
+ )
+}
+
+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(initialTransactions)
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [dcFilter, setDcFilter] = React.useState<"ALL" | "DEBIT" | "CREDIT">(
+ "ALL"
+ )
+
+ // 查看分录详情弹窗
+ const [viewingTxn, setViewingTxn] =
+ React.useState(null)
+
+ // 删除确认弹窗
+ const [deletingTxn, setDeletingTxn] =
+ React.useState(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()
+
+ 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 (
+
+
+ {/* 页头控制与过滤检索区 */}
+
+
+
+
+ 交易记录
+
+
+ 已入账 {transactionsList.length} 笔
+
+
+
+ 查看已清洗入账的正式总账流水与多币种对账
+
+
+
+
}
+ size="default"
+ className="w-full shadow-xs sm:w-auto"
+ >
+
+ 记一笔
+
+
+
+ {/* 筛选控制器 */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="搜索商户、对手方、说明、备注或金额"
+ className="pl-8"
+ />
+
+
+
+
+ {hasFilters && (
+
+ )}
+
+
+ {/* 流水列表区 */}
+ {groupedTransactions.length > 0 ? (
+
+ {groupedTransactions.map((group) => (
+
+ {/* 日期分组标题 */}
+
+
+ {group.dateLabel}
+
+
+ ({group.items.length} 笔)
+
+
+
+ {/* 分组流水卡片容器 */}
+
+ {/* PC 列表表头 */}
+
+ 商户与描述
+ 结算渠道
+ 发生金额 (原币)
+ 入账金额 (折算)
+ 场景
+
+
+
+
+ {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 = (
+
+
+
+ }
+ />
+ }
+ >
+
+
+ 更多操作
+
+
+ setViewingTxn(txn)}
+ >
+
+ 查看明细
+
+
+ setDeletingTxn(txn)}
+ >
+
+ 删除流水
+
+
+
+ )
+
+ return (
+
+ {/* 移动端顶栏 (商户 + 更多操作在最右侧) / PC 端商户描述 */}
+
+
+
+ {renderChannelLogoOrIcon(ch)}
+
+
+
+
+ {txn.merchantName ||
+ txn.cp ||
+ txn.description ||
+ "未命名交易"}
+
+
+
+
+ {formatTime(txn.txnDate)}
+
+ {txn.description && txn.merchantName && (
+ <>
+ •
+
+ {txn.description}
+
+ >
+ )}
+
+
+
+
+ {/* 移动端自然流右侧菜单 */}
+
+ {actionMenu}
+
+
+
+ {/* 结算渠道 */}
+
+
+ {renderChannelLogoOrIcon(ch)}
+
+
+ {ch?.displayName || "未关联渠道"}
+
+
+
+ {/* 发生金额 (原币) */}
+
+
+ 发生金额
+
+
+
+ {txn.dcFlag === "CREDIT" ? "+" : "-"}
+ {txn.txnAmt}
+
+
+ {txn.txnCcy}
+
+
+
+
+ {/* 入账金额 (折算) */}
+
+
+ 入账折算
+
+
+ {isMultiCurrency ? (
+ <>
+
+
+ {txn.dcFlag === "CREDIT" ? "+" : "-"}
+ {txn.postingAmt}
+
+
+ {txn.postingCcy}
+
+
+ {rateDisplay && (
+
+ {rateDisplay}
+
+ )}
+ >
+ ) : (
+
+ 等额入账
+
+ )}
+
+
+
+ {/* 场景徽标 */}
+
+
+ 场景类型
+
+ {getSceneBadge(txn.txnScene, txn.dcFlag)}
+
+
+ {/* PC 端操作按钮 */}
+
+ {actionMenu}
+
+
+ )
+ })}
+
+
+
+ ))}
+
+ ) : (
+ /* 空状态展示 */
+
+
+
+
+
+ {hasFilters ? "未找到符合条件的流水" : "暂无已入账的交易流水"}
+
+
+ {hasFilters
+ ? "调整关键词或重置筛选条件后再试。"
+ : "可在「流水记账」中快速录入并确认清洗入账,入账后的总账流水将完整汇总于此。"}
+
+
+ {hasFilters ? (
+
+ ) : (
+
} size="sm">
+
+ 前往记账
+
+ )}
+
+
+ )}
+
+ {/* 分录明细弹窗 */}
+
+
+ {/* 删除确认弹窗 */}
+
+
+
+ )
+}
diff --git a/components/app-header.tsx b/components/app-header.tsx
index 2b555a7..ea411fc 100644
--- a/components/app-header.tsx
+++ b/components/app-header.tsx
@@ -7,9 +7,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
-import {
- SidebarTrigger,
-} from "@/components/ui/sidebar"
+import { SidebarTrigger } from "@/components/ui/sidebar"
import { ThemeToggle } from "@/components/theme-toggle"
export function AppHeader({ title }: { title: string }) {
@@ -17,7 +15,7 @@ export function AppHeader({ title }: { title: string }) {
return (
-
+
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index 718ecab..05de500 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -1,16 +1,18 @@
-"use client";
+"use client"
-import * as React from "react";
-import Link from "next/link";
+import * as React from "react"
+import Link from "next/link"
import {
CreditCardIcon,
LayoutDashboardIcon,
+ PenToolIcon,
+ ReceiptTextIcon,
WalletCardsIcon,
-} from "lucide-react";
+} from "lucide-react"
-import { FluxentLogo } from "@/components/fluxent-logo";
-import { NavMain, type NavMainItem } from "@/components/nav-main";
-import { NavUser, type NavUserData } from "@/components/nav-user";
+import { FluxentLogo } from "@/components/fluxent-logo"
+import { NavMain, type NavMainItem } from "@/components/nav-main"
+import { NavUser, type NavUserData } from "@/components/nav-user"
import {
Sidebar,
SidebarContent,
@@ -20,7 +22,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
-} from "@/components/ui/sidebar";
+} from "@/components/ui/sidebar"
const navMainItems: NavMainItem[] = [
{
@@ -28,6 +30,16 @@ const navMainItems: NavMainItem[] = [
url: "/",
icon: LayoutDashboardIcon,
},
+ {
+ title: "流水记账",
+ url: "/bookkeeping",
+ icon: PenToolIcon,
+ },
+ {
+ title: "交易记录",
+ url: "/transactions",
+ icon: ReceiptTextIcon,
+ },
{
title: "资金账户",
url: "/accounts",
@@ -38,21 +50,21 @@ const navMainItems: NavMainItem[] = [
url: "/channels",
icon: CreditCardIcon,
},
-];
+]
export function AppSidebar({
user,
...props
}: {
- user: NavUserData;
+ user: NavUserData
} & React.ComponentProps) {
- const { isMobile, setOpenMobile } = useSidebar();
+ const { isMobile, setOpenMobile } = useSidebar()
const handleLogoNavigation = () => {
if (isMobile) {
- setOpenMobile(false);
+ setOpenMobile(false)
}
- };
+ }
return (
@@ -89,5 +101,5 @@ export function AppSidebar({
- );
+ )
}
diff --git a/components/fluxent-logo.tsx b/components/fluxent-logo.tsx
index b05a13d..4d6e4be 100644
--- a/components/fluxent-logo.tsx
+++ b/components/fluxent-logo.tsx
@@ -1,12 +1,12 @@
-import React from "react";
-import { cn } from "@/lib/utils";
+import React from "react"
+import { cn } from "@/lib/utils"
export function FluxentLogo({
className,
size = 36,
}: {
- className?: string;
- size?: number;
+ className?: string
+ size?: number
}) {
return (
- );
+ )
}
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
index c1c4d77..1c32893 100644
--- a/components/nav-main.tsx
+++ b/components/nav-main.tsx
@@ -1,8 +1,8 @@
-"use client";
+"use client"
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { type LucideIcon } from "lucide-react";
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { type LucideIcon } from "lucide-react"
import {
SidebarGroup,
@@ -11,24 +11,24 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
-} from "@/components/ui/sidebar";
+} from "@/components/ui/sidebar"
export interface NavMainItem {
- title: string;
- url: string;
- icon: LucideIcon;
- badge?: string;
+ title: string
+ url: string
+ icon: LucideIcon
+ badge?: string
}
export function NavMain({ items }: { items: NavMainItem[] }) {
- const pathname = usePathname();
- const { isMobile, setOpenMobile } = useSidebar();
+ const pathname = usePathname()
+ const { isMobile, setOpenMobile } = useSidebar()
const handleNavigation = () => {
if (isMobile) {
- setOpenMobile(false);
+ setOpenMobile(false)
}
- };
+ }
return (
@@ -37,14 +37,15 @@ export function NavMain({ items }: { items: NavMainItem[] }) {
{items.map((item) => {
- const Icon = item.icon;
+ const Icon = item.icon
return (
}
@@ -58,9 +59,9 @@ export function NavMain({ items }: { items: NavMainItem[] }) {
)}
- );
+ )
})}
- );
+ )
}
diff --git a/components/nav-secondary.tsx b/components/nav-secondary.tsx
index 61f2e03..0b61582 100644
--- a/components/nav-secondary.tsx
+++ b/components/nav-secondary.tsx
@@ -1,8 +1,8 @@
-"use client";
+"use client"
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { type LucideIcon } from "lucide-react";
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { type LucideIcon } from "lucide-react"
import {
SidebarGroup,
@@ -11,13 +11,13 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
-} from "@/components/ui/sidebar";
+} from "@/components/ui/sidebar"
export interface NavSecondaryItem {
- title: string;
- url: string;
- icon: LucideIcon;
- badge?: React.ReactNode;
+ title: string
+ url: string
+ icon: LucideIcon
+ badge?: React.ReactNode
}
export function NavSecondary({
@@ -25,23 +25,23 @@ export function NavSecondary({
className,
...props
}: {
- items: NavSecondaryItem[];
+ items: NavSecondaryItem[]
} & React.ComponentProps
) {
- const pathname = usePathname();
- const { isMobile, setOpenMobile } = useSidebar();
+ const pathname = usePathname()
+ const { isMobile, setOpenMobile } = useSidebar()
const handleNavigation = () => {
if (isMobile) {
- setOpenMobile(false);
+ setOpenMobile(false)
}
- };
+ }
return (
{items.map((item) => {
- const Icon = item.icon;
+ const Icon = item.icon
return (
{item.title}
- );
+ )
})}
- );
+ )
}
diff --git a/components/nav-user.tsx b/components/nav-user.tsx
index 65ef301..4cea5de 100644
--- a/components/nav-user.tsx
+++ b/components/nav-user.tsx
@@ -1,4 +1,4 @@
-"use client";
+"use client"
import {
ChevronsUpDownIcon,
@@ -6,14 +6,10 @@ import {
Settings2Icon,
SparklesIcon,
UserCogIcon,
-} from "lucide-react";
-import { signOut } from "next-auth/react";
+} from "lucide-react"
+import { signOut } from "next-auth/react"
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from "@/components/ui/avatar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,29 +18,27 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
-} from "@/components/ui/sidebar";
+} from "@/components/ui/sidebar"
export interface NavUserData {
- name: string;
- email: string;
- avatar?: string | null;
+ name: string
+ email: string
+ avatar?: string | null
}
export function NavUser({ user }: { user: NavUserData }) {
- const { isMobile } = useSidebar();
- const initials = (user.name || user.email || "U")
- .slice(0, 2)
- .toUpperCase();
+ const { isMobile } = useSidebar()
+ const initials = (user.name || user.email || "U").slice(0, 2).toUpperCase()
const handleSignOut = () => {
- signOut({ callbackUrl: "/login" });
- };
+ signOut({ callbackUrl: "/login" })
+ }
return (
@@ -62,7 +56,7 @@ export function NavUser({ user }: { user: NavUserData }) {
{user.avatar ? (
) : null}
-
+
{initials}
@@ -89,7 +83,7 @@ export function NavUser({ user }: { user: NavUserData }) {
{user.avatar ? (
) : null}
-
+
{initials}
@@ -140,5 +134,5 @@ export function NavUser({ user }: { user: NavUserData }) {
- );
+ )
}
diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx
index dcfa1d8..378f38b 100644
--- a/components/theme-toggle.tsx
+++ b/components/theme-toggle.tsx
@@ -1,17 +1,17 @@
-"use client";
+"use client"
-import * as React from "react";
-import { useTheme } from "next-themes";
-import { MoonIcon, SunIcon } from "lucide-react";
-import { Button } from "@/components/ui/button";
+import * as React from "react"
+import { useTheme } from "next-themes"
+import { MoonIcon, SunIcon } from "lucide-react"
+import { Button } from "@/components/ui/button"
export function ThemeToggle() {
- const { resolvedTheme, setTheme } = useTheme();
+ const { resolvedTheme, setTheme } = useTheme()
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false
- );
+ )
if (!mounted) {
return (
@@ -23,10 +23,10 @@ export function ThemeToggle() {
>
- );
+ )
}
- const isDark = resolvedTheme === "dark";
+ const isDark = resolvedTheme === "dark"
return (
- );
+ )
}
-
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
index b678e78..d685419 100644
--- a/components/ui/breadcrumb.tsx
+++ b/components/ui/breadcrumb.tsx
@@ -84,9 +84,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
- {children ?? (
-
- )}
+ {children ?? }
)
}
@@ -106,8 +104,7 @@ function BreadcrumbEllipsis({
)}
{...props}
>
-
+
More
)
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
index de682d2..53e78a3 100644
--- a/components/ui/checkbox.tsx
+++ b/components/ui/checkbox.tsx
@@ -1,9 +1,9 @@
-"use client";
+"use client"
-import * as React from "react";
-import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
-import { CheckIcon } from "lucide-react";
-import { cn } from "@/lib/utils";
+import * as React from "react"
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+import { CheckIcon } from "lucide-react"
+import { cn } from "@/lib/utils"
function Checkbox({
className,
@@ -13,7 +13,7 @@ function Checkbox({
- );
+ )
}
-export { Checkbox };
+export { Checkbox }
diff --git a/components/ui/combobox.tsx b/components/ui/combobox.tsx
index d4ca41b..68968fe 100644
--- a/components/ui/combobox.tsx
+++ b/components/ui/combobox.tsx
@@ -110,7 +110,10 @@ function ComboboxContent({
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
index 5cadf3f..59a48ab 100644
--- a/components/ui/dialog.tsx
+++ b/components/ui/dialog.tsx
@@ -70,8 +70,7 @@ function DialogContent({
/>
}
>
-
+
Close
)}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
index cafe6ba..68aae85 100644
--- a/components/ui/dropdown-menu.tsx
+++ b/components/ui/dropdown-menu.tsx
@@ -40,7 +40,10 @@ function DropdownMenuContent({
>
@@ -134,7 +137,10 @@ function DropdownMenuSubContent({
return (
-
+
{children}
@@ -210,8 +215,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
-
+
{children}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
index ca1ee68..c9028a7 100644
--- a/components/ui/select.tsx
+++ b/components/ui/select.tsx
@@ -82,7 +82,10 @@ function SelectContent({
@@ -161,8 +164,7 @@ function SelectScrollUpButton({
)}
{...props}
>
-
+
)
}
@@ -180,8 +182,7 @@ function SelectScrollDownButton({
)}
{...props}
>
-
+
)
}
diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx
index 57c0b67..f8255d1 100644
--- a/components/ui/sheet.tsx
+++ b/components/ui/sheet.tsx
@@ -70,8 +70,7 @@ function SheetContent({
/>
}
>
-
+
Close
)}
diff --git a/drizzle.config.ts b/drizzle.config.ts
index 81c560e..9212ad4 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -1,7 +1,7 @@
-import { defineConfig } from "drizzle-kit";
-import * as dotenv from "dotenv";
+import { defineConfig } from "drizzle-kit"
+import * as dotenv from "dotenv"
-dotenv.config({ path: ".env.local" });
+dotenv.config({ path: ".env.local" })
export default defineConfig({
schema: "./lib/db/schema.ts",
@@ -12,4 +12,4 @@ export default defineConfig({
},
strict: true,
verbose: true,
-});
+})
diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts
index 0d4ceb9..534b6d4 100644
--- a/hooks/use-mobile.ts
+++ b/hooks/use-mobile.ts
@@ -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() {
return React.useSyncExternalStore(
(callback) => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
- mql.addEventListener("change", callback);
- return () => mql.removeEventListener("change", callback);
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ mql.addEventListener("change", callback)
+ return () => mql.removeEventListener("change", callback)
},
() => window.innerWidth < MOBILE_BREAKPOINT,
() => false
- );
+ )
}
diff --git a/lib/actions/account.ts b/lib/actions/account.ts
index e828e88..533acac 100644
--- a/lib/actions/account.ts
+++ b/lib/actions/account.ts
@@ -1,18 +1,18 @@
-"use server";
+"use server"
-import { z } from "zod";
-import { and, desc, eq, isNull } from "drizzle-orm";
-import { revalidatePath } from "next/cache";
-import { auth } from "@/lib/auth";
-import { db } from "@/lib/db";
-import { accounts, channels, type Account } from "@/lib/db/schema";
+import { z } from "zod"
+import { and, desc, eq, isNull } from "drizzle-orm"
+import { revalidatePath } from "next/cache"
+import { auth } from "@/lib/auth"
+import { db } from "@/lib/db"
+import { accounts, channels, type Account } from "@/lib/db/schema"
async function requireUser() {
- const session = await auth();
+ const session = await auth()
if (!session?.user?.id) {
- throw new Error("请先登录");
+ throw new Error("请先登录")
}
- return session.user.id;
+ return session.user.id
}
const accountSchema = z.object({
@@ -34,47 +34,50 @@ const accountSchema = z.object({
// 现金账户字段
location: z.string().max(150).optional().nullable(),
-});
+})
-export type AccountInput = z.infer;
+export type AccountInput = z.infer
export type AccountWithChannels = Account & {
- channelCount: number;
-};
+ channelCount: number
+}
export async function getAccountsAction(): Promise<{
- success: boolean;
- data?: AccountWithChannels[];
- error?: string;
+ success: boolean
+ data?: AccountWithChannels[]
+ error?: string
}> {
try {
- const userId = await requireUser();
+ const userId = await requireUser()
const userAccounts = await db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
- .orderBy(desc(accounts.createdAt));
+ .orderBy(desc(accounts.createdAt))
const userChannels = await db
.select({ id: channels.id, refAccounts: channels.refAccounts })
.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 count = userChannels.filter((ch) =>
- Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
- ).length;
+ const count = userChannels.filter(
+ (ch) => Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
+ ).length
return {
...acc,
channelCount: count,
- };
- });
+ }
+ })
- return { success: true, data: result };
+ return { success: true, data: result }
} catch (err) {
- console.error("getAccountsAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "获取账户失败" };
+ console.error("getAccountsAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "获取账户失败",
+ }
}
}
@@ -82,13 +85,16 @@ export async function createAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
- const userId = await requireUser();
- const parsed = accountSchema.safeParse(data);
+ const userId = await requireUser()
+ const parsed = accountSchema.safeParse(data)
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
.insert(accounts)
.values({
@@ -96,8 +102,13 @@ export async function createAccountAction(
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
- primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
- supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
+ primaryCurrency: val.primaryCurrency
+ ? val.primaryCurrency.toUpperCase()
+ : null,
+ supportedCurrencies:
+ val.supportedCurrencies && val.supportedCurrencies.length > 0
+ ? val.supportedCurrencies
+ : null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
@@ -107,15 +118,18 @@ export async function createAccountAction(
location: val.location?.trim() || null,
isActive: true,
})
- .returning();
+ .returning()
- revalidatePath("/accounts");
- revalidatePath("/channels");
- revalidatePath("/");
- return { success: true, data: newAccount };
+ revalidatePath("/accounts")
+ revalidatePath("/channels")
+ revalidatePath("/")
+ return { success: true, data: newAccount }
} catch (err) {
- console.error("createAccountAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "创建账户失败" };
+ console.error("createAccountAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "创建账户失败",
+ }
}
}
@@ -124,21 +138,29 @@ export async function updateAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
- const userId = await requireUser();
- const parsed = accountSchema.safeParse(data);
+ const userId = await requireUser()
+ const parsed = accountSchema.safeParse(data)
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
.update(accounts)
.set({
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
- primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
- supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
+ primaryCurrency: val.primaryCurrency
+ ? val.primaryCurrency.toUpperCase()
+ : null,
+ supportedCurrencies:
+ val.supportedCurrencies && val.supportedCurrencies.length > 0
+ ? val.supportedCurrencies
+ : null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
@@ -148,20 +170,29 @@ export async function updateAccountAction(
location: val.location?.trim() || null,
updatedAt: new Date(),
})
- .where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
- .returning();
+ .where(
+ and(
+ eq(accounts.id, id),
+ eq(accounts.userId, userId),
+ isNull(accounts.deletedAt)
+ )
+ )
+ .returning()
if (!updated) {
- return { success: false, error: "未找到该账户或无权修改" };
+ return { success: false, error: "未找到该账户或无权修改" }
}
- revalidatePath("/accounts");
- revalidatePath("/channels");
- revalidatePath("/");
- return { success: true, data: updated };
+ revalidatePath("/accounts")
+ revalidatePath("/channels")
+ revalidatePath("/")
+ return { success: true, data: updated }
} catch (err) {
- console.error("updateAccountAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "更新账户失败" };
+ console.error("updateAccountAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "更新账户失败",
+ }
}
}
@@ -170,20 +201,29 @@ export async function toggleAccountActiveAction(
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
- const userId = await requireUser();
+ const userId = await requireUser()
await db
.update(accounts)
.set({
isActive,
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");
- return { success: true };
+ revalidatePath("/accounts")
+ return { success: true }
} catch (err) {
- console.error("toggleAccountActiveAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
+ console.error("toggleAccountActiveAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "状态切换失败",
+ }
}
}
@@ -191,7 +231,7 @@ export async function deleteAccountAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
- const userId = await requireUser();
+ const userId = await requireUser()
// 软删除
const [deleted] = await db
.update(accounts)
@@ -199,19 +239,28 @@ export async function deleteAccountAction(
deletedAt: new Date(),
updatedAt: new Date(),
})
- .where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
- .returning();
+ .where(
+ and(
+ eq(accounts.id, id),
+ eq(accounts.userId, userId),
+ isNull(accounts.deletedAt)
+ )
+ )
+ .returning()
if (!deleted) {
- return { success: false, error: "账户不存在或已删除" };
+ return { success: false, error: "账户不存在或已删除" }
}
- revalidatePath("/accounts");
- revalidatePath("/channels");
- revalidatePath("/");
- return { success: true };
+ revalidatePath("/accounts")
+ revalidatePath("/channels")
+ revalidatePath("/")
+ return { success: true }
} catch (err) {
- console.error("deleteAccountAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "删除账户失败" };
+ console.error("deleteAccountAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "删除账户失败",
+ }
}
}
diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts
index c867e52..4934356 100644
--- a/lib/actions/auth.ts
+++ b/lib/actions/auth.ts
@@ -1,10 +1,10 @@
-"use server";
+"use server"
-import { z } from "zod";
-import { eq } from "drizzle-orm";
-import { db } from "@/lib/db";
-import { users } from "@/lib/db/schema";
-import { hashPassword } from "@/lib/auth/password";
+import { z } from "zod"
+import { eq } from "drizzle-orm"
+import { db } from "@/lib/db"
+import { users } from "@/lib/db/schema"
+import { hashPassword } from "@/lib/auth/password"
const registerSchema = z
.object({
@@ -13,16 +13,20 @@ const registerSchema = z
password: z.string().min(8, "密码长度至少需要 8 个字符"),
confirmPassword: z.string().min(1, "请确认密码"),
})
- .refine((data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword, {
- message: "两次输入的密码不一致",
- path: ["confirmPassword"],
- });
+ .refine(
+ (data: { password: string; confirmPassword: string }) =>
+ data.password === data.confirmPassword,
+ {
+ message: "两次输入的密码不一致",
+ path: ["confirmPassword"],
+ }
+ )
export type RegisterState = {
- success?: boolean;
- error?: string;
- fieldErrors?: Record;
-};
+ success?: boolean
+ error?: string
+ fieldErrors?: Record
+}
export async function registerAction(
prevState: RegisterState | null,
@@ -33,18 +37,18 @@ export async function registerAction(
email: formData.get("email"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
- };
+ }
- const parsed = registerSchema.safeParse(rawData);
+ const parsed = registerSchema.safeParse(rawData)
if (!parsed.success) {
return {
success: false,
fieldErrors: parsed.error.flatten().fieldErrors,
- };
+ }
}
- const { name, email, password } = parsed.data;
- const normalizedEmail = email.toLowerCase().trim();
+ const { name, email, password } = parsed.data
+ const normalizedEmail = email.toLowerCase().trim()
try {
// 检查邮箱是否已被注册
@@ -52,17 +56,17 @@ export async function registerAction(
.select()
.from(users)
.where(eq(users.email, normalizedEmail))
- .limit(1);
+ .limit(1)
if (existing) {
return {
success: false,
error: "该邮箱已被注册,请直接登录",
- };
+ }
}
// 使用 Argon2 哈希密码
- const passwordHash = await hashPassword(password);
+ const passwordHash = await hashPassword(password)
// 插入新用户
await db.insert(users).values({
@@ -70,16 +74,16 @@ export async function registerAction(
email: normalizedEmail,
passwordHash,
isActive: true,
- });
+ })
return {
success: true,
- };
+ }
} catch (err) {
- console.error("Registration error:", err);
+ console.error("Registration error:", err)
return {
success: false,
error: "注册失败,请稍后重试",
- };
+ }
}
}
diff --git a/lib/actions/bookkeeping.ts b/lib/actions/bookkeeping.ts
new file mode 100644
index 0000000..6e39f56
--- /dev/null
+++ b/lib/actions/bookkeeping.ts
@@ -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
+
+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 | 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 : "清洗合并事务失败",
+ }
+ }
+}
diff --git a/lib/actions/channel.ts b/lib/actions/channel.ts
index 253395c..5d23661 100644
--- a/lib/actions/channel.ts
+++ b/lib/actions/channel.ts
@@ -1,34 +1,40 @@
-"use server";
+"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 {
- accounts,
- channels,
- type Channel,
-} from "@/lib/db/schema";
+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 { accounts, channels, type Channel } from "@/lib/db/schema"
async function requireUser() {
- const session = await auth();
+ const session = await auth()
if (!session?.user?.id) {
- throw new Error("请先登录");
+ throw new Error("请先登录")
}
- return session.user.id;
+ return session.user.id
}
const channelSchema = z.object({
- channelType: z.enum(["PAYMENT_CARD", "E_WALLET", "CASH", "TRANSFER"] as const),
- refAccounts: z.array(z.string().uuid("无效的账户标识")).min(1, "请至少关联一个资金账户"),
+ channelType: z.enum([
+ "PAYMENT_CARD",
+ "E_WALLET",
+ "CASH",
+ "TRANSFER",
+ ] as const),
+ refAccounts: z
+ .array(z.string().uuid("无效的账户标识"))
+ .min(1, "请至少关联一个资金账户"),
desc: z.string().max(500).optional().nullable(),
ext: z.string().optional().nullable(),
// 支付卡特定字段
region: z.string().max(10).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(),
cardNumberSuffix: z.string().max(10).optional().nullable(),
cardBrand: z.string().max(32).optional().nullable(),
@@ -37,56 +43,62 @@ const channelSchema = z.object({
platform: z.string().max(64).optional().nullable(),
platformAccountId: z.string().max(100).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;
+export type ChannelInput = z.infer
export type ChannelWithAccountNames = Channel & {
- linkedAccounts: { id: string; name: string }[];
-};
+ linkedAccounts: { id: string; name: string }[]
+}
export async function getChannelsAction(): Promise<{
- success: boolean;
- data?: ChannelWithAccountNames[];
- error?: string;
+ success: boolean
+ data?: ChannelWithAccountNames[]
+ error?: string
}> {
try {
- const userId = await requireUser();
+ const userId = await requireUser()
const userChannels = await db
.select()
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
- .orderBy(desc(channels.createdAt));
+ .orderBy(desc(channels.createdAt))
const userAccounts = await db
.select({ id: accounts.id, name: accounts.name })
.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 linked: { id: string; name: string }[] = [];
+ const linked: { id: string; name: string }[] = []
if (Array.isArray(ch.refAccounts)) {
for (const accId of ch.refAccounts) {
- const name = accountMap.get(accId);
+ const name = accountMap.get(accId)
if (name) {
- linked.push({ id: accId, name });
+ linked.push({ id: accId, name })
}
}
}
return {
...ch,
linkedAccounts: linked,
- };
- });
+ }
+ })
- return { success: true, data: result };
+ return { success: true, data: result }
} catch (err) {
- console.error("getChannelsAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "获取渠道失败" };
+ console.error("getChannelsAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "获取渠道失败",
+ }
}
}
@@ -94,13 +106,16 @@ export async function createChannelAction(
data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> {
try {
- const userId = await requireUser();
- const parsed = channelSchema.safeParse(data);
+ const userId = await requireUser()
+ const parsed = channelSchema.safeParse(data)
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
@@ -112,16 +127,16 @@ export async function createChannelAction(
inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt)
)
- );
+ )
if (userOwnedAccounts.length !== val.refAccounts.length) {
- return { success: false, error: "关联的部分账户不存在或已被删除" };
+ return { success: false, error: "关联的部分账户不存在或已被删除" }
}
// 自动提取或补全卡号后4位
- let suffix = val.cardNumberSuffix?.trim() || null;
+ let suffix = val.cardNumberSuffix?.trim() || null
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
- suffix = val.cardNumberFull.slice(-4);
+ suffix = val.cardNumberFull.slice(-4)
}
const [newChannel] = await db
@@ -144,15 +159,18 @@ export async function createChannelAction(
subChannel: val.subChannel?.trim() || null,
subChannelType: val.subChannelType || null,
})
- .returning();
+ .returning()
- revalidatePath("/channels");
- revalidatePath("/accounts");
- revalidatePath("/");
- return { success: true, data: newChannel };
+ revalidatePath("/channels")
+ revalidatePath("/accounts")
+ revalidatePath("/")
+ return { success: true, data: newChannel }
} catch (err) {
- console.error("createChannelAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "创建渠道失败" };
+ console.error("createChannelAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "创建渠道失败",
+ }
}
}
@@ -161,13 +179,16 @@ export async function updateChannelAction(
data: ChannelInput
): Promise<{ success: boolean; data?: Channel; error?: string }> {
try {
- const userId = await requireUser();
- const parsed = channelSchema.safeParse(data);
+ const userId = await requireUser()
+ const parsed = channelSchema.safeParse(data)
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
.select({ id: accounts.id })
@@ -178,15 +199,15 @@ export async function updateChannelAction(
inArray(accounts.id, val.refAccounts),
isNull(accounts.deletedAt)
)
- );
+ )
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) {
- suffix = val.cardNumberFull.slice(-4);
+ suffix = val.cardNumberFull.slice(-4)
}
const [updated] = await db
@@ -208,20 +229,29 @@ export async function updateChannelAction(
subChannelType: val.subChannelType || null,
updatedAt: new Date(),
})
- .where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
- .returning();
+ .where(
+ and(
+ eq(channels.id, id),
+ eq(channels.userId, userId),
+ isNull(channels.deletedAt)
+ )
+ )
+ .returning()
if (!updated) {
- return { success: false, error: "渠道不存在或无权修改" };
+ return { success: false, error: "渠道不存在或无权修改" }
}
- revalidatePath("/channels");
- revalidatePath("/accounts");
- revalidatePath("/");
- return { success: true, data: updated };
+ revalidatePath("/channels")
+ revalidatePath("/accounts")
+ revalidatePath("/")
+ return { success: true, data: updated }
} catch (err) {
- console.error("updateChannelAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "更新渠道失败" };
+ console.error("updateChannelAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "更新渠道失败",
+ }
}
}
@@ -230,22 +260,31 @@ export async function toggleChannelActiveAction(
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
- const userId = await requireUser();
+ const userId = await requireUser()
await db
.update(channels)
.set({
isActive,
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("/accounts");
- revalidatePath("/");
- return { success: true };
+ revalidatePath("/channels")
+ revalidatePath("/accounts")
+ revalidatePath("/")
+ return { success: true }
} catch (err) {
- console.error("toggleChannelActiveAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
+ console.error("toggleChannelActiveAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "状态切换失败",
+ }
}
}
@@ -253,26 +292,35 @@ export async function deleteChannelAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
- const userId = await requireUser();
+ const userId = await requireUser()
const [deleted] = await db
.update(channels)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
- .where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
- .returning();
+ .where(
+ and(
+ eq(channels.id, id),
+ eq(channels.userId, userId),
+ isNull(channels.deletedAt)
+ )
+ )
+ .returning()
if (!deleted) {
- return { success: false, error: "渠道不存在或已删除" };
+ return { success: false, error: "渠道不存在或已删除" }
}
- revalidatePath("/channels");
- revalidatePath("/accounts");
- revalidatePath("/");
- return { success: true };
+ revalidatePath("/channels")
+ revalidatePath("/accounts")
+ revalidatePath("/")
+ return { success: true }
} catch (err) {
- console.error("deleteChannelAction error:", err);
- return { success: false, error: err instanceof Error ? err.message : "删除渠道失败" };
+ console.error("deleteChannelAction error:", err)
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : "删除渠道失败",
+ }
}
}
diff --git a/lib/actions/transaction.ts b/lib/actions/transaction.ts
new file mode 100644
index 0000000..3e844f2
--- /dev/null
+++ b/lib/actions/transaction.ts
@@ -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 : "删除交易失败",
+ }
+ }
+}
diff --git a/lib/auth/config.ts b/lib/auth/config.ts
index 0fa0280..b5984d3 100644
--- a/lib/auth/config.ts
+++ b/lib/auth/config.ts
@@ -1,4 +1,4 @@
-import type { NextAuthConfig } from "next-auth";
+import type { NextAuthConfig } from "next-auth"
export const authConfig = {
pages: {
@@ -7,37 +7,37 @@ export const authConfig = {
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
- const isLoggedIn = !!auth?.user;
- const pathname = nextUrl.pathname;
- const publicPaths = ["/login", "/register", "/api/auth"];
+ const isLoggedIn = !!auth?.user
+ const pathname = nextUrl.pathname
+ const publicPaths = ["/login", "/register", "/api/auth"]
const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith("/api/auth/")
- );
+ )
// 已登录用户在登录/注册页时重定向到首页
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
- return Response.redirect(new URL("/", nextUrl));
+ return Response.redirect(new URL("/", nextUrl))
}
// 访问受保护页面必须已登录
if (!isLoggedIn && !isPublic) {
- return false;
+ return false
}
- return true;
+ return true
},
jwt({ token, user }) {
if (user?.id) {
- token.id = user.id;
+ token.id = user.id
}
- return token;
+ return token
},
session({ session, token }) {
if (session.user && token.id) {
- session.user.id = token.id as string;
+ session.user.id = token.id as string
}
- return session;
+ return session
},
},
providers: [],
-} satisfies NextAuthConfig;
+} satisfies NextAuthConfig
diff --git a/lib/auth/index.ts b/lib/auth/index.ts
index e47aa4e..981b9be 100644
--- a/lib/auth/index.ts
+++ b/lib/auth/index.ts
@@ -1,11 +1,11 @@
-import NextAuth from "next-auth";
-import Credentials from "next-auth/providers/credentials";
-import { eq } from "drizzle-orm";
-import { db } from "@/lib/db";
-import { users, userAccounts } from "@/lib/db/schema";
-import { verifyPassword } from "./password";
-import { authConfig } from "./config";
-import type { Provider } from "next-auth/providers";
+import NextAuth from "next-auth"
+import Credentials from "next-auth/providers/credentials"
+import { eq } from "drizzle-orm"
+import { db } from "@/lib/db"
+import { users, userAccounts } from "@/lib/db/schema"
+import { verifyPassword } from "./password"
+import { authConfig } from "./config"
+import type { Provider } from "next-auth/providers"
// 动态构建 Providers 列表
const providers: Provider[] = [
@@ -17,25 +17,25 @@ const providers: Provider[] = [
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
- return null;
+ return null
}
- const email = String(credentials.email).toLowerCase().trim();
- const password = String(credentials.password);
+ const email = String(credentials.email).toLowerCase().trim()
+ const password = String(credentials.password)
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
- .limit(1);
+ .limit(1)
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) {
- return null;
+ return null
}
return {
@@ -43,16 +43,16 @@ const providers: Provider[] = [
name: user.name,
email: user.email,
image: user.avatar,
- };
+ }
},
}),
-];
+]
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
const isOidcEnabled =
process.env.AUTH_OIDC_ENABLED === "true" &&
Boolean(process.env.AUTH_OIDC_ISSUER) &&
- Boolean(process.env.AUTH_OIDC_CLIENT_ID);
+ Boolean(process.env.AUTH_OIDC_CLIENT_ID)
if (isOidcEnabled) {
providers.push({
@@ -72,7 +72,7 @@ if (isOidcEnabled) {
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
},
},
- });
+ })
}
export const { handlers, signIn, signOut, auth } = NextAuth({
@@ -86,13 +86,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig.callbacks,
async signIn({ user, account }) {
if (!account || account.type === "credentials") {
- return true;
+ return true
}
// 处理 OIDC / OAuth 登录与本地用户的关联或新建
- const email = user.email?.toLowerCase().trim();
+ const email = user.email?.toLowerCase().trim()
if (!email) {
- return false;
+ return false
}
// 1. 查询用户是否已存在
@@ -100,7 +100,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
.select()
.from(users)
.where(eq(users.email, email))
- .limit(1);
+ .limit(1)
if (!existingUser) {
// 创建新用户
@@ -112,20 +112,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
avatar: user.image || null,
isActive: true,
})
- .returning();
- existingUser = newUser;
+ .returning()
+ existingUser = newUser
}
- user.id = existingUser.id;
+ user.id = existingUser.id
// 2. 查询是否已记录该 provider 的账户绑定
const [existingAccount] = await db
.select()
.from(userAccounts)
- .where(
- eq(userAccounts.providerAccountId, account.providerAccountId)
- )
- .limit(1);
+ .where(eq(userAccounts.providerAccountId, account.providerAccountId))
+ .limit(1)
if (!existingAccount) {
await db.insert(userAccounts).values({
@@ -134,14 +132,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providerAccountId: account.providerAccountId,
refreshToken: account.refresh_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,
scope: account.scope,
idToken: account.id_token,
- });
+ })
}
- return true;
+ return true
},
},
-});
+})
diff --git a/lib/auth/password.ts b/lib/auth/password.ts
index 5b5241c..0ea9629 100644
--- a/lib/auth/password.ts
+++ b/lib/auth/password.ts
@@ -1,4 +1,4 @@
-import { hash, verify } from "@node-rs/argon2";
+import { hash, verify } from "@node-rs/argon2"
// 遵循 OWASP 密码哈希安全推荐配置
const ARGON2_OPTIONS = {
@@ -6,16 +6,19 @@ const ARGON2_OPTIONS = {
timeCost: 2,
outputLen: 32,
parallelism: 1,
-};
+}
export async function hashPassword(password: string): Promise {
- return await hash(password, ARGON2_OPTIONS);
+ return await hash(password, ARGON2_OPTIONS)
}
-export async function verifyPassword(password: string, passwordHash: string): Promise {
+export async function verifyPassword(
+ password: string,
+ passwordHash: string
+): Promise {
try {
- return await verify(passwordHash, password);
+ return await verify(passwordHash, password)
} catch {
- return false;
+ return false
}
}
diff --git a/lib/db/index.ts b/lib/db/index.ts
index 2e4a8ea..8a047aa 100644
--- a/lib/db/index.ts
+++ b/lib/db/index.ts
@@ -1,15 +1,17 @@
-import { drizzle } from "drizzle-orm/postgres-js";
-import postgres from "postgres";
-import * as schema from "./schema";
+import { drizzle } from "drizzle-orm/postgres-js"
+import postgres from "postgres"
+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 开发环境下避免热重载创建重复连接池
const globalForDb = globalThis as unknown as {
- conn: postgres.Sql | undefined;
-};
+ conn: postgres.Sql | undefined
+}
-const client = globalForDb.conn ?? postgres(connectionString, { max: 10 });
-if (process.env.NODE_ENV !== "production") globalForDb.conn = client;
+const client = globalForDb.conn ?? postgres(connectionString, { max: 10 })
+if (process.env.NODE_ENV !== "production") globalForDb.conn = client
-export const db = drizzle(client, { schema });
+export const db = drizzle(client, { schema })
diff --git a/lib/db/normalize-card-brands.ts b/lib/db/normalize-card-brands.ts
index ec256d7..ebe75da 100644
--- a/lib/db/normalize-card-brands.ts
+++ b/lib/db/normalize-card-brands.ts
@@ -41,7 +41,10 @@ async function main() {
`
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()
diff --git a/lib/db/reset.ts b/lib/db/reset.ts
index e5248d2..7416e44 100644
--- a/lib/db/reset.ts
+++ b/lib/db/reset.ts
@@ -1,22 +1,22 @@
-import postgres from "postgres";
-import * as dotenv from "dotenv";
+import postgres from "postgres"
+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() {
- console.log("Dropping old tables...");
- await sql`DROP TABLE IF EXISTS transactions CASCADE`;
- await sql`DROP TABLE IF EXISTS channels CASCADE`;
- await sql`DROP TABLE IF EXISTS accounts CASCADE`;
- await sql`DROP TABLE IF EXISTS user_accounts CASCADE`;
- await sql`DROP TABLE IF EXISTS users CASCADE`;
- console.log("Old tables dropped.");
- await sql.end();
+ console.log("Dropping old tables...")
+ await sql`DROP TABLE IF EXISTS transactions CASCADE`
+ await sql`DROP TABLE IF EXISTS channels CASCADE`
+ await sql`DROP TABLE IF EXISTS accounts CASCADE`
+ await sql`DROP TABLE IF EXISTS user_accounts CASCADE`
+ await sql`DROP TABLE IF EXISTS users CASCADE`
+ console.log("Old tables dropped.")
+ await sql.end()
}
resetAndInit().catch((err) => {
- console.error(err);
- process.exit(1);
-});
+ console.error(err)
+ process.exit(1)
+})
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 90d53d5..84d008a 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -8,8 +8,8 @@ import {
jsonb,
uniqueIndex,
index,
-} from "drizzle-orm/pg-core";
-import { sql } from "drizzle-orm";
+} from "drizzle-orm/pg-core"
+import { sql } from "drizzle-orm"
// ---------------------------------------------------------------------------
// 基础时间戳辅助
@@ -22,7 +22,7 @@ export const timestamps = {
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
-};
+}
// ---------------------------------------------------------------------------
// 1. 用户与认证表
@@ -42,13 +42,11 @@ export const users = pgTable(
isActive: boolean("is_active").default(true).notNull(),
...timestamps,
},
- (t) => [
- uniqueIndex("users_email_unique").on(t.email),
- ]
-);
+ (t) => [uniqueIndex("users_email_unique").on(t.email)]
+)
-export type User = typeof users.$inferSelect;
-export type NewUser = typeof users.$inferInsert;
+export type User = typeof users.$inferSelect
+export type NewUser = typeof users.$inferInsert
/**
* 用户认证授权表 (支持 OIDC / OAuth 账号关联)
@@ -61,7 +59,9 @@ export const userAccounts = pgTable(
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
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"),
accessToken: text("access_token"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
@@ -77,14 +77,14 @@ export const userAccounts = pgTable(
),
index("user_accounts_user_id_idx").on(t.userId),
]
-);
+)
// ---------------------------------------------------------------------------
// 2. 资金账户表 (Accounts)
// ---------------------------------------------------------------------------
-export type AccountType = "BANK" | "E_WALLET" | "CASH";
-export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY";
+export type AccountType = "BANK" | "E_WALLET" | "CASH"
+export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY"
export const accounts = pgTable(
"accounts",
@@ -94,7 +94,9 @@ export const accounts = pgTable(
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
name: varchar("name", { length: 150 }).notNull(),
- accountType: varchar("account_type", { length: 32 }).$type().notNull(),
+ accountType: varchar("account_type", { length: 32 })
+ .$type()
+ .notNull(),
balanceType: varchar("balance_type", { length: 32 })
.$type()
.default("ASSET")
@@ -119,20 +121,18 @@ export const accounts = pgTable(
...timestamps,
},
- (t) => [
- index("accounts_user_id_idx").on(t.userId),
- ]
-);
+ (t) => [index("accounts_user_id_idx").on(t.userId)]
+)
-export type Account = typeof accounts.$inferSelect;
-export type NewAccount = typeof accounts.$inferInsert;
+export type Account = typeof accounts.$inferSelect
+export type NewAccount = typeof accounts.$inferInsert
// ---------------------------------------------------------------------------
// 3. 渠道表 (Channels)
// ---------------------------------------------------------------------------
-export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER";
-export type PaymentInstrumentType = "CREDIT" | "DEBIT";
+export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"
+export type PaymentInstrumentType = "CREDIT" | "DEBIT"
export const channels = pgTable(
"channels",
@@ -141,7 +141,9 @@ export const channels = pgTable(
userId: uuid("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
- channelType: varchar("channel_type", { length: 32 }).$type().notNull(),
+ channelType: varchar("channel_type", { length: 32 })
+ .$type()
+ .notNull(),
refAccounts: jsonb("ref_accounts").$type().notNull(), // 关联的账户 UUID 列表
isActive: boolean("is_active").default(true).notNull(),
desc: text("desc"),
@@ -150,7 +152,9 @@ export const channels = pgTable(
// 支付卡渠道字段
region: varchar("region", { length: 10 }), // 发卡地如 HK, CN
issuerName: varchar("issuer_name", { length: 100 }),
- cardType: varchar("card_type", { length: 32 }).$type(),
+ cardType: varchar("card_type", {
+ length: 32,
+ }).$type(),
cardNumberFull: varchar("card_number_full", { length: 100 }),
cardNumberSuffix: varchar("card_number_suffix", { length: 10 }),
cardBrand: varchar("card_brand", { length: 32 }),
@@ -159,29 +163,29 @@ export const channels = pgTable(
platform: varchar("platform", { length: 64 }),
platformAccountId: varchar("platform_account_id", { length: 100 }),
subChannel: varchar("sub_channel", { length: 64 }),
- subChannelType: varchar("sub_channel_type", { length: 32 }).$type(),
+ subChannelType: varchar("sub_channel_type", {
+ length: 32,
+ }).$type(),
...timestamps,
},
- (t) => [
- index("channels_user_id_idx").on(t.userId),
- ]
-);
+ (t) => [index("channels_user_id_idx").on(t.userId)]
+)
-export type Channel = typeof channels.$inferSelect;
-export type NewChannel = typeof channels.$inferInsert;
+export type Channel = typeof channels.$inferSelect
+export type NewChannel = typeof channels.$inferInsert
// ---------------------------------------------------------------------------
// 4. 交易记账表 (Transactions)
// ---------------------------------------------------------------------------
-export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED";
-export type DcFlag = "DEBIT" | "CREDIT";
+export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED"
+export type DcFlag = "DEBIT" | "CREDIT"
export interface FxRateItem {
- fromCcy: string;
- toCcy: string;
- rate: string;
+ fromCcy: string
+ toCcy: string
+ rate: string
}
export const transactions = pgTable(
@@ -238,7 +242,75 @@ export const transactions = pgTable(
index("transactions_user_id_idx").on(t.userId),
index("transactions_txn_date_idx").on(t.txnDate),
]
-);
+)
-export type Transaction = typeof transactions.$inferSelect;
-export type NewTransaction = typeof transactions.$inferInsert;
+export type Transaction = typeof transactions.$inferSelect
+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(),
+ 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(),
+
+ dcFlag: varchar("dc_flag", { length: 16 })
+ .$type()
+ .default("DEBIT")
+ .notNull(),
+ refChannels: jsonb("ref_channels").$type().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()
+ .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
diff --git a/proxy.ts b/proxy.ts
index 3c334c4..dcc5b50 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,12 +1,12 @@
-import NextAuth from "next-auth";
-import { authConfig } from "@/lib/auth/config";
+import NextAuth from "next-auth"
+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 = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
-};
+}