✨ feat: impl accounts and channels
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user