Files
juip-web/src/admin/dashboard/page.tsx

499 lines
16 KiB
TypeScript
Raw Normal View History

2026-09-03 14:37:18 +08:00
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"
2026-09-03 14:37:18 +08:00
import RealNameDialog from "@/components/composites/dialogs/realNameDialog"
2026-07-24 18:00:22 +08:00
import Page from "@/components/page"
2026-09-03 14:37:18 +08:00
import { QrCodeImage } from "@/components/qrCodeImage"
2026-07-30 13:55:28 +08:00
import { Button } from "@/components/ui/button"
2026-09-03 14:37:18 +08:00
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
2026-07-30 13:55:28 +08:00
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"
2026-08-31 14:49:28 +08:00
import {
MOCK_ACCOUNT_INFO,
MOCK_BALANCE,
MOCK_CONSUMPTION,
MOCK_IP_STATS,
} from "./mock"
2026-07-24 18:00:22 +08:00
2026-09-03 14:37:18 +08:00
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]
: "",
}
}
2026-07-30 13:55:28 +08:00
2026-08-31 14:49:28 +08:00
export default function DashboardPage() {
// ========== 数据定义(待接口就绪后改为 useEffect 拉取) ==========
const balanceData = MOCK_BALANCE
const accountInfo = MOCK_ACCOUNT_INFO
const consumptionData = MOCK_CONSUMPTION
const ipAccountData = MOCK_IP_STATS
2026-09-03 14:37:18 +08:00
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])
2026-07-30 13:55:28 +08:00
const [editDialogOpen, setEditDialogOpen] = useState(false)
2026-09-03 14:37:18 +08:00
const [realNameOpen, setRealNameOpen] = useState(false)
const [agreementOpen, setAgreementOpen] = useState(false)
const [managerQrOpen, setManagerQrOpen] = useState(false)
const handleSaveInfo = (data: EditInfoValues) => {
2026-08-31 14:49:28 +08:00
// TODO: 接入 src/api/user.ts updateUserProfile
void data
2026-07-30 13:55:28 +08:00
}
2026-09-03 14:37:18 +08:00
// 未返回状态(请求中/失败/无手机号)时默认按「未实名/未签署」展示,
// 接口成功后自动更新为真实状态
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>
)
})()
2026-07-30 13:55:28 +08:00
2026-09-03 14:37:18 +08:00
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}
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
<div className="flex shrink-0 items-center gap-4">
{agreementAction()}
2026-07-30 13:55:28 +08:00
<Button
size="sm"
variant="outline"
2026-09-03 14:37:18 +08:00
className="text-blue-600 hover:text-blue-700"
onClick={() => setEditDialogOpen(true)}
2026-07-30 13:55:28 +08:00
>
2026-09-03 14:37:18 +08:00
编辑
2026-07-30 13:55:28 +08:00
</Button>
</div>
</div>
2026-09-03 14:37:18 +08:00
<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>
2026-09-03 14:37:18 +08:00
<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>
2026-09-03 14:37:18 +08:00
<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>
2026-09-03 14:37:18 +08:00
<BalanceCard
label="可用余额(元)"
amount={fmt(balanceData.balance)}
image={banner}
imageAlt="可用余额"
gradientClassName="from-blue-100 to-blue-50"
>
<Button
size="sm"
2026-09-03 14:37:18 +08:00
className="bg-white text-blue-600 shadow-sm hover:bg-white/80"
>
2026-09-03 14:37:18 +08:00
去提现
</Button>
2026-09-03 14:37:18 +08:00
<Button
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700"
>
去充值
</Button>
</BalanceCard>
2026-09-03 14:37:18 +08:00
<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>
2026-07-30 13:55:28 +08:00
2026-09-03 14:37:18 +08:00
<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>
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
<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)}
/>
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
<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>
2026-07-30 13:55:28 +08:00
</div>
</div>
2026-09-03 14:37:18 +08:00
</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>
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
<div className="grid grid-cols-3 divide-x divide-gray-100">
<IpStat
icon={b10}
alt="使用中"
value={ipAccountData.using}
label="使用中"
chipClassName="bg-emerald-50"
2026-07-30 13:55:28 +08:00
/>
2026-09-03 14:37:18 +08:00
<IpStat
icon={b12}
alt="总个数"
value={ipAccountData.total}
label="总个数"
chipClassName="bg-blue-50"
2026-07-30 13:55:28 +08:00
/>
2026-09-03 14:37:18 +08:00
<IpStat
icon={b11}
alt="已过期"
value={ipAccountData.expired}
label="已过期"
chipClassName="bg-rose-50"
2026-07-30 13:55:28 +08:00
/>
</div>
</div>
2026-09-03 14:37:18 +08:00
</section>
2026-07-30 13:55:28 +08:00
2026-09-03 14:37:18 +08:00
<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} />
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
<p className="text-xs text-muted-foreground">请扫码添加</p>
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
</DialogContent>
</Dialog>
</Page>
)
}
2026-09-03 14:37:18 +08:00
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>
)
}
2026-09-03 14:37:18 +08:00
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>
2026-07-30 13:55:28 +08:00
</div>
2026-09-03 14:37:18 +08:00
</div>
)
}
2026-09-03 14:37:18 +08:00
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"
2026-07-30 13:55:28 +08:00
/>
2026-09-03 14:37:18 +08:00
<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>
2026-07-30 13:55:28 +08:00
)
2026-07-24 18:00:22 +08:00
}