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
+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>
)
}