diff --git a/.env b/.env index 0f15930..415afbc 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ -VITE_API_BASE_URL=http://192.168.0.15:8080 \ No newline at end of file +VITE_API_BASE_URL=http://192.168.0.15:8080 +VITE_CLIENT_ID=web +VITE_CLIENT_SECRET=web \ No newline at end of file diff --git a/bun.lock b/bun.lock index 0d8c165..e6aeef8 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@fontsource-variable/geist": "^5.2.9", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.17.0", @@ -342,6 +343,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.0.tgz", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], diff --git a/package.json b/package.json index c523bea..b4da69b 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,38 @@ { - "name": "juip-web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite --host", - "build": "tsc -b && vite build", - "lint": "biome check --write" - }, - "dependencies": { - "@fontsource-variable/geist": "^5.2.9", - "@tailwindcss/vite": "^4.3.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^1.17.0", - "radix-ui": "^1.5.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-router-dom": "^7.17.0", - "shadcn": "^4.11.0", - "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.0", - "tw-animate-css": "^1.4.0" - }, - "devDependencies": { - "@biomejs/biome": "2.4.16", - "@types/node": "^25.9.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "globals": "^17.6.0", - "typescript": "~6.0.2", - "vite": "^8.0.12" - }, - "packageManager": "bun@1.3.2" + "name": "juip-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc -b && vite build", + "lint": "biome check --write" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "radix-ui": "^1.5.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.17.0", + "shadcn": "^4.11.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@biomejs/biome": "2.4.16", + "@types/node": "^25.9.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "vite": "^8.0.12" + }, + "packageManager": "bun@1.3.2" } diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..8a5c70d 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,9 @@ - \ No newline at end of file + + + + + + + + + diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/admin/assets/b10.webp b/src/admin/assets/b10.webp new file mode 100644 index 0000000..529b7d8 Binary files /dev/null and b/src/admin/assets/b10.webp differ diff --git a/src/admin/assets/b11.webp b/src/admin/assets/b11.webp new file mode 100644 index 0000000..cc7bd20 Binary files /dev/null and b/src/admin/assets/b11.webp differ diff --git a/src/admin/assets/b12.webp b/src/admin/assets/b12.webp new file mode 100644 index 0000000..4e0151c Binary files /dev/null and b/src/admin/assets/b12.webp differ diff --git a/src/admin/assets/b3.webp b/src/admin/assets/b3.webp new file mode 100644 index 0000000..e40c110 Binary files /dev/null and b/src/admin/assets/b3.webp differ diff --git a/src/admin/assets/b4.webp b/src/admin/assets/b4.webp new file mode 100644 index 0000000..9d776b6 Binary files /dev/null and b/src/admin/assets/b4.webp differ diff --git a/src/admin/assets/b5.webp b/src/admin/assets/b5.webp new file mode 100644 index 0000000..6d62048 Binary files /dev/null and b/src/admin/assets/b5.webp differ diff --git a/src/admin/assets/b6.webp b/src/admin/assets/b6.webp new file mode 100644 index 0000000..68677d0 Binary files /dev/null and b/src/admin/assets/b6.webp differ diff --git a/src/admin/assets/b7.webp b/src/admin/assets/b7.webp new file mode 100644 index 0000000..c619d22 Binary files /dev/null and b/src/admin/assets/b7.webp differ diff --git a/src/admin/assets/b8.webp b/src/admin/assets/b8.webp new file mode 100644 index 0000000..5ee9571 Binary files /dev/null and b/src/admin/assets/b8.webp differ diff --git a/src/admin/assets/b9.webp b/src/admin/assets/b9.webp new file mode 100644 index 0000000..f70d363 Binary files /dev/null and b/src/admin/assets/b9.webp differ diff --git a/src/admin/assets/banner.webp b/src/admin/assets/banner.webp new file mode 100644 index 0000000..5dc8c25 Binary files /dev/null and b/src/admin/assets/banner.webp differ diff --git a/src/admin/assets/banner2.webp b/src/admin/assets/banner2.webp new file mode 100644 index 0000000..638c8fe Binary files /dev/null and b/src/admin/assets/banner2.webp differ diff --git a/src/admin/channels/page.tsx b/src/admin/channels/page.tsx new file mode 100644 index 0000000..bfb0580 --- /dev/null +++ b/src/admin/channels/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function ChannelsPage() { + return IP管理 +} diff --git a/src/admin/clients.tsx b/src/admin/clients.tsx index 93620b5..9541ab4 100644 --- a/src/admin/clients.tsx +++ b/src/admin/clients.tsx @@ -1,9 +1,19 @@ import { - IdCard, - MessageCircleMoreIcon, + Archive, + CalendarClock, + CircleDollarSignIcon, + ClipboardClock, + ClipboardList, + Clock1, + FolderCode, + LayoutDashboard, PanelLeftCloseIcon, PanelLeftOpenIcon, - UserRound, + ReceiptJapaneseYenIcon, + Router, + ShoppingBag, + TicketPercent, + UserCheck, } from "lucide-react" import { type ReactNode, useState } from "react" import { Link } from "react-router-dom" @@ -16,19 +26,20 @@ import { } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import logoAvatar from "./assets/logo-avatar.svg" +import { useLayout } from "./layout-context" -export function Shell(props: { children: ReactNode; navbar: boolean }) { - const { navbar } = props +export function Shell(props: { children: ReactNode }) { + const { navbar } = useLayout() return (
{props.children} @@ -36,49 +47,45 @@ export function Shell(props: { children: ReactNode; navbar: boolean }) { ) } -export function Mask(props: { - navbar: boolean - setNavbar: (v: boolean) => void -}) { - const { navbar, setNavbar } = props +export function Content(props: { children: ReactNode }) { + const { navbar } = useLayout() + return ( +
+ {props.children} +
+ ) +} +export function Mask() { + const { navbar, setNavbar } = useLayout() return ( -
- - - - 实名认证 - - -
- - - 联系专属客服 -
@@ -123,19 +110,11 @@ export function Header(props: { navbar: boolean; toggleNavbar: () => void }) { } function UserCenter() { - return
{"用户"}
+ return
用户
} -export function Navbar(props: { - navbar: boolean - setNavbar: (v: boolean) => void -}) { - const { navbar, setNavbar } = props - const closeNavBarIfMobile = () => { - if (window.innerWidth < 768) { - setNavbar(false) - } - } +export function Navbar() { + const { navbar } = useLayout() return ( ) } -function NavTitle(props: { label: string }) { - return ( -

- - {props.label} - - -

- ) -} - function NavItem(props: { href: string icon?: ReactNode @@ -212,6 +228,7 @@ function NavItem(props: { expand?: boolean }) { const [open, setOpen] = useState(false) + const { setNavbar } = useLayout() const handleOpenChange = (open: boolean) => { if (!props.expand) { @@ -219,6 +236,12 @@ function NavItem(props: { } } + const closeNavBarIfMobile = () => { + if (window.innerWidth < 768) { + setNavbar(false) + } + } + return ( @@ -230,6 +253,7 @@ function NavItem(props: { `group-data-[expand=true]:px-4`, )} to={props.href} + onClick={closeNavBarIfMobile} > {props.icon} diff --git a/src/admin/coupon/page.tsx b/src/admin/coupon/page.tsx new file mode 100644 index 0000000..919a30e --- /dev/null +++ b/src/admin/coupon/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function CouponPage() { + return 优惠券 +} diff --git a/src/admin/dashboard/page.tsx b/src/admin/dashboard/page.tsx index d117759..b0432dc 100644 --- a/src/admin/dashboard/page.tsx +++ b/src/admin/dashboard/page.tsx @@ -1,5 +1,415 @@ +import { useState } from "react" +import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog" +import DataTable from "@/components/data-table" import Page from "@/components/page" +import { Button } from "@/components/ui/button" +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" export default function DashboardPage() { - return 账户总览页面 + const balanceData = { + balance: 68.6, + hCoin: 68.6, + } + + const accountInfo = { + phone: "191****1234", + qq: "123456123", + wechat: "123456123", + email: "123456123@qq.com", + taobao: "123456123", + } + + const recentOrders = [ + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + ] + + const consumptionData = { + today: 2365, + month: 312365, + todayRefund: 2365, + year: 256562, + monthRefund: 30, + todayRecharge: 50, + monthRecharge: 20, + } + + const ipAccountData = { + using: 30, + total: 50, + expired: 20, + yearConsumption: 2365, + } + const [editDialogOpen, setEditDialogOpen] = useState(false) + const handleSaveInfo = (data: any) => { + console.log("保存的信息:", data) + // 这里调用你的 API 保存数据 + } + return ( + + {/* banner */} +
+
+
+
+ + 可用余额(元) + +
+
+

+ {balanceData.balance} +

+
+ + +
+
+
+ banner +
+ +
+
+
+

可用H币(个)

+
+
+

+ {balanceData.hCoin} +

+ +
+
+ banner2 +
+
+ + {/* 最近订单 */} +
+

最近订单

+
+ row.original.price.toFixed(2), + }, + { accessorKey: "connections", header: "总连接数" }, + { accessorKey: "account", header: "账号" }, + { + accessorKey: "orderAmount", + header: "订单金额(元)", + cell: ({ row }) => row.original.orderAmount.toFixed(2), + }, + { + accessorKey: "paidAmount", + header: "实付金额(元)", + cell: ({ row }) => row.original.paidAmount.toFixed(2), + }, + ]} + /> +
+
+ + {/* 消费信息 */} +
+
+

消费信息

+
+
+
+ 今日消费 +
+

今日消费

+

+ {consumptionData.today} +

+
+
+
+ 当月消费 +
+

当月消费

+

+ {consumptionData.month} +

+
+
+
+ 今日退款 +
+

今日退款

+

+ {consumptionData.todayRefund} +

+
+
+
+ 本年消费 +
+

本年消费

+

+ {consumptionData.year} +

+
+
+
+ 今日充值 +
+

今日充值

+

+ {consumptionData.todayRecharge} +

+
+
+
+ 当月充值 +
+

当月充值

+

+ {consumptionData.monthRecharge} +

+
+
+
+ 当月退款 +
+

当月退款

+

+ {consumptionData.monthRefund} +

+
+
+
+
+ + {/* 账号信息 */} +
+

账号信息

+
+
+ {accountInfo.phone} + +
+ 账号二维码 +
+
+ +
+
+ 密码 + + ******** + +
+
+ QQ + + {accountInfo.qq || "未绑定"} + +
+
+ 微信号 + + {accountInfo.wechat || "未绑定"} + +
+
+ 淘宝会员 + + {accountInfo.taobao || "未绑定"} + +
+
+ 邮箱 + + {accountInfo.email || "未绑定"} + +
+
+
+
+ +
+
+ + {/* IP账号 */} +
+

IP账号

+
+
+
+ 使用中图标 +
+

{ipAccountData.using}

+

使用中

+
+
+
+ 总个数图标 +
+

{ipAccountData.total}

+

总个数

+
+
+
+ 已过期图标 +
+

{ipAccountData.expired}

+

已过期

+
+
+
+ +
+ ) } diff --git a/src/admin/effects.tsx b/src/admin/effects.tsx new file mode 100644 index 0000000..e515624 --- /dev/null +++ b/src/admin/effects.tsx @@ -0,0 +1,59 @@ +import { type ReactNode, useEffect } from "react" +import { useLayout } from "./layout-context" + +type Breakpoints = { + sm: boolean + md: boolean + lg: boolean + xl: boolean +} + +const breakpoints: Breakpoints = { sm: false, md: false, lg: false, xl: false } + +export function useBreakpoint() { + return breakpoints +} + +export const bp = breakpoints + +export default function Effects(props: { children?: ReactNode }) { + const { setNavbar } = useLayout() + + useEffect(() => { + const sm = window.matchMedia("(width >= 40rem)") + const md = window.matchMedia("(width >= 48rem)") + const lg = window.matchMedia("(width >= 64rem)") + const xl = window.matchMedia("(width >= 80rem)") + + breakpoints.sm = sm.matches + breakpoints.md = md.matches + breakpoints.lg = lg.matches + breakpoints.xl = xl.matches + + setNavbar(md.matches) + + sm.addEventListener("change", (e) => { + breakpoints.sm = e.matches + }) + md.addEventListener("change", (e) => { + breakpoints.md = e.matches + setNavbar(e.matches) + }) + lg.addEventListener("change", (e) => { + breakpoints.lg = e.matches + }) + xl.addEventListener("change", (e) => { + breakpoints.xl = e.matches + }) + + const html = document.documentElement + const originalFontSize = html.style.fontSize + html.style.fontSize = "16px" + + return () => { + html.style.fontSize = originalFontSize + } + }, [setNavbar]) + + return <>{props.children} +} diff --git a/src/admin/funds/page.tsx b/src/admin/funds/page.tsx new file mode 100644 index 0000000..63ca757 --- /dev/null +++ b/src/admin/funds/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function FundsPage() { + return 资金明细 +} diff --git a/src/admin/layout-context.tsx b/src/admin/layout-context.tsx new file mode 100644 index 0000000..15e0485 --- /dev/null +++ b/src/admin/layout-context.tsx @@ -0,0 +1,27 @@ +import { createContext, type ReactNode, useContext, useState } from "react" + +type LayoutState = { + navbar: boolean + setNavbar: (navbar: boolean) => void + toggleNavbar: () => void +} + +const LayoutContext = createContext(null) + +export function LayoutProvider(props: { children: ReactNode }) { + const [navbar, setNavbar] = useState(false) + + const toggleNavbar = () => setNavbar(!navbar) + + return ( + + {props.children} + + ) +} + +export function useLayout() { + const ctx = useContext(LayoutContext) + if (!ctx) throw new Error("useLayout must be used within LayoutProvider") + return ctx +} diff --git a/src/admin/layout.tsx b/src/admin/layout.tsx index d6623cb..92cf199 100644 --- a/src/admin/layout.tsx +++ b/src/admin/layout.tsx @@ -1,50 +1,50 @@ -import { useState } from "react" import { Outlet } from "react-router-dom" import { Content, Header, Mask, Navbar, Shell } from "./clients" +import Effects from "./effects" +import { LayoutProvider } from "./layout-context" export default function AdminLayout() { - const [navbar, setNavbar] = useState(true) - - const toggleNavbar = () => setNavbar(!navbar) return ( -
- -
- -
+ + +
+ +
+ +
-
-
-
+
+
+
- - - - - - - - - - - - + + + + + + + + + + + + - -
+ + - {/* 用 Outlet 替代 {props.children} */} - - - -
+ + + +
+ ) } diff --git a/src/admin/whitelist/page.tsx b/src/admin/whitelist/page.tsx new file mode 100644 index 0000000..db4be2c --- /dev/null +++ b/src/admin/whitelist/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function WhitelistPage() { + return 白名单 +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..e09c753 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,101 @@ +import type { ApiResponse } from "@/lib/api" +import { callByDevice, callByUser } from "./base" + +// 用户类型 +export interface User { + id: number + username: string + // ... +} + +export type LoginMode = "phone_code" | "password" + +export async function login(props: { + username: string + password: string + remember: boolean + mode: LoginMode +}): Promise { + // 使用 callByDevice 去拿 Token (设备级认证) + const result = await callByDevice<{ + access_token: string + refresh_token: string + expires_in: number + }>("/api/auth/token", { + ...props, + grant_type: "password", + login_type: props.mode, + }) + + if (!result.success) return result + + // 登录成功,存入 localStorage + const { access_token, refresh_token, expires_in } = result.data + localStorage.setItem("auth_token", access_token) + localStorage.setItem("auth_refresh", refresh_token) + localStorage.setItem( + "auth_expires_in", + String(Date.now() + expires_in * 1000), + ) + + return { success: true, data: undefined } +} + +export async function logout() { + const accessToken = localStorage.getItem("auth_token") + const refreshToken = localStorage.getItem("auth_refresh") + + if (accessToken && refreshToken) { + await callByUser("/api/auth/revoke", { + access_token: accessToken, + refresh_token: refreshToken, + }) + } + + // 清除前端本地缓存 + localStorage.removeItem("auth_token") + localStorage.removeItem("auth_refresh") + localStorage.removeItem("auth_expires_in") + + // 强制跳转到登录页 + window.location.href = "/login" +} + +// 获取当前用户信息 (校验 Token 是否有效) +export async function getProfile() { + return await callByUser("/api/auth/introspect") +} + +// 刷新 Token +export async function refreshAuth(): Promise<{ + access_token: string + refresh_token: string +} | null> { + const refreshToken = localStorage.getItem("auth_refresh") + if (!refreshToken) return null + + const resp = await callByDevice<{ + access_token: string + refresh_token: string + expires_in: number + }>(`/api/auth/token`, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }) + + if (!resp.success) { + localStorage.removeItem("auth_refresh") + return null + } + + // 刷新成功,更新本地缓存 + const { access_token, refresh_token, expires_in } = resp.data + localStorage.setItem("auth_token", access_token) + localStorage.setItem("auth_refresh", refresh_token) + localStorage.setItem( + "auth_expires_in", + String(Date.now() + expires_in * 1000), + ) + + return { access_token, refresh_token } +} diff --git a/src/api/base.ts b/src/api/base.ts new file mode 100644 index 0000000..ccfc961 --- /dev/null +++ b/src/api/base.ts @@ -0,0 +1,158 @@ +import { + API_BASE_URL, + type ApiResponse, + CLIENT_ID, + CLIENT_SECRET, + UnauthorizedError, +} from "@/lib/api" + +let deviceToken: string | null = null +let deviceTokenExpire: Date | null = null + +async function call( + endpoint: string, + body?: string, + auth?: string, +): Promise> { + try { + const headers: HeadersInit = { + "Content-Type": "application/json", + } + if (auth) headers["Authorization"] = auth + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "POST", + headers, + body, + }) + + if (response.status === 401) { + // 如果是 刷新Token 的接口本身报 401,说明 RefreshToken 彻底失效,直接抛出错误 + if ( + endpoint.includes("/auth/token") || + endpoint.includes("/auth/revoke") + ) { + throw UnauthorizedError + } + + // 动态引入 auth.ts 里的刷新函数,避免循环引用报错 + const { refreshAuth } = await import("./auth") + + // 尝试刷新 Token + const newTokens = await refreshAuth() + + if (newTokens) { + // 刷新成功,用新 Token 重试当前请求(递归调用) + return call(endpoint, body, `Bearer ${newTokens.access_token}`) + } else { + // 刷新失败,抛出 401 错误,让上层处理跳转 + throw UnauthorizedError + } + } + + const contentType = response.headers.get("Content-Type") ?? "text/plain" + + if (contentType.includes("text/plain")) { + const text = await response.text() + if (!response.ok) { + return { + success: false, + status: response.status, + message: text || "请求失败", + } + } + return { success: true, data: undefined as T } + } + + if (contentType.includes("application/json")) { + const json = await response.json() + if (!response.ok) { + return { + success: false, + status: response.status, + message: json.message || json.error_description || "请求失败", + } + } + return { success: true, data: json } + } + + throw new Error(`无法解析响应数据: ${contentType}`) + } catch (e) { + console.error("后端请求异常:", e) + return { + success: false, + status: 500, + message: (e as Error).message || "网络错误", + } + } +} + +// ====================== +// Public +// ====================== +async function callPublic( + endpoint: string, + data?: unknown, +): Promise> { + return call(endpoint, data ? JSON.stringify(data) : undefined) +} + +// ====================== +// Device +// ====================== +async function callByDevice( + endpoint: string, + data?: unknown, +): Promise> { + // 检查内存中的 Token 是否过期 + if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) { + // 用 btoa 生成 Basic 认证头 + const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`) + const resp = await call<{ + access_token: string + expires_in: number + }>( + "/api/auth/token", + JSON.stringify({ grant_type: "client_credentials" }), + `Basic ${basic}`, + ) + + if (!resp.success) { + return resp + } + deviceToken = resp.data.access_token + // 提前 60 秒过期,防止临界点失效 + deviceTokenExpire = new Date( + Date.now() + (resp.data.expires_in - 60) * 1000, + ) + } + + return call( + endpoint, + data ? JSON.stringify(data) : undefined, + `Bearer ${deviceToken}`, + ) +} + +// ====================== +// User +// ====================== +async function callByUser( + endpoint: string, + data?: unknown, +): Promise> { + // 这里我们假设用户登录后的 Token 存在 localStorage 里 + // 如果你是用 Cookie 存 Token,这里用 js-cookie 的 get 方法替换即可 + const token = localStorage.getItem("auth_token") + + if (!token) { + throw UnauthorizedError + } + return call( + endpoint, + data ? JSON.stringify(data) : undefined, + `Bearer ${token}`, + ) +} + +export { callByDevice, callByUser, callPublic } diff --git a/src/components/authGuard.tsx b/src/components/authGuard.tsx new file mode 100644 index 0000000..3ffde57 --- /dev/null +++ b/src/components/authGuard.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react" +import { Navigate, useLocation } from "react-router-dom" +import { getProfile, refreshAuth } from "@/api/auth" + +export default function AuthGuard({ children }: { children: React.ReactNode }) { + const location = useLocation() + const [isValid, setIsValid] = useState(null) + + useEffect(() => { + const checkAuth = async () => { + const accessToken = localStorage.getItem("auth_token") + const refreshToken = localStorage.getItem("auth_refresh") + + // 没 Token,判定无效 + if (!accessToken && !refreshToken) { + setIsValid(false) + return + } + + // 尝试用现有 AccessToken 获取用户信息 + const profileResp = await getProfile() + if (profileResp.success) { + setIsValid(true) + return + } + + // AccessToken 无效 尝试用 RefreshToken 刷新 + if (refreshToken) { + const newTokens = await refreshAuth() + if (newTokens) { + setIsValid(true) + return + } + } + + // 刷新失败或无 RefreshToken,为未登录 + localStorage.removeItem("auth_token") + localStorage.removeItem("auth_refresh") + localStorage.removeItem("auth_expires_in") + setIsValid(false) + } + + checkAuth() + }, []) + + if (isValid === null) + return ( +
+ 验证身份中... +
+ ) + + // if (!isValid) { + // return ( + // + // ) + // } + + return <>{children} +} diff --git a/src/components/composites/dialogs/editInfoDialog.tsx b/src/components/composites/dialogs/editInfoDialog.tsx new file mode 100644 index 0000000..4d4de77 --- /dev/null +++ b/src/components/composites/dialogs/editInfoDialog.tsx @@ -0,0 +1,149 @@ +import { Eye, EyeOff } from "lucide-react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface EditInfoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + initialData?: { + username: string + taobao: string + password: string + email: string + wechat: string + qq: string + } + onSave?: (data: any) => void +} + +export function EditInfoDialog({ + open, + onOpenChange, + initialData = { + username: "191****1234", + taobao: "191****1234", + password: "********", + email: "123456123@qq.com", + wechat: "", + qq: "", + }, + onSave, +}: EditInfoDialogProps) { + const [formData, setFormData] = useState(initialData) + const [showPassword, setShowPassword] = useState(false) + + const handleChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + const handleSubmit = () => { + onSave?.(formData) + onOpenChange(false) + } + + return ( + + + + + 编辑信息 + + + +
+ {/* 用户名 */} +
+ + handleChange("username", e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 淘宝会员名 */} +
+ + handleChange("taobao", e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 密码 */} +
+ +
+ handleChange("password", e.target.value)} + className="h-11 bg-gray-50 border-gray-200 pr-10" + /> + +
+
+ + {/* 邮箱 */} +
+ + handleChange("email", e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 微信 */} +
+ + handleChange("wechat", e.target.value)} + placeholder="请输入" + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* QQ */} +
+ + handleChange("qq", e.target.value)} + placeholder="请输入" + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 保存按钮 */} + +
+
+
+ ) +} diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx new file mode 100644 index 0000000..d62a085 --- /dev/null +++ b/src/components/data-table.tsx @@ -0,0 +1,123 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { Loader } from "lucide-react" +import { Pagination, type PaginationProps } from "@/components/ui/pagination" +import { + TableBody, + TableCell, + TableHead, + TableHeader, + Table as TableRoot, + TableRow, +} from "@/components/ui/table" +import { cn } from "@/lib/utils" + +export type DataTableProps = { + data: T[] + status: "load" | "done" | "fail" + columns: ColumnDef[] + pagination?: PaginationProps + classNames?: { + headRow?: string + dataRow?: string + } +} + +export default function DataTable>( + props: DataTableProps, +) { + const table = useReactTable({ + data: props.data, + columns: props.columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + rowCount: props.pagination?.total ?? props.data.length, + state: { + pagination: { + pageIndex: props.pagination?.page ?? 0, + pageSize: props.pagination?.size ?? props.data.length, + }, + columnFilters: [], + }, + autoResetPageIndex: false, + }) + console.log("data length:", props.data.length) + console.log("pageSize:", props.pagination?.size ?? props.data.length) + console.log("rows:", table.getRowModel().rows.length) + return ( + <> + {/* 数据表 */} +
+ + + {table.getHeaderGroups().map(group => ( + + {group.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {props.status === "fail" ? ( + + + 加载失败 + + + ) : !props.data?.length ? ( + + + 暂无数据 + + + ) : ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + )} + + + {props.status === "load" && ( +
+ + 加载中 +
+ )} +
+ + {/* 分页器 */} + {props.pagination && } + + ) +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index f59876f..1b57dc7 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,9 +1,8 @@ -import * as React from "react" -import { Dialog as DialogPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils/index" -import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" +import type * as React from "react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils/index" function Dialog({ ...props @@ -38,7 +37,7 @@ function DialogOverlay({ data-slot="dialog-overlay" className={cn( "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", - className + className, )} {...props} /> @@ -60,7 +59,7 @@ function DialogContent({ data-slot="dialog-content" className={cn( "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", - className + className, )} {...props} > @@ -72,8 +71,7 @@ function DialogContent({ className="absolute top-2 right-2" size="icon-sm" > - + Close @@ -106,7 +104,7 @@ function DialogFooter({ data-slot="dialog-footer" className={cn( "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", - className + className, )} {...props} > @@ -129,7 +127,7 @@ function DialogTitle({ data-slot="dialog-title" className={cn( "font-heading text-base leading-none font-medium", - className + className, )} {...props} /> @@ -145,7 +143,7 @@ function DialogDescription({ data-slot="dialog-description" className={cn( "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", - className + className, )} {...props} /> diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..71bc1c9 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils/index" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..bb596fb --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,304 @@ +"use client" + +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" +import type * as React from "react" +import { useEffect, useState } from "react" + +import { cn } from "@/lib/utils" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select" + +export interface PaginationProps { + page: number + size: number + total: number + sizeOptions?: number[] + onPageChange?: (page: number) => void + onSizeChange?: (size: number) => void + className?: string +} + +function Pagination({ + page, + size, + total, + sizeOptions = [10, 20, 50, 100], + onPageChange, + onSizeChange, + className, +}: PaginationProps) { + const [currentPage, setCurrentPage] = useState(page) + const totalPages = Math.ceil(total / size) + + // 同步外部 page 变化 + useEffect(() => { + setCurrentPage(page) + }, [page]) + + // 分页器逻辑 + const generatePaginationItems = () => { + // 最多显示7个页码,其余用省略号 + const SIBLINGS = 1 // 当前页左右各显示的页码数 + const DOTS = -1 // 省略号标记 + + if (totalPages <= 7) { + // 总页数少于7,全部显示 + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + // 是否需要显示左边的省略号 + const showLeftDots = currentPage > 2 + SIBLINGS + + // 是否需要显示右边的省略号 + const showRightDots = currentPage < totalPages - (2 + SIBLINGS) + + if (showLeftDots && showRightDots) { + // 两边都有省略号 + const leftSiblingIndex = Math.max(currentPage - SIBLINGS, 1) + const rightSiblingIndex = Math.min(currentPage + SIBLINGS, totalPages) + + return [ + 1, + DOTS, + ...Array.from( + { length: rightSiblingIndex - leftSiblingIndex + 1 }, + (_, i) => leftSiblingIndex + i, + ), + DOTS, + totalPages, + ] + } + + if (!showLeftDots && showRightDots) { + // 只有右边有省略号 + return [ + ...Array.from({ length: 3 + SIBLINGS * 2 }, (_, i) => i + 1), + DOTS, + totalPages, + ] + } + + if (showLeftDots && !showRightDots) { + // 只有左边有省略号 + return [ + 1, + DOTS, + ...Array.from( + { length: 3 + SIBLINGS * 2 }, + (_, i) => totalPages - (3 + SIBLINGS * 2) + i + 1, + ), + ] + } + + return [] + } + + const handlePageChange = (newPage: number) => { + if (newPage < 1 || newPage > totalPages || newPage === currentPage) { + return + } + setCurrentPage(newPage) + onPageChange?.(newPage) + } + + const handlePageSizeChange = (newSize: string) => { + const parsedSize = parseInt(newSize, 10) + if (onSizeChange) { + onSizeChange(parsedSize) + } + } + + const paginationItems = generatePaginationItems() + + return ( +
+
+ 共 {total} 条记录,每页 + + 条 +
+ + + + + handlePageChange(currentPage - 1)} + className={ + currentPage === 1 ? "opacity-50 pointer-events-none" : "" + } + /> + + + {paginationItems.map((pageNum, index) => { + if (pageNum === -1) { + return ( + + + + ) + } + + return ( + + handlePageChange(pageNum)} + > + {pageNum} + + + ) + })} + + + handlePageChange(currentPage + 1)} + className={ + currentPage === totalPages + ? "opacity-50 pointer-events-none" + : "" + } + /> + + + +
+ ) +} + +function PaginationLayout({ + className, + ...props +}: React.ComponentProps<"nav">) { + return ( +