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