Files
juip-web/src/admin/dashboard/page.tsx
2026-09-03 14:37:18 +08:00

499 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Check } from "lucide-react"
import { type ReactNode, useEffect, useState } from "react"
import { fetchAgreementStatus, fetchRealNameStatus } from "@/api/agreement"
import { getUserInfo } from "@/api/token"
import AgreementDialog from "@/components/composites/dialogs/agreementDialog"
import {
EditInfoDialog,
type EditInfoValues,
} from "@/components/composites/dialogs/editInfoDialog"
import RealNameDialog from "@/components/composites/dialogs/realNameDialog"
import Page from "@/components/page"
import { QrCodeImage } from "@/components/qrCodeImage"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import b3 from "../assets/b3.webp"
import b5 from "../assets/b5.webp"
import b6 from "../assets/b6.webp"
import b7 from "../assets/b7.webp"
import b8 from "../assets/b8.webp"
import b9 from "../assets/b9.webp"
import b10 from "../assets/b10.webp"
import b11 from "../assets/b11.webp"
import b12 from "../assets/b12.webp"
import banner from "../assets/banner.webp"
import banner2 from "../assets/banner2.webp"
import {
MOCK_ACCOUNT_INFO,
MOCK_BALANCE,
MOCK_CONSUMPTION,
MOCK_IP_STATS,
} from "./mock"
const fmt = (n: number) => n.toFixed(2)
const MANAGER_QR_URLS: Record<number, string> = {
1029: "https://work.weixin.qq.com/u/vc887a78c8a042ee8e?v=4.1.8.133653",
1030: "https://work.weixin.qq.com/u/vc83c509032e079ac3?v=4.1.8.23932",
1017: "https://work.weixin.qq.com/u/vc7e4f48bc9e52e3d0?v=4.1.8.133653",
}
function readUserInfo() {
const raw = getUserInfo<Record<string, unknown>>()
const info = (raw ?? {}) as Record<string, unknown>
const phone = String(
info.Phone ??
info.LoginCode ??
info.phone ??
info.logincode ??
info.Mobile ??
info.mobile ??
"",
)
const managerId = Number(info.ManagerId ?? info.managerId ?? 0)
return {
phone,
managerQrUrl:
Number.isFinite(managerId) && MANAGER_QR_URLS[managerId]
? MANAGER_QR_URLS[managerId]
: "",
}
}
export default function DashboardPage() {
// ========== 数据定义(待接口就绪后改为 useEffect 拉取) ==========
const balanceData = MOCK_BALANCE
const accountInfo = MOCK_ACCOUNT_INFO
const consumptionData = MOCK_CONSUMPTION
const ipAccountData = MOCK_IP_STATS
const { phone, managerQrUrl } = readUserInfo()
// 实名认证状态(php-api /agent/index/isverify)、网络安全协议签署状态
// (php-api /tencent/index/get_is_upload)
const [verified, setVerified] = useState<boolean | null>(null)
const [agreementSigned, setAgreementSigned] = useState<boolean | null>(null)
const refreshVerifyState = () => {
if (!phone) return
void fetchRealNameStatus(phone).then(res => {
if (res.success) setVerified(res.data)
})
}
const refreshAgreementState = () => {
void fetchAgreementStatus().then(res => {
if (res.success) setAgreementSigned(res.data)
})
}
useEffect(() => {
if (phone) {
void fetchRealNameStatus(phone).then(res => {
if (res.success) setVerified(res.data)
})
}
void fetchAgreementStatus().then(res => {
if (res.success) setAgreementSigned(res.data)
})
}, [phone])
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [realNameOpen, setRealNameOpen] = useState(false)
const [agreementOpen, setAgreementOpen] = useState(false)
const [managerQrOpen, setManagerQrOpen] = useState(false)
const handleSaveInfo = (data: EditInfoValues) => {
// TODO: 接入 src/api/user.ts updateUserProfile
void data
}
// 未返回状态(请求中/失败/无手机号)时默认按「未实名/未签署」展示,
// 接口成功后自动更新为真实状态
const verifyBadge = (() => {
if (verified === true) {
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-600">
<Check className="size-3.5" />
已认证
</span>
)
}
return (
<Button
size="sm"
variant="outline"
className="shrink-0 text-blue-600 hover:text-blue-700"
onClick={() => setRealNameOpen(true)}
>
去实名
</Button>
)
})()
const agreementAction = (mobile = false) => {
const className = mobile
? "mt-3 inline-flex items-center gap-1 self-start text-xs sm:hidden"
: "hidden items-center gap-1 text-xs sm:inline-flex"
if (agreementSigned === true) {
return (
<span className={cn(className, "text-emerald-600")}>
签署网络安全协议
<Check className="size-3.5" />
</span>
)
}
return (
<button
type="button"
onClick={() => setAgreementOpen(true)}
className={cn(className, "text-blue-600 hover:text-blue-700")}
>
签署网络安全协议
</button>
)
}
return (
<Page className="grid grid-cols-1 gap-4 content-start">
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-[6fr_3fr_3fr]">
<div className="flex flex-col overflow-hidden rounded-xl bg-card md:col-span-2 lg:col-span-1">
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-gray-100 px-4 py-2 sm:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<h3 className="shrink-0 text-base font-semibold">账户信息</h3>
{verifyBadge}
</div>
<div className="flex shrink-0 items-center gap-4">
{agreementAction()}
<Button
size="sm"
variant="outline"
className="text-blue-600 hover:text-blue-700"
onClick={() => setEditDialogOpen(true)}
>
编辑
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="flex items-center gap-4 p-5 md:border-r md:border-dashed md:border-gray-200">
<div className="flex w-1/2 shrink-0 flex-col items-center gap-1.5">
<p className="text-center text-[11px] leading-4 text-muted-foreground">
扫二维码联系
<br />
我的客户经理
</p>
<div className="group relative">
<button
type="button"
onClick={() => managerQrUrl && setManagerQrOpen(true)}
className="block size-16 overflow-hidden rounded-lg border border-gray-200"
>
{managerQrUrl ? (
<QrCodeImage text={managerQrUrl} size={64} />
) : (
<img
src="/logo.png"
alt="账号二维码"
className="size-full object-cover"
/>
)}
</button>
{managerQrUrl && (
<div className="pointer-events-none absolute left-20 top-0 z-50 hidden rounded-lg border border-gray-200 bg-white p-2 shadow-lg group-hover:block max-md:hidden">
<QrCodeImage text={managerQrUrl} size={200} />
</div>
)}
</div>
</div>
<div className="flex w-1/2 min-w-0 flex-col justify-center gap-3">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">手机号/用户名</p>
<p className="mt-0.5 truncate text-base font-medium">
{phone || accountInfo.phone}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">密码</p>
<div className="mt-0.5 flex items-center justify-between gap-2">
<span className="font-mono text-sm">********</span>
<button
type="button"
onClick={() => setEditDialogOpen(true)}
className="shrink-0 text-xs text-blue-600 hover:text-blue-700"
>
修改密码
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center p-5">
<dl className="divide-y divide-gray-100">
<InfoRow label="QQ号" value={accountInfo.qq} />
<InfoRow label="微信号" value={accountInfo.wechat} />
<InfoRow label="淘宝会员名" value={accountInfo.taobao} />
<InfoRow label="邮箱" value={accountInfo.email} />
</dl>
{agreementAction(true)}
</div>
</div>
</div>
<BalanceCard
label="可用余额(元)"
amount={fmt(balanceData.balance)}
image={banner}
imageAlt="可用余额"
gradientClassName="from-blue-100 to-blue-50"
>
<Button
size="sm"
className="bg-white text-blue-600 shadow-sm hover:bg-white/80"
>
去提现
</Button>
<Button
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700"
>
去充值
</Button>
</BalanceCard>
<BalanceCard
label="可用H币(个)"
amount={fmt(balanceData.hCoin)}
image={banner2}
imageAlt="可用H币"
gradientClassName="from-cyan-100 to-cyan-50"
>
<Button
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700"
>
去充值
</Button>
</BalanceCard>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[7fr_5fr]">
<div className="flex flex-col overflow-hidden rounded-xl bg-card">
<div className="flex h-12 shrink-0 items-center border-b border-gray-100 px-4 sm:px-5">
<h3 className="text-base font-semibold">消费信息</h3>
</div>
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-[1fr_150px] sm:p-5">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
<StatTile
icon={b3}
alt="今日消费"
label="今日消费"
value={fmt(consumptionData.today)}
/>
<StatTile
icon={b6}
alt="今日退款"
label="今日退款"
value={fmt(consumptionData.todayRefund)}
/>
<StatTile
icon={b8}
alt="今日充值"
label="今日充值"
value={fmt(consumptionData.todayRecharge)}
/>
<StatTile
icon={b5}
alt="当月消费"
label="当月消费"
value={fmt(consumptionData.month)}
/>
<StatTile
icon={b6}
alt="当月退款"
label="当月退款"
value={fmt(consumptionData.monthRefund)}
/>
<StatTile
icon={b9}
alt="当月充值"
label="当月充值"
value={fmt(consumptionData.monthRecharge)}
/>
</div>
<div className="flex flex-row items-center justify-center gap-3 rounded-lg bg-linear-to-br from-blue-50 to-blue-100/60 p-4 text-center ring-1 ring-blue-100 sm:flex-col">
<img src={b7} alt="本年消费" className="size-12 object-contain" />
<div>
<p className="text-2xl font-bold tabular-nums text-blue-600">
{fmt(consumptionData.year)}
</p>
<p className="text-xs text-muted-foreground">本年消费</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col overflow-hidden rounded-xl bg-card">
<div className="flex h-12 shrink-0 items-center border-b border-gray-100 px-4 sm:px-5">
<h3 className="text-base font-semibold">IP账号</h3>
</div>
<div className="grid grid-cols-3 divide-x divide-gray-100">
<IpStat
icon={b10}
alt="使用中"
value={ipAccountData.using}
label="使用中"
chipClassName="bg-emerald-50"
/>
<IpStat
icon={b12}
alt="总个数"
value={ipAccountData.total}
label="总个数"
chipClassName="bg-blue-50"
/>
<IpStat
icon={b11}
alt="已过期"
value={ipAccountData.expired}
label="已过期"
chipClassName="bg-rose-50"
/>
</div>
</div>
</section>
<EditInfoDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
onSave={handleSaveInfo}
/>
<RealNameDialog
open={realNameOpen}
onOpenChange={setRealNameOpen}
onVerified={refreshVerifyState}
/>
<AgreementDialog
open={agreementOpen}
onOpenChange={setAgreementOpen}
onSigned={refreshAgreementState}
/>
<Dialog open={managerQrOpen} onOpenChange={setManagerQrOpen}>
<DialogContent className="sm:max-w-xs">
<DialogHeader>
<DialogTitle className="text-center">我的客户经理</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-2 pb-2">
<div className="rounded-lg border border-gray-200 p-2">
<QrCodeImage text={managerQrUrl} size={200} />
</div>
<p className="text-xs text-muted-foreground">请扫码添加</p>
</div>
</DialogContent>
</Dialog>
</Page>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4 py-2 text-sm">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate text-right">{value || "--"}</dd>
</div>
)
}
function BalanceCard(props: {
label: string
amount: string
image: string
imageAlt: string
gradientClassName?: string
children?: ReactNode
}) {
return (
<div
className={cn(
"flex flex-col justify-between gap-5 overflow-hidden rounded-xl bg-linear-to-b p-5",
props.gradientClassName,
)}
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-gray-600">{props.label}</p>
<img
src={props.image}
alt={props.imageAlt}
className="-mr-1 -mt-1 size-14 object-contain"
/>
</div>
<div className="flex min-w-0 flex-col gap-3">
<p className="truncate text-3xl font-bold tabular-nums">
{props.amount}
</p>
<div className="flex flex-wrap gap-2">{props.children}</div>
</div>
</div>
)
}
function StatTile(props: {
icon: string
alt: string
label: string
value: string
}) {
return (
<div className="flex min-w-0 items-center gap-3 rounded-lg bg-gray-50 p-3 ring-1 ring-gray-100">
<img
src={props.icon}
alt={props.alt}
className="size-9 shrink-0 object-contain"
/>
<div className="min-w-0">
<p className="truncate text-xs text-muted-foreground">{props.label}</p>
<p className="truncate text-lg font-semibold tabular-nums">
{props.value}
</p>
</div>
</div>
)
}
function IpStat(props: {
icon: string
alt: string
value: number
label: string
chipClassName?: string
}) {
return (
<div className="flex flex-col items-center justify-center gap-2.5 p-3 text-center sm:p-4">
<div
className={cn(
"flex size-14 shrink-0 items-center justify-center rounded-full",
props.chipClassName,
)}
>
<img
src={props.icon}
alt={props.alt}
className="size-8 object-contain"
/>
</div>
<p className="text-2xl font-bold tabular-nums">{props.value}</p>
<p className="text-xs text-muted-foreground">{props.label}</p>
</div>
)
}