499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|