feat: impl accounts and channels

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