✨ 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client"
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { CircleAlertIcon, Loader2Icon } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@/components/ui/field"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox"
|
||||
import {
|
||||
CARD_BRANDS,
|
||||
CARD_BRAND_LABELS,
|
||||
detectCardBrand,
|
||||
getCardBrandLogoUrl,
|
||||
normalizeCardBrand,
|
||||
type CardBrand,
|
||||
} from "@/lib/payment/card-brand"
|
||||
import {
|
||||
createChannelAction,
|
||||
updateChannelAction,
|
||||
type ChannelInput,
|
||||
type ChannelWithAccountNames,
|
||||
} from "@/lib/actions/channel"
|
||||
import { type AccountWithChannels } from "@/lib/actions/account"
|
||||
type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"
|
||||
const channelLabels = {
|
||||
PAYMENT_CARD: "支付卡",
|
||||
E_WALLET: "电子钱包",
|
||||
CASH: "现金",
|
||||
TRANSFER: "转账",
|
||||
} as const
|
||||
export function ChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
accounts,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
channel?: ChannelWithAccountNames | null
|
||||
accounts: AccountWithChannels[]
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const editing = Boolean(channel)
|
||||
const [channelType, setChannelType] = React.useState<ChannelType>(
|
||||
channel?.channelType || "PAYMENT_CARD"
|
||||
)
|
||||
const [selectedAccounts, setSelectedAccounts] = React.useState<string[]>(
|
||||
channel
|
||||
? Array.isArray(channel.refAccounts)
|
||||
? channel.refAccounts
|
||||
: []
|
||||
: accounts.length
|
||||
? [accounts[0].id]
|
||||
: []
|
||||
)
|
||||
const [desc, setDesc] = React.useState(channel?.desc || "")
|
||||
const [region, setRegion] = React.useState(channel?.region || "")
|
||||
const [issuerName, setIssuerName] = React.useState(channel?.issuerName || "")
|
||||
const [cardType, setCardType] = React.useState<"CREDIT" | "DEBIT" | "NONE">(
|
||||
channel?.cardType || "CREDIT"
|
||||
)
|
||||
const [cardBrand, setCardBrand] = React.useState(channel?.cardBrand || "")
|
||||
const [cardNumberFull, setCardNumberFull] = React.useState(
|
||||
channel?.cardNumberFull || ""
|
||||
)
|
||||
const [cardNumberSuffix, setCardNumberSuffix] = React.useState(
|
||||
channel?.cardNumberFull ? "" : channel?.cardNumberSuffix || ""
|
||||
)
|
||||
const [platform, setPlatform] = React.useState(channel?.platform || "")
|
||||
const [platformAccountId, setPlatformAccountId] = React.useState(
|
||||
channel?.platformAccountId || ""
|
||||
)
|
||||
const [subChannel, setSubChannel] = React.useState(channel?.subChannel || "")
|
||||
const [subChannelType, setSubChannelType] = React.useState<
|
||||
"CREDIT" | "DEBIT" | "NONE"
|
||||
>(channel?.subChannelType || "DEBIT")
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
|
||||
const cardBrandRequest = React.useRef(0)
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setErrorMessage(null)
|
||||
if (!selectedAccounts.length) {
|
||||
setErrorMessage("请至少关联一个资金账户")
|
||||
return
|
||||
}
|
||||
const full = cardNumberFull.trim()
|
||||
const payload: ChannelInput = {
|
||||
channelType,
|
||||
refAccounts: selectedAccounts,
|
||||
desc: desc.trim() || null,
|
||||
region: region.trim().toUpperCase() || null,
|
||||
issuerName:
|
||||
channelType === "PAYMENT_CARD" ? issuerName.trim() || null : null,
|
||||
cardType:
|
||||
channelType === "PAYMENT_CARD" && cardType !== "NONE" ? cardType : null,
|
||||
cardNumberFull: channelType === "PAYMENT_CARD" ? full || null : null,
|
||||
cardNumberSuffix:
|
||||
channelType === "PAYMENT_CARD" && !full
|
||||
? cardNumberSuffix.trim() || null
|
||||
: null,
|
||||
cardBrand:
|
||||
channelType === "PAYMENT_CARD" ? normalizeCardBrand(cardBrand) : null,
|
||||
platform: channelType === "E_WALLET" ? platform.trim() || null : null,
|
||||
platformAccountId:
|
||||
channelType === "E_WALLET" ? platformAccountId.trim() || null : null,
|
||||
subChannel: channelType === "E_WALLET" ? subChannel.trim() || null : null,
|
||||
subChannelType:
|
||||
channelType === "E_WALLET" && subChannelType !== "NONE"
|
||||
? subChannelType
|
||||
: null,
|
||||
}
|
||||
setIsPending(true)
|
||||
try {
|
||||
const result =
|
||||
editing && channel
|
||||
? await updateChannelAction(channel.id, payload)
|
||||
: await createChannelAction(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-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑支付渠道" : "新建支付渠道"}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
配置渠道信息,并指定实际结算的资金账户。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id="channel-form" onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>无法保存渠道</AlertTitle>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="desc">描述</FieldLabel>
|
||||
<Input
|
||||
id="desc"
|
||||
value={desc}
|
||||
onChange={(event) => setDesc(event.target.value)}
|
||||
placeholder="例如:日常主用渠道"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="channelType">类型</FieldLabel>
|
||||
<Select
|
||||
value={channelType}
|
||||
onValueChange={(value) =>
|
||||
value && setChannelType(value as ChannelType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="channelType">
|
||||
<SelectValue>{channelLabels[channelType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="PAYMENT_CARD">支付卡</SelectItem>
|
||||
<SelectItem value="E_WALLET">电子钱包</SelectItem>
|
||||
<SelectItem value="CASH">现金</SelectItem>
|
||||
<SelectItem value="TRANSFER">转账</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldSet>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLegend variant="label">归属账户</FieldLegend>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已选 {selectedAccounts.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{accounts.length ? (
|
||||
accounts.map((account) => (
|
||||
<label
|
||||
key={account.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-border/70 px-3 py-2 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedAccounts.includes(account.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedAccounts((items) =>
|
||||
checked
|
||||
? [...items, account.id]
|
||||
: items.filter((id) => id !== account.id)
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{account.name}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{account.primaryCurrency || "通用"}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<p className="border border-dashed border-border p-3 text-center text-xs text-muted-foreground">
|
||||
暂无资金账户,请先创建账户。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FieldSet>
|
||||
<FieldGroup>
|
||||
{channelType === "PAYMENT_CARD" && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="issuerName">发卡机构</FieldLabel>
|
||||
<Input
|
||||
id="issuerName"
|
||||
value={issuerName}
|
||||
onChange={(event) => setIssuerName(event.target.value)}
|
||||
placeholder="例如:汇丰银行"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cardBrand">卡组织</FieldLabel>
|
||||
<Combobox
|
||||
items={CARD_BRANDS}
|
||||
value={normalizeCardBrand(cardBrand)}
|
||||
onValueChange={(value) => {
|
||||
cardBrandRequest.current += 1
|
||||
setCardBrand((value as CardBrand | null) || "")
|
||||
}}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput
|
||||
id="cardBrand"
|
||||
placeholder="选择卡组织"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>没有匹配的卡组织</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(brand: CardBrand) => (
|
||||
<ComboboxItem key={brand} value={brand}>
|
||||
<Image
|
||||
src={getCardBrandLogoUrl(brand) || ""}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
{CARD_BRAND_LABELS[brand]}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cardType">卡片类型</FieldLabel>
|
||||
<Select
|
||||
value={cardType}
|
||||
onValueChange={(value) =>
|
||||
value && setCardType(value as typeof cardType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="cardType">
|
||||
<SelectValue>
|
||||
{cardType === "CREDIT"
|
||||
? "信用卡"
|
||||
: cardType === "DEBIT"
|
||||
? "借记卡"
|
||||
: "未指定"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="CREDIT">信用卡</SelectItem>
|
||||
<SelectItem value="DEBIT">借记卡</SelectItem>
|
||||
<SelectItem value="NONE">未指定</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="region">地区代码</FieldLabel>
|
||||
<Input
|
||||
id="region"
|
||||
value={region}
|
||||
onChange={(event) => setRegion(event.target.value)}
|
||||
placeholder="例如:HK"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
{!cardNumberSuffix && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cardNumberFull">完整卡号</FieldLabel>
|
||||
<Input
|
||||
id="cardNumberFull"
|
||||
value={cardNumberFull}
|
||||
className="font-mono"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
const request = ++cardBrandRequest.current
|
||||
setCardNumberFull(value)
|
||||
if (value) setCardNumberSuffix("")
|
||||
if (!value) return
|
||||
void detectCardBrand(value).then((brand) => {
|
||||
if (request === cardBrandRequest.current && brand)
|
||||
setCardBrand(brand)
|
||||
})
|
||||
}}
|
||||
placeholder="可留空"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!cardNumberFull && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cardNumberSuffix">
|
||||
卡号末尾四位
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="cardNumberSuffix"
|
||||
value={cardNumberSuffix}
|
||||
className="font-mono"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setCardNumberSuffix(value)
|
||||
if (value) setCardNumberFull("")
|
||||
}}
|
||||
placeholder="例如:8888"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{channelType === "E_WALLET" && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="platform">平台名称</FieldLabel>
|
||||
<Input
|
||||
id="platform"
|
||||
value={platform}
|
||||
onChange={(event) => setPlatform(event.target.value)}
|
||||
placeholder="例如:支付宝"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="platformAccountId">
|
||||
平台账号
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="platformAccountId"
|
||||
value={platformAccountId}
|
||||
onChange={(event) =>
|
||||
setPlatformAccountId(event.target.value)
|
||||
}
|
||||
placeholder="手机号或邮箱"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subChannel">子渠道名称</FieldLabel>
|
||||
<Input
|
||||
id="subChannel"
|
||||
value={subChannel}
|
||||
onChange={(event) => setSubChannel(event.target.value)}
|
||||
placeholder="例如:余额"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subChannelType">子渠道类型</FieldLabel>
|
||||
<Select
|
||||
value={subChannelType}
|
||||
onValueChange={(value) =>
|
||||
value &&
|
||||
setSubChannelType(value as typeof subChannelType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="subChannelType">
|
||||
<SelectValue>
|
||||
{subChannelType === "CREDIT"
|
||||
? "信用消费"
|
||||
: subChannelType === "DEBIT"
|
||||
? "借记储值"
|
||||
: "未指定"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="DEBIT">借记储值</SelectItem>
|
||||
<SelectItem value="CREDIT">信用消费</SelectItem>
|
||||
<SelectItem value="NONE">未指定</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="walletRegion">地区代码</FieldLabel>
|
||||
<Input
|
||||
id="walletRegion"
|
||||
value={region}
|
||||
onChange={(event) => setRegion(event.target.value)}
|
||||
placeholder="例如:HK"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{(channelType === "CASH" || channelType === "TRANSFER") && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
此类型无需额外信息。
|
||||
</p>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button form="channel-form" type="submit" disabled={isPending}>
|
||||
{isPending && (
|
||||
<Loader2Icon data-icon="inline-start" className="animate-spin" />
|
||||
)}
|
||||
{editing ? "保存修改" : "创建渠道"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
ArrowRightLeftIcon,
|
||||
BanknoteIcon,
|
||||
CreditCardIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
PowerOffIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
WalletCardsIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
type ChannelWithAccountNames,
|
||||
deleteChannelAction,
|
||||
toggleChannelActiveAction,
|
||||
} from "@/lib/actions/channel"
|
||||
import { 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 { ChannelDialog } from "./channel-dialog"
|
||||
import {
|
||||
CARD_BRAND_LABELS,
|
||||
getCardBrandLogoUrl,
|
||||
normalizeCardBrand,
|
||||
} from "@/lib/payment/card-brand"
|
||||
|
||||
interface ChannelsViewProps {
|
||||
initialChannels: ChannelWithAccountNames[]
|
||||
initialAccounts: AccountWithChannels[]
|
||||
}
|
||||
|
||||
const channelTypeLabels = {
|
||||
PAYMENT_CARD: "支付卡",
|
||||
E_WALLET: "电子钱包",
|
||||
CASH: "现金",
|
||||
TRANSFER: "转账",
|
||||
} as const
|
||||
const channelIcon = (type: ChannelWithAccountNames["channelType"]) =>
|
||||
type === "PAYMENT_CARD" ? (
|
||||
<CreditCardIcon className="size-4" />
|
||||
) : type === "E_WALLET" ? (
|
||||
<WalletCardsIcon className="size-4" />
|
||||
) : type === "CASH" ? (
|
||||
<BanknoteIcon className="size-4" />
|
||||
) : (
|
||||
<ArrowRightLeftIcon className="size-4" />
|
||||
)
|
||||
|
||||
function paymentCardIcon(channel: ChannelWithAccountNames) {
|
||||
const brand = normalizeCardBrand(channel.cardBrand)
|
||||
const logoUrl = getCardBrandLogoUrl(channel.cardBrand)
|
||||
|
||||
return logoUrl && brand ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={CARD_BRAND_LABELS[brand]}
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6 object-contain"
|
||||
/>
|
||||
) : (
|
||||
channelIcon(channel.channelType)
|
||||
)
|
||||
}
|
||||
|
||||
function channelName(channel: ChannelWithAccountNames) {
|
||||
if (channel.channelType === "PAYMENT_CARD")
|
||||
return channel.issuerName || "支付卡"
|
||||
if (channel.channelType === "E_WALLET") return channel.platform || "电子钱包"
|
||||
return channel.channelType === "CASH" ? "现金渠道" : "转账渠道"
|
||||
}
|
||||
|
||||
function formatCardNumber(cardNumber: string) {
|
||||
return cardNumber
|
||||
.replace(/\D/g, "")
|
||||
.replace(/(.{4})/g, "$1 ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function ChannelsView({
|
||||
initialChannels,
|
||||
initialAccounts,
|
||||
}: ChannelsViewProps) {
|
||||
const router = useRouter()
|
||||
const [channelsList, setChannelsList] = React.useState(initialChannels)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
const [typeFilter, setTypeFilter] = React.useState("ALL")
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [editingChannel, setEditingChannel] =
|
||||
React.useState<ChannelWithAccountNames | null>(null)
|
||||
const [deletingChannel, setDeletingChannel] =
|
||||
React.useState<ChannelWithAccountNames | null>(null)
|
||||
const [isDeleting, setIsDeleting] = React.useState(false)
|
||||
const [togglingId, setTogglingId] = React.useState<string | null>(null)
|
||||
const [visibleCardIds, setVisibleCardIds] = React.useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [copyStatus, setCopyStatus] = React.useState<{
|
||||
channelId: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const filteredChannels = React.useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
return channelsList.filter((item) => {
|
||||
const searchable = [
|
||||
channelName(item),
|
||||
item.issuerName,
|
||||
item.cardBrand,
|
||||
item.cardNumberSuffix,
|
||||
item.platform,
|
||||
item.platformAccountId,
|
||||
item.region,
|
||||
item.subChannel,
|
||||
item.desc,
|
||||
...(item.linkedAccounts || []).map((account) => account.name),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return (
|
||||
(!query || searchable.includes(query)) &&
|
||||
(typeFilter === "ALL" || item.channelType === typeFilter)
|
||||
)
|
||||
})
|
||||
}, [channelsList, searchQuery, typeFilter])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingChannel(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingChannel) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteChannelAction(deletingChannel.id)
|
||||
if (result.success) {
|
||||
setChannelsList((items) =>
|
||||
items.filter((item) => item.id !== deletingChannel.id)
|
||||
)
|
||||
setDeletingChannel(null)
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
const toggleActive = async (
|
||||
channel: ChannelWithAccountNames,
|
||||
isActive: boolean
|
||||
) => {
|
||||
setTogglingId(channel.id)
|
||||
setChannelsList((items) =>
|
||||
items.map((item) =>
|
||||
item.id === channel.id ? { ...item, isActive } : item
|
||||
)
|
||||
)
|
||||
try {
|
||||
const result = await toggleChannelActiveAction(channel.id, isActive)
|
||||
if (!result.success)
|
||||
setChannelsList((items) =>
|
||||
items.map((item) =>
|
||||
item.id === channel.id ? { ...item, isActive: !isActive } : item
|
||||
)
|
||||
)
|
||||
else router.refresh()
|
||||
} catch {
|
||||
setChannelsList((items) =>
|
||||
items.map((item) =>
|
||||
item.id === channel.id ? { ...item, isActive: !isActive } : item
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
const hasFilters = Boolean(searchQuery) || typeFilter !== "ALL"
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("")
|
||||
setTypeFilter("ALL")
|
||||
}
|
||||
const toggleCardVisibility = (channelId: string) => {
|
||||
setVisibleCardIds((ids) => {
|
||||
const nextIds = new Set(ids)
|
||||
if (nextIds.has(channelId)) nextIds.delete(channelId)
|
||||
else nextIds.add(channelId)
|
||||
return nextIds
|
||||
})
|
||||
}
|
||||
const copyCardNumber = async (channel: ChannelWithAccountNames) => {
|
||||
if (!channel.cardNumberFull) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(channel.cardNumberFull)
|
||||
setCopyStatus({ channelId: channel.id, message: "已复制" })
|
||||
} catch {
|
||||
setCopyStatus({ channelId: channel.id, message: "复制失败" })
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onValueChange={(value) => setTypeFilter(value ?? "ALL")}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-36">
|
||||
<SelectValue>
|
||||
{typeFilter === "ALL"
|
||||
? "全部类型"
|
||||
: channelTypeLabels[
|
||||
typeFilter as keyof typeof channelTypeLabels
|
||||
]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="ALL">全部类型</SelectItem>
|
||||
<SelectItem value="PAYMENT_CARD">支付卡</SelectItem>
|
||||
<SelectItem value="E_WALLET">电子钱包</SelectItem>
|
||||
<SelectItem value="CASH">现金</SelectItem>
|
||||
<SelectItem value="TRANSFER">转账</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{filteredChannels.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-border/70 bg-card">
|
||||
<div className="hidden grid-cols-[minmax(220px,1.5fr)_minmax(180px,1fr)_110px_minmax(180px,1fr)_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>归属账户</span>
|
||||
<span />
|
||||
</div>
|
||||
{filteredChannels.map((channel) => {
|
||||
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 === channel.id}
|
||||
onClick={() => toggleActive(channel, !channel.isActive)}
|
||||
>
|
||||
{channel.isActive ? (
|
||||
<PowerOffIcon data-icon="inline-start" />
|
||||
) : (
|
||||
<PowerIcon data-icon="inline-start" />
|
||||
)}
|
||||
{channel.isActive ? "停用渠道" : "启用渠道"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingChannel(channel)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
编辑渠道
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeletingChannel(channel)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除渠道
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`grid gap-3 border-b border-border/60 px-4 py-3 last:border-b-0 md:grid-cols-[minmax(220px,1.5fr)_minmax(180px,1fr)_110px_minmax(180px,1fr)_64px] md:items-center ${!channel.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">
|
||||
{channel.channelType === "PAYMENT_CARD"
|
||||
? paymentCardIcon(channel)
|
||||
: channelIcon(channel.channelType)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{channel.desc ||
|
||||
channel.platformAccountId ||
|
||||
channel.subChannel ||
|
||||
""}
|
||||
</p>
|
||||
{!channel.isActive && (
|
||||
<Badge variant="secondary">已停用</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs">
|
||||
<p className="min-w-0 truncate text-muted-foreground">
|
||||
{channelName(channel)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 md:hidden">{actionMenu}</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-xs md:block">
|
||||
<span className="text-muted-foreground md:hidden">
|
||||
标识
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{channel.channelType === "PAYMENT_CARD" &&
|
||||
(channel.cardNumberSuffix || channel.cardNumberFull) ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<span className="font-mono whitespace-nowrap text-muted-foreground">
|
||||
{visibleCardIds.has(channel.id) &&
|
||||
channel.cardNumberFull
|
||||
? formatCardNumber(channel.cardNumberFull)
|
||||
: `•••• ${
|
||||
channel.cardNumberSuffix ||
|
||||
formatCardNumber(
|
||||
channel.cardNumberFull || ""
|
||||
).slice(-4)
|
||||
}`}
|
||||
</span>
|
||||
{channel.cardNumberFull && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={
|
||||
visibleCardIds.has(channel.id)
|
||||
? "隐藏完整卡号"
|
||||
: "显示完整卡号"
|
||||
}
|
||||
onClick={() =>
|
||||
toggleCardVisibility(channel.id)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{visibleCardIds.has(channel.id) ? (
|
||||
<EyeOffIcon />
|
||||
) : (
|
||||
<EyeIcon />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{visibleCardIds.has(channel.id)
|
||||
? "隐藏完整卡号"
|
||||
: "显示完整卡号"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="复制卡号"
|
||||
onClick={() => copyCardNumber(channel)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CopyIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>复制卡号</TooltipContent>
|
||||
</Tooltip>
|
||||
{copyStatus?.channelId === channel.id && (
|
||||
<span className="text-[11px] whitespace-nowrap text-muted-foreground">
|
||||
{copyStatus.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : channel.channelType === "E_WALLET" ? (
|
||||
channel.platformAccountId || ""
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs md:block">
|
||||
<span className="text-muted-foreground md:hidden">类型</span>
|
||||
<span>{channelTypeLabels[channel.channelType]}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-xs md:block">
|
||||
<span className="text-muted-foreground md:hidden">
|
||||
归属账户
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{channel.linkedAccounts?.length ? (
|
||||
channel.linkedAccounts
|
||||
.map((account) => account.name)
|
||||
.join("、")
|
||||
) : (
|
||||
<span className="text-destructive">未关联</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden md:flex md:justify-end">
|
||||
{actionMenu}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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">
|
||||
<CreditCardIcon 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>
|
||||
)}
|
||||
<ChannelDialog
|
||||
key={`${dialogOpen}-${editingChannel?.id ?? "new"}`}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
channel={editingChannel}
|
||||
accounts={initialAccounts}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
<Dialog
|
||||
open={Boolean(deletingChannel)}
|
||||
onOpenChange={(open) => !open && setDeletingChannel(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除渠道</DialogTitle>
|
||||
<DialogDescription>
|
||||
将删除「{deletingChannel ? channelName(deletingChannel) : ""}
|
||||
」。历史流水仍会保留,之后不能再选择此渠道。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingChannel(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,52 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { getChannelsAction } from "@/lib/actions/channel"
|
||||
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 { ChannelsView } from "./channels-view"
|
||||
|
||||
export default async function ChannelsPage() {
|
||||
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 [channelsRes, accountsRes] = await Promise.all([
|
||||
getChannelsAction(),
|
||||
getAccountsAction(),
|
||||
])
|
||||
|
||||
const initialChannels =
|
||||
channelsRes.success && channelsRes.data ? channelsRes.data : []
|
||||
const initialAccounts =
|
||||
accountsRes.success && accountsRes.data ? accountsRes.data : []
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
user={{
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
avatar: userAvatar,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<AppHeader title="支付渠道" />
|
||||
|
||||
<ChannelsView
|
||||
key={JSON.stringify([initialChannels, initialAccounts])}
|
||||
initialChannels={initialChannels}
|
||||
initialAccounts={initialAccounts}
|
||||
/>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
+7
-3
@@ -1,3 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Inter:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@@ -5,8 +6,9 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-heading: var(--font-app-sans);
|
||||
--font-sans: var(--font-app-sans);
|
||||
--font-mono: var(--font-app-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -48,6 +50,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-app-sans: "Inter", "Noto Sans SC", Arial, sans-serif;
|
||||
--font-app-mono: "Geist Mono", "SFMono-Regular", Consolas, monospace;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -129,4 +133,4 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-12
@@ -1,15 +1,6 @@
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google"
|
||||
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'})
|
||||
|
||||
const fontMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
})
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -20,10 +11,12 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased", fontMono.variable, "font-sans", inter.variable)}
|
||||
className="antialiased"
|
||||
>
|
||||
<body>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"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 {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
CoinsIcon,
|
||||
Globe2Icon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
PieChartIcon,
|
||||
TrendingUpIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
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";
|
||||
|
||||
interface LoginFormProps {
|
||||
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 [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [rememberMe, setRememberMe] = React.useState(false);
|
||||
const [isPending, setIsPending] = React.useState(false);
|
||||
const [isOidcPending, setIsOidcPending] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(() => {
|
||||
if (authError === "CredentialsSignin") {
|
||||
return "邮箱或密码错误,请核对后重试";
|
||||
}
|
||||
if (authError) {
|
||||
return "认证过程中遇到问题,请重新登录";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!email.trim()) {
|
||||
setErrorMessage("请输入登录邮箱");
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setErrorMessage("请输入登录密码");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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("账号或密码不正确,请重新检查");
|
||||
} else {
|
||||
setErrorMessage("登录失败,请稍后重试");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setErrorMessage("网络异常,无法连接到认证服务器");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOidcLogin = async () => {
|
||||
try {
|
||||
setIsOidcPending(true);
|
||||
await signIn("oidc", { callbackUrl });
|
||||
} catch {
|
||||
setIsOidcPending(false);
|
||||
setErrorMessage("SSO 单点登录发起失败,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
|
||||
{/* Decorative ambient background meshes */}
|
||||
<div className="pointer-events-none absolute -top-40 -right-40 size-[32rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute top-1/2 -left-48 size-[34rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.08),rgba(255,255,255,0))]" />
|
||||
|
||||
{/* Top navigation / branding header */}
|
||||
<header className="relative z-10 flex h-16 w-full items-center justify-between px-6 md:px-12">
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<FluxentLogo size={32} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-heading text-base font-semibold tracking-tight">
|
||||
Fluxent
|
||||
</span>
|
||||
<span className="text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Financial OS
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content grid */}
|
||||
<main className="relative z-10 flex flex-1 items-center justify-center px-4 py-8 sm:px-6">
|
||||
<div className="grid w-full max-w-4xl gap-8 lg:grid-cols-[1.1fr_1fr] lg:items-center">
|
||||
{/* Left Hero feature showcase (visible on large screen) */}
|
||||
<div className="hidden flex-col gap-8 pr-4 lg:flex">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-border/80 bg-muted/60 px-3 py-1 text-xs text-muted-foreground backdrop-blur-xs">
|
||||
<span className="inline-block size-1.5 rounded-full bg-primary animate-pulse" />
|
||||
全球多币种 • 全场景记账 • 实时汇率
|
||||
</div>
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
掌控每一笔资产流动,
|
||||
<br />
|
||||
让财务管理行云流水。
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent
|
||||
提供银行级的精确记录与现代化的优雅交互体验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Visual value propositions */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5 rounded-xl border border-border/60 bg-card/50 p-3.5 backdrop-blur-xs transition-colors hover:border-border">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CoinsIcon className="size-4" />
|
||||
</div>
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
全币种与汇率追踪
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
多币种入账与清算汇率对账,资产估值一目了然
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 rounded-xl border border-border/60 bg-card/50 p-3.5 backdrop-blur-xs transition-colors hover:border-border">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<WalletIcon className="size-4" />
|
||||
</div>
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
全渠道多账户管理
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
银行账户、电子钱包与支付卡渠道统一调度
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 rounded-xl border border-border/60 bg-card/50 p-3.5 backdrop-blur-xs transition-colors hover:border-border">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<TrendingUpIcon className="size-4" />
|
||||
</div>
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
复式记账与场景分类
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
标准借贷分录与消费场景画像,财务合规严谨
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 rounded-xl border border-border/60 bg-card/50 p-3.5 backdrop-blur-xs transition-colors hover:border-border">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<PieChartIcon className="size-4" />
|
||||
</div>
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
资产汇总与统计
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
跨账户资金结构分布分析,收支报表一览无余
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Login Card */}
|
||||
<div className="w-full">
|
||||
<Card className="border-border/80 shadow-lg shadow-black/5 dark:shadow-black/25">
|
||||
<CardHeader className="gap-1.5 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold tracking-tight">
|
||||
欢迎回到 Fluxent
|
||||
</CardTitle>
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/5 text-primary lg:hidden">
|
||||
<FluxentLogo size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
输入您的注册邮箱与密码以继续使用
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{registered === "1" && (
|
||||
<Alert className="border-border bg-muted/40">
|
||||
<CheckCircle2Icon className="text-foreground" />
|
||||
<AlertTitle>账号注册成功</AlertTitle>
|
||||
<AlertDescription className="text-xs">
|
||||
您的 Fluxent 账户已就绪,请使用新密码进行登录。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="text-xs">
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* OIDC Single Sign On Button (if enabled) */}
|
||||
{oidcEnabled && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleOidcLogin}
|
||||
disabled={isOidcPending || isPending}
|
||||
className="w-full justify-center text-xs font-medium"
|
||||
>
|
||||
{isOidcPending ? (
|
||||
<Loader2Icon
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Globe2Icon data-icon="inline-start" />
|
||||
)}
|
||||
通过 {oidcName} 快速登录
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Separator className="w-full" />
|
||||
<span className="absolute bg-card px-2 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
或者使用邮箱密码
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Email & Password Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="email" className="text-xs font-medium">
|
||||
邮箱地址
|
||||
</Label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isPending}
|
||||
className="pl-8"
|
||||
/>
|
||||
<MailIcon className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-xs font-medium">
|
||||
密码
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isPending}
|
||||
className="pl-8"
|
||||
/>
|
||||
<LockIcon className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="text-xs text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
保持此设备的登录状态
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isPending}
|
||||
className="mt-1 w-full justify-center text-xs font-medium"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<KeyRoundIcon data-icon="inline-start" />
|
||||
)}
|
||||
{isPending ? "正在登录..." : "登录进入系统"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t border-border/50 py-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
还没有 Fluxent 账号?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-foreground underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
立即注册新账号
|
||||
<ArrowRightIcon
|
||||
data-icon="inline-end"
|
||||
className="inline size-3 ml-0.5"
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<p className="mt-4 text-center text-[11px] text-muted-foreground">
|
||||
登录即代表您同意 Fluxent 的服务条款与隐私政策
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 flex h-12 items-center justify-center border-t border-border/40 px-6 text-center text-[11px] text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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)";
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-svh items-center justify-center bg-background">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginForm oidcEnabled={oidcEnabled} oidcName={oidcName} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+209
-15
@@ -1,19 +1,213 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CreditCardIcon,
|
||||
DollarSignIcon,
|
||||
LayersIcon,
|
||||
PlusIcon,
|
||||
SlidersHorizontalIcon,
|
||||
TrendingUpIcon,
|
||||
WalletIcon,
|
||||
} 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";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
|
||||
// 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
const userName = user.name || "Fluxent 用户";
|
||||
const userEmail = user.email || "";
|
||||
const userAvatar = user.image || null;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh p-6">
|
||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
||||
<div>
|
||||
<h1 className="font-medium">Project ready!</h1>
|
||||
<p>You may now add components and start building.</p>
|
||||
<p>We've already added the button component for you.</p>
|
||||
<Button className="mt-2">Button</Button>
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
user={{
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
avatar: userAvatar,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<AppHeader title="控制台" />
|
||||
|
||||
{/* Inset Main Dashboard Content */}
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
|
||||
{/* Welcome greeting banner */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/80 bg-gradient-to-br from-card via-card to-muted/40 p-6 md:p-8">
|
||||
<div className="relative z-10 flex flex-col gap-3 md:max-w-2xl">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground md:text-3xl">
|
||||
欢迎回来,{userName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
这是您的 Fluxent 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<Button size="sm" className="text-xs">
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
记一笔交易
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
render={<Link href="/accounts" />}
|
||||
nativeButton={false}
|
||||
>
|
||||
<LayersIcon data-icon="inline-start" />
|
||||
管理资金账户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle background decoration */}
|
||||
<div className="pointer-events-none absolute -right-12 -bottom-12 size-64 rounded-full bg-primary/5 blur-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Quick Metrics & Highlights Cards (3 columns on desktop) */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-border/70 bg-card/60 backdrop-blur-xs">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground">
|
||||
总资产估值 (CNY)
|
||||
</CardTitle>
|
||||
<DollarSignIcon className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tracking-tight">¥ 0.00</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
暂未录入资金账户余额
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70 bg-card/60 backdrop-blur-xs">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground">
|
||||
活跃账户与卡片
|
||||
</CardTitle>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tracking-tight">0</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
支持银行卡、信用卡、电子钱包
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70 bg-card/60 backdrop-blur-xs">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground">
|
||||
本月记账笔数
|
||||
</CardTitle>
|
||||
<TrendingUpIcon className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tracking-tight">0</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
全场景交易流水自动归集
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Next Steps / Feature Guide (3 columns) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-heading text-base font-semibold tracking-tight text-foreground">
|
||||
开始搭建您的资产版图
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Link
|
||||
href="/accounts"
|
||||
className="group flex flex-col justify-between rounded-xl border border-border/80 bg-card p-5 transition-all hover:border-foreground/30 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CreditCardIcon className="size-4.5" />
|
||||
</div>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
1. 添加账户与支付渠道
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1 text-xs font-medium text-primary">
|
||||
<span>去配置账户</span>
|
||||
<ArrowRightIcon
|
||||
data-icon="inline-end"
|
||||
className="size-3.5 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="group flex flex-col justify-between rounded-xl border border-border/80 bg-card p-5 transition-all hover:border-foreground/30 hover:shadow-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayersIcon className="size-4.5" />
|
||||
</div>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
2. 建立首笔复式流水
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1 text-xs font-medium text-primary">
|
||||
<span>新增记账</span>
|
||||
<ArrowRightIcon
|
||||
data-icon="inline-end"
|
||||
className="size-3.5 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex flex-col justify-between rounded-xl border border-border/80 bg-card p-5 transition-all hover:border-foreground/30 hover:shadow-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<SlidersHorizontalIcon className="size-4.5" />
|
||||
</div>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
3. 账户首选项与设置
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
配置个人偏好货币、导出记账数据或连接外部服务。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1 text-xs font-medium text-primary">
|
||||
<span>偏好设置</span>
|
||||
<ArrowRightIcon
|
||||
data-icon="inline-end"
|
||||
className="size-3.5 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
(Press <kbd>d</kbd> to toggle dark mode)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Suspense } from "react";
|
||||
import { RegisterForm } from "./register-form";
|
||||
|
||||
export const metadata = {
|
||||
title: "注册新账号 - Fluxent",
|
||||
description: "创建您的 Fluxent 多币种资产与记账账户",
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-svh items-center justify-center bg-background">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState } from "react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircle2Icon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
SparklesIcon,
|
||||
UserIcon,
|
||||
} 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
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";
|
||||
|
||||
const initialState: RegisterState = {};
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
registerAction,
|
||||
initialState
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}, [state?.success, router]);
|
||||
|
||||
// 密码复杂度提示计算
|
||||
const hasMinLen = password.length >= 8;
|
||||
const passwordsMatch = Boolean(
|
||||
password && confirmPassword && password === confirmPassword
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
|
||||
{/* Decorative ambient gradients */}
|
||||
<div className="pointer-events-none absolute -top-40 -left-40 size-[32rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-40 -right-40 size-[34rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.08),rgba(255,255,255,0))]" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="relative z-10 flex h-16 w-full items-center justify-between px-6 md:px-12">
|
||||
<Link
|
||||
href="/"
|
||||
className="group flex items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<FluxentLogo size={32} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-heading text-base font-semibold tracking-tight">
|
||||
Fluxent
|
||||
</span>
|
||||
<span className="text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
Financial OS
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Card */}
|
||||
<main className="relative z-10 flex flex-1 items-center justify-center px-4 py-8 sm:px-6">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="border-border/80 shadow-lg shadow-black/5 dark:shadow-black/25">
|
||||
<CardHeader className="gap-1.5 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-bold tracking-tight">
|
||||
开启您的 Fluxent 空间
|
||||
</CardTitle>
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<SparklesIcon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
创建主账户,开启多币种资产追踪与专业级记账
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* 注册成功反馈 */}
|
||||
{state?.success ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<CheckCircle2Icon className="size-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
账户已成功创建!
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
系统正在为您准备控制台,即将自动跳转至登录页...
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-xs"
|
||||
onClick={() => router.push("/login?registered=1")}
|
||||
>
|
||||
立即前往登录
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 全局错误提示 */}
|
||||
{state?.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="text-xs">
|
||||
{state.error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="flex flex-col gap-3.5">
|
||||
{/* 姓名 / 昵称 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="name" className="text-xs font-medium">
|
||||
姓名 / 昵称
|
||||
</Label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="例如:Alex Chen"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="pl-8"
|
||||
/>
|
||||
<UserIcon className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{state?.fieldErrors?.name && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{state.fieldErrors.name[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 邮箱 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="email" className="text-xs font-medium">
|
||||
工作或个人邮箱
|
||||
</Label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="alex@example.com"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="pl-8"
|
||||
/>
|
||||
<MailIcon className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{state?.fieldErrors?.email && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{state.fieldErrors.email[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-xs font-medium">
|
||||
登录密码
|
||||
</Label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="至少 8 个字符"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isPending}
|
||||
className="pl-8"
|
||||
/>
|
||||
<LockIcon className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{state?.fieldErrors?.password && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{state.fieldErrors.password[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
再次确认密码
|
||||
</Label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="重复输入上方设置的密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={isPending}
|
||||
className="pl-8"
|
||||
/>
|
||||
<LockIcon className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{state?.fieldErrors?.confirmPassword && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{state.fieldErrors.confirmPassword[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码强度/匹配指引 */}
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-border/40 bg-muted/30 p-2.5 text-[11px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
hasMinLen ? "bg-primary" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<span>密码长度不少于 8 位</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
passwordsMatch
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<span>两次密码输入保持一致</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isPending}
|
||||
className="mt-1 w-full justify-center text-xs font-medium"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<KeyRoundIcon data-icon="inline-start" />
|
||||
)}
|
||||
{isPending ? "正在建立账户..." : "注册并立即开始"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t border-border/50 py-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
已经拥有 Fluxent 账号?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-foreground underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
data-icon="inline-start"
|
||||
className="inline size-3 mr-0.5"
|
||||
/>
|
||||
返回直接登录
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 flex h-12 items-center justify-center border-t border-border/40 px-6 text-center text-[11px] text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user