572 lines
21 KiB
TypeScript
572 lines
21 KiB
TypeScript
"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>
|
|
)
|
|
}
|