feat: impl accounts and channels

This commit is contained in:
2026-09-06 18:17:42 +08:00
parent aa9999a940
commit 1ccee1414c
70 changed files with 9879 additions and 31 deletions
+316
View File
@@ -0,0 +1,316 @@
"use client"
import * as React from "react"
import { CircleAlertIcon, Loader2Icon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
createAccountAction,
updateAccountAction,
type AccountInput,
type AccountWithChannels,
} from "@/lib/actions/account"
type AccountType = "BANK" | "E_WALLET" | "CASH"
const accountLabels = {
BANK: "银行账户",
E_WALLET: "电子钱包",
CASH: "现金",
} as const
const balanceLabels = {
ASSET: "资产类",
LIABILITY: "负债类",
EQUITY: "权益类",
} as const
export function AccountDialog({
open,
onOpenChange,
account,
onSuccess,
}: {
open: boolean
onOpenChange: (open: boolean) => void
account?: AccountWithChannels | null
onSuccess: () => void
}) {
const editing = Boolean(account)
const [accountType, setAccountType] = React.useState<AccountType>(
account?.accountType || "BANK"
)
const [balanceType, setBalanceType] = React.useState<
"ASSET" | "LIABILITY" | "EQUITY"
>(account?.balanceType || "ASSET")
const [name, setName] = React.useState(account?.name || "")
const [primaryCurrency, setPrimaryCurrency] = React.useState(
account?.primaryCurrency || ""
)
const [supportedCurrencies, setSupportedCurrencies] = React.useState(
account?.supportedCurrencies?.join(", ") || ""
)
const [remark, setRemark] = React.useState(account?.remark || "")
const [issuerName, setIssuerName] = React.useState(account?.issuerName || "")
const [accountNumber, setAccountNumber] = React.useState(
account?.accountNumber || ""
)
const [platform, setPlatform] = React.useState(account?.platform || "")
const [accountId, setAccountId] = React.useState(account?.accountId || "")
const [location, setLocation] = React.useState(account?.location || "")
const [isPending, setIsPending] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setErrorMessage(null)
if (!name.trim()) {
setErrorMessage("请输入账户名称")
return
}
const currencies = supportedCurrencies
.split(/[,\s]+/)
.map((item) => item.trim().toUpperCase())
.filter(Boolean)
const payload: AccountInput = {
name: name.trim(),
accountType,
balanceType,
primaryCurrency: primaryCurrency.trim().toUpperCase() || null,
supportedCurrencies: currencies.length ? currencies : null,
remark: remark.trim() || null,
issuerName: accountType === "BANK" ? issuerName.trim() || null : null,
accountNumber:
accountType === "BANK" ? accountNumber.trim() || null : null,
platform: accountType === "E_WALLET" ? platform.trim() || null : null,
accountId: accountType === "E_WALLET" ? accountId.trim() || null : null,
location: accountType === "CASH" ? location.trim() || null : null,
}
setIsPending(true)
try {
const result =
editing && account
? await updateAccountAction(account.id, payload)
: await createAccountAction(payload)
if (!result.success) {
setErrorMessage(result.error || "保存失败,请检查输入")
return
}
onOpenChange(false)
onSuccess()
} catch {
setErrorMessage("网络异常,提交失败")
} finally {
setIsPending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? "编辑资金账户" : "新建资金账户"}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form id="account-form" onSubmit={submit}>
<FieldGroup>
{errorMessage && (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle></AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<FieldGroup className="grid gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="accountType"></FieldLabel>
<Select
value={accountType}
onValueChange={(value) =>
value && setAccountType(value as AccountType)
}
>
<SelectTrigger id="accountType">
<SelectValue>{accountLabels[accountType]}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="BANK"></SelectItem>
<SelectItem value="E_WALLET"></SelectItem>
<SelectItem value="CASH"></SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="balanceType"></FieldLabel>
<Select
value={balanceType}
onValueChange={(value) =>
value && setBalanceType(value as typeof balanceType)
}
>
<SelectTrigger id="balanceType">
<SelectValue>{balanceLabels[balanceType]}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="ASSET"></SelectItem>
<SelectItem value="LIABILITY"></SelectItem>
<SelectItem value="EQUITY"></SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<FieldGroup>
<Field>
<FieldLabel htmlFor="accountName"></FieldLabel>
<Input
id="accountName"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:招商银行个人消费卡账户"
disabled={isPending}
required
/>
</Field>
<FieldGroup className="grid gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="primaryCurrency"></FieldLabel>
<Input
id="primaryCurrency"
value={primaryCurrency}
onChange={(event) => setPrimaryCurrency(event.target.value)}
placeholder="例如:HKD"
disabled={isPending}
/>
</Field>
<Field>
<FieldLabel htmlFor="supportedCurrencies">
</FieldLabel>
<Input
id="supportedCurrencies"
value={supportedCurrencies}
onChange={(event) =>
setSupportedCurrencies(event.target.value)
}
placeholder="用逗号分隔"
disabled={isPending}
/>
</Field>
</FieldGroup>
</FieldGroup>
<FieldGroup className="grid gap-3 sm:grid-cols-2">
{accountType === "BANK" && (
<>
<Field>
<FieldLabel htmlFor="issuerName"></FieldLabel>
<Input
id="issuerName"
value={issuerName}
onChange={(event) => setIssuerName(event.target.value)}
placeholder="例如:汇丰银行"
disabled={isPending}
/>
</Field>
<Field>
<FieldLabel htmlFor="accountNumber">
</FieldLabel>
<Input
id="accountNumber"
value={accountNumber}
onChange={(event) => setAccountNumber(event.target.value)}
placeholder="可填写完整账号或尾号"
disabled={isPending}
/>
</Field>
</>
)}
{accountType === "E_WALLET" && (
<>
<Field>
<FieldLabel htmlFor="platform"></FieldLabel>
<Input
id="platform"
value={platform}
onChange={(event) => setPlatform(event.target.value)}
placeholder="例如:支付宝"
disabled={isPending}
/>
</Field>
<Field>
<FieldLabel htmlFor="accountId"></FieldLabel>
<Input
id="accountId"
value={accountId}
onChange={(event) => setAccountId(event.target.value)}
placeholder="手机号、邮箱或会员号"
disabled={isPending}
/>
</Field>
</>
)}
{accountType === "CASH" && (
<Field>
<FieldLabel htmlFor="location"></FieldLabel>
<Input
id="location"
value={location}
onChange={(event) => setLocation(event.target.value)}
placeholder="例如:随身钱包"
disabled={isPending}
/>
</Field>
)}
</FieldGroup>
<Field>
<FieldLabel htmlFor="remark"></FieldLabel>
<Input
id="remark"
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder="可选"
disabled={isPending}
/>
</Field>
</FieldGroup>
</form>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
</Button>
<Button form="account-form" type="submit" disabled={isPending}>
{isPending && (
<Loader2Icon data-icon="inline-start" className="animate-spin" />
)}
{editing ? "保存修改" : "创建账户"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+470
View File
@@ -0,0 +1,470 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import {
BanknoteIcon,
CreditCardIcon,
LandmarkIcon,
Layers2Icon,
Loader2Icon,
MoreHorizontalIcon,
PowerIcon,
PowerOffIcon,
PencilIcon,
PlusIcon,
SearchIcon,
Trash2Icon,
WalletCardsIcon,
} from "lucide-react"
import {
deleteAccountAction,
toggleAccountActiveAction,
type AccountWithChannels,
} from "@/lib/actions/account"
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"
import { AccountDialog } from "./account-dialog"
const typeLabels = {
BANK: "银行账户",
E_WALLET: "电子钱包",
CASH: "现金",
} as const
const balanceLabels = {
ASSET: "资产类",
LIABILITY: "负债类",
EQUITY: "权益类",
} as const
const typeIcon = (type: AccountWithChannels["accountType"]) =>
type === "BANK" ? (
<LandmarkIcon className="size-4" />
) : type === "E_WALLET" ? (
<WalletCardsIcon className="size-4" />
) : (
<BanknoteIcon className="size-4" />
)
export function AccountsView({
initialAccounts,
}: {
initialAccounts: AccountWithChannels[]
}) {
const router = useRouter()
const [accountsList, setAccountsList] = React.useState(initialAccounts)
const [searchQuery, setSearchQuery] = React.useState("")
const [typeFilter, setTypeFilter] = React.useState("ALL")
const [balanceFilter, setBalanceFilter] = React.useState("ALL")
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editingAccount, setEditingAccount] =
React.useState<AccountWithChannels | null>(null)
const [deletingAccount, setDeletingAccount] =
React.useState<AccountWithChannels | null>(null)
const [isDeleting, setIsDeleting] = React.useState(false)
const [togglingId, setTogglingId] = React.useState<string | null>(null)
const filteredAccounts = React.useMemo(() => {
const query = searchQuery.trim().toLowerCase()
return accountsList.filter((account) => {
const text = [
account.name,
account.issuerName,
account.platform,
account.accountNumber,
account.accountId,
account.primaryCurrency,
account.remark,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
return (
(!query || text.includes(query)) &&
(typeFilter === "ALL" || account.accountType === typeFilter) &&
(balanceFilter === "ALL" || account.balanceType === balanceFilter)
)
})
}, [accountsList, balanceFilter, searchQuery, typeFilter])
const groups = (
Object.keys(balanceLabels) as Array<keyof typeof balanceLabels>
)
.map((balanceType) => ({
balanceType,
accounts: filteredAccounts.filter(
(account) => account.balanceType === balanceType
),
}))
.filter((group) => group.accounts.length)
const clearFilters = () => {
setSearchQuery("")
setTypeFilter("ALL")
setBalanceFilter("ALL")
}
const hasFilters =
Boolean(searchQuery) || typeFilter !== "ALL" || balanceFilter !== "ALL"
const openCreate = () => {
setEditingAccount(null)
setDialogOpen(true)
}
const toggleActive = async (
account: AccountWithChannels,
checked: boolean
) => {
setTogglingId(account.id)
setAccountsList((items) =>
items.map((item) =>
item.id === account.id ? { ...item, isActive: checked } : item
)
)
try {
const result = await toggleAccountActiveAction(account.id, checked)
if (!result.success)
setAccountsList((items) =>
items.map((item) =>
item.id === account.id ? { ...item, isActive: !checked } : item
)
)
else router.refresh()
} catch {
setAccountsList((items) =>
items.map((item) =>
item.id === account.id ? { ...item, isActive: !checked } : item
)
)
} finally {
setTogglingId(null)
}
}
const confirmDelete = async () => {
if (!deletingAccount) return
setIsDeleting(true)
try {
const result = await deleteAccountAction(deletingAccount.id)
if (result.success) {
setAccountsList((items) =>
items.filter((item) => item.id !== deletingAccount.id)
)
setDeletingAccount(null)
router.refresh()
}
} finally {
setIsDeleting(false)
}
}
return (
<TooltipProvider>
<main className="flex min-w-0 flex-1 flex-col gap-4 p-4 md:p-6 lg:p-8">
<div className="flex flex-col gap-4 border-b border-border/70 pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">
</p>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
</h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Button onClick={openCreate} className="w-full sm:w-auto">
<PlusIcon data-icon="inline-start" />
</Button>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<div className="relative min-w-0 flex-1 md:max-w-md">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索账户、机构、账号或币种"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex">
<Select
value={typeFilter}
onValueChange={(value) => setTypeFilter(value ?? "ALL")}
>
<SelectTrigger className="w-full sm:w-32">
<SelectValue>
{typeFilter === "ALL"
? "全部类型"
: typeLabels[typeFilter as keyof typeof typeLabels]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="ALL"></SelectItem>
<SelectItem value="BANK"></SelectItem>
<SelectItem value="E_WALLET"></SelectItem>
<SelectItem value="CASH"></SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Select
value={balanceFilter}
onValueChange={(value) => setBalanceFilter(value ?? "ALL")}
>
<SelectTrigger className="w-full sm:w-32">
<SelectValue>
{balanceFilter === "ALL"
? "全部分类"
: balanceLabels[
balanceFilter as keyof typeof balanceLabels
]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="ALL"></SelectItem>
<SelectItem value="ASSET"></SelectItem>
<SelectItem value="LIABILITY"></SelectItem>
<SelectItem value="EQUITY"></SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
</Button>
)}
</div>
{groups.length ? (
<div className="flex flex-col gap-6">
{groups.map(({ balanceType, accounts }) => (
<section
key={balanceType}
aria-labelledby={`account-group-${balanceType}`}
>
<div className="mb-2 flex items-center gap-2">
<h2
id={`account-group-${balanceType}`}
className="text-sm font-semibold"
>
{balanceLabels[balanceType]}
</h2>
<span className="text-xs text-muted-foreground">
{accounts.length}
</span>
</div>
<div className="overflow-hidden rounded-lg border border-border/70 bg-card">
<div className="hidden grid-cols-[minmax(220px,1.6fr)_110px_110px_64px] gap-3 border-b bg-muted/30 px-4 py-2.5 text-[11px] font-medium text-muted-foreground md:grid">
<span></span>
<span></span>
<span></span>
<span />
</div>
{accounts.map((account) => {
const actionMenu = (
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="打开账户操作"
/>
}
/>
}
>
<MoreHorizontalIcon />
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={togglingId === account.id}
onClick={() =>
toggleActive(account, !account.isActive)
}
>
{account.isActive ? (
<PowerOffIcon data-icon="inline-start" />
) : (
<PowerIcon data-icon="inline-start" />
)}
{account.isActive ? "停用账户" : "启用账户"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setEditingAccount(account)
setDialogOpen(true)
}}
>
<PencilIcon data-icon="inline-start" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingAccount(account)}
>
<Trash2Icon data-icon="inline-start" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
return (
<div
key={account.id}
className={`grid gap-3 border-b border-border/60 px-4 py-3 last:border-b-0 md:grid-cols-[minmax(220px,1.6fr)_110px_110px_64px] md:items-center ${!account.isActive ? "opacity-60" : ""}`}
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{typeIcon(account.accountType)}
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-medium">
{account.name}
</p>
{!account.isActive && (
<Badge variant="secondary"></Badge>
)}
</div>
<p className="truncate text-xs text-muted-foreground">
{account.issuerName ||
account.platform ||
account.location ||
account.remark ||
""}
</p>
</div>
</div>
<div className="shrink-0 md:hidden">{actionMenu}</div>
</div>
<div className="flex justify-between text-xs md:block">
<span className="text-muted-foreground md:hidden">
</span>
{typeLabels[account.accountType]}
</div>
<div className="flex justify-between text-xs md:block">
<span className="text-muted-foreground md:hidden">
</span>
<span className="inline-flex items-center gap-1">
<CreditCardIcon className="size-3.5 text-muted-foreground" />
{account.channelCount}
</span>
</div>
<div className="hidden md:flex md:justify-end">
{actionMenu}
</div>
</div>
)
})}
</div>
</section>
))}
</div>
) : (
<div className="flex min-h-56 flex-col items-center justify-center rounded-lg border border-dashed border-border px-6 py-10 text-center">
<Layers2Icon className="size-6 text-muted-foreground" />
<h2 className="mt-3 text-sm font-semibold">
{hasFilters ? "没有符合条件的账户" : "还没有资金账户"}
</h2>
<p className="mt-1 text-xs text-muted-foreground">
{hasFilters
? "调整关键词或筛选条件后再试。"
: "创建账户后即可关联支付渠道。"}
</p>
<div className="mt-4 flex gap-2">
{hasFilters && (
<Button variant="outline" size="sm" onClick={clearFilters}>
</Button>
)}
<Button size="sm" onClick={openCreate}>
<PlusIcon data-icon="inline-start" />
</Button>
</div>
</div>
)}
<AccountDialog
key={`${dialogOpen}-${editingAccount?.id ?? "new"}`}
open={dialogOpen}
onOpenChange={setDialogOpen}
account={editingAccount}
onSuccess={() => router.refresh()}
/>
<Dialog
open={Boolean(deletingAccount)}
onOpenChange={(open) => !open && setDeletingAccount(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{deletingAccount?.name}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeletingAccount(null)}
disabled={isDeleting}
>
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={isDeleting}
>
{isDeleting && (
<Loader2Icon
data-icon="inline-start"
className="animate-spin"
/>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</TooltipProvider>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { redirect } from "next/navigation"
import { auth } from "@/lib/auth"
import { getAccountsAction } from "@/lib/actions/account"
import { AppSidebar } from "@/components/app-sidebar"
import { AppHeader } from "@/components/app-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { AccountsView } from "./accounts-view"
export default async function AccountsPage() {
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 accountsRes = await getAccountsAction()
const initialAccounts =
accountsRes.success && accountsRes.data ? accountsRes.data : []
return (
<SidebarProvider>
<AppSidebar
user={{
name: userName,
email: userEmail,
avatar: userAvatar,
}}
/>
<SidebarInset>
<AppHeader title="资金账户" />
<AccountsView
key={JSON.stringify(initialAccounts)}
initialAccounts={initialAccounts}
/>
</SidebarInset>
</SidebarProvider>
)
}