diff --git a/admin/src/components/layout/authenticated-layout.tsx b/admin/src/components/layout/authenticated-layout.tsx index 5306774e..7716dac0 100644 --- a/admin/src/components/layout/authenticated-layout.tsx +++ b/admin/src/components/layout/authenticated-layout.tsx @@ -20,9 +20,25 @@ export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) { const { auth } = useAuthStore() const navigate = useNavigate() const location = useLocation() - const [ready, setReady] = useState(!auth.accessToken) + // demo 模式(GitHub Pages 预览):跳过登录校验,直接放行看 UI + const demoMode = import.meta.env.VITE_DEMO_MODE === '1' + const [ready, setReady] = useState(!auth.accessToken || demoMode) useEffect(() => { + if (demoMode) { + if (!auth.user) { + auth.setUser({ + accountNo: 'demo', + email: 'demo@cloudsteps.example', + role: ['admin'], + exp: Date.now() + 24 * 60 * 60 * 1000, + displayName: '演示账号', + username: 'demo', + }) + } + return + } + if (!auth.accessToken) { navigate({ to: '/sign-in', @@ -86,7 +102,7 @@ export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [auth.accessToken]) - if (!auth.accessToken || !ready) { + if ((!auth.accessToken && !demoMode) || !ready) { return (
正在验证登录状态… diff --git a/admin/src/features/scenario-sessions/index.tsx b/admin/src/features/scenario-sessions/index.tsx index a9cbd689..55c316e8 100644 --- a/admin/src/features/scenario-sessions/index.tsx +++ b/admin/src/features/scenario-sessions/index.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react' import { Eye, Loader2 } from 'lucide-react' import { toast } from 'sonner' import { get } from '@/lib/api' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Table, TableBody, @@ -12,7 +12,6 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Badge } from '@/components/ui/badge' import { AdminPage } from '@/components/admin-page' import { UserPicker } from '@/features/inbox-notifications/user-picker' import { @@ -98,11 +97,7 @@ export function ScenarioSessionsPage() {
{userId && ( - )} diff --git a/admin/src/features/settings/index.tsx b/admin/src/features/settings/index.tsx index 38f9ebad..64bc0a36 100644 --- a/admin/src/features/settings/index.tsx +++ b/admin/src/features/settings/index.tsx @@ -1,5 +1,13 @@ import { Outlet, useRouterState } from '@tanstack/react-router' -import { Bell, History, Palette, ScrollText, UserCog } from 'lucide-react' +import { + Bell, + History, + Palette, + ScrollText, + Ticket, + UserCog, + Wallet, +} from 'lucide-react' import { Separator } from '@/components/ui/separator' import { ConfigDrawer } from '@/components/config-drawer' import { Header } from '@/components/layout/header' @@ -15,6 +23,16 @@ const sidebarNavItems = [ href: '/settings', icon: , }, + { + title: '邀请码', + href: '/settings/invite-code', + icon: , + }, + { + title: '账户充值', + href: '/settings/recharge', + icon: , + }, { title: '通知', href: '/settings/notifications', diff --git a/admin/src/features/settings/invite-code/index.tsx b/admin/src/features/settings/invite-code/index.tsx new file mode 100644 index 00000000..f2be7b9d --- /dev/null +++ b/admin/src/features/settings/invite-code/index.tsx @@ -0,0 +1,14 @@ +import { ContentSection } from '../components/content-section' +import { InviteCodePanel } from './invite-code' + +export function SettingsInviteCode() { + return ( + + + + ) +} diff --git a/admin/src/features/settings/invite-code/invite-code.tsx b/admin/src/features/settings/invite-code/invite-code.tsx new file mode 100644 index 00000000..d2c10954 --- /dev/null +++ b/admin/src/features/settings/invite-code/invite-code.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react' +import { Copy, RefreshCw, Ticket, Users } from 'lucide-react' +import { toast } from 'sonner' +import { formatDateTime } from '@/lib/datetime' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + generateMockCode, + mockInviteCode, + mockInviteRecords, + type InviteRecord, +} from './mock' + +const statusLabel: Record = { + registered: '已注册', + activated: '已激活', +} + +async function copyText(text: string, label: string) { + try { + await navigator.clipboard.writeText(text) + toast.success(`${label}已复制`) + } catch { + toast.error('复制失败,请手动选择复制') + } +} + +export function InviteCodePanel() { + const [code, setCode] = useState(mockInviteCode.code) + const [link, setLink] = useState(mockInviteCode.link) + const [records] = useState(mockInviteRecords) + const [regenerating, setRegenerating] = useState(false) + + const totalInvited = mockInviteCode.totalInvited + const totalActivated = mockInviteCode.totalActivated + + const onRegenerate = () => { + if (regenerating) return + setRegenerating(true) + // 模拟生成新邀请码的耗时 + setTimeout(() => { + const next = generateMockCode() + setCode(next) + setLink(`https://cloudsteps.example.com/i/${next.split('-')[1]}`) + setRegenerating(false) + toast.success('已生成新的邀请码') + }, 500) + } + + return ( +
+ {/* ===== 我的邀请码 ===== */} + + + + + 我的邀请码 + + + 把邀请码或邀请链接分享给好友,好友注册后即可在下方看到记录。 + + + +
+
+ + {code} + + + 生成于 {formatDateTime(mockInviteCode.createdAt)} + +
+
+ + +
+
+ +
+
+
专属邀请链接
+
{link}
+
+ +
+
+
+ + {/* ===== 统计 ===== */} +
+ + + 累计邀请 + {totalInvited} + + + + + 已激活 + {totalActivated} + + +
+ + {/* ===== 邀请记录 ===== */} + + + + + 邀请记录 + + + 好友通过你的邀请码注册后会出现于此。 + + + + + + + 被邀请人 + 注册时间 + 状态 + + + + {records.length === 0 ? ( + + + 暂无邀请记录 + + + ) : ( + records.map((r) => ( + + {r.invitee} + {formatDateTime(r.registeredAt)} + + + {statusLabel[r.status]} + + + + )) + )} + +
+
+
+
+ ) +} diff --git a/admin/src/features/settings/invite-code/mock.ts b/admin/src/features/settings/invite-code/mock.ts new file mode 100644 index 00000000..cc0d90e1 --- /dev/null +++ b/admin/src/features/settings/invite-code/mock.ts @@ -0,0 +1,88 @@ +// 纯前端 mock 数据,不涉及后端。 +// 邀请码页面只展示邀请码与邀请记录,不做奖励。 + +export type InviteRecordStatus = 'registered' | 'activated' + +export type InviteRecord = { + id: number + invitee: string + registeredAt: string + status: InviteRecordStatus +} + +export type InviteCodeInfo = { + code: string + link: string + createdAt: string + totalInvited: number + totalActivated: number +} + +export const mockInviteCode: InviteCodeInfo = { + code: 'CLOUD-7K9F2A', + link: 'https://cloudsteps.example.com/i/7K9F2A', + createdAt: '2026-07-12 10:24:00', + totalInvited: 8, + totalActivated: 5, +} + +export const mockInviteRecords: InviteRecord[] = [ + { + id: 1, + invitee: '138****2041', + registeredAt: '2026-08-30 14:21:08', + status: 'activated', + }, + { + id: 2, + invitee: '159****7762', + registeredAt: '2026-08-28 09:05:42', + status: 'activated', + }, + { + id: 3, + invitee: '小马同学', + registeredAt: '2026-08-25 20:13:55', + status: 'registered', + }, + { + id: 4, + invitee: '186****1190', + registeredAt: '2026-08-21 11:47:30', + status: 'activated', + }, + { + id: 5, + invitee: 'Lily', + registeredAt: '2026-08-18 16:02:11', + status: 'registered', + }, + { + id: 6, + invitee: '133****8821', + registeredAt: '2026-08-12 08:30:00', + status: 'activated', + }, + { + id: 7, + invitee: '阿涛', + registeredAt: '2026-08-05 22:18:46', + status: 'registered', + }, + { + id: 8, + invitee: '177****4503', + registeredAt: '2026-07-30 10:09:22', + status: 'activated', + }, +] + +// 生成一个新的 mock 邀请码(仅本地展示) +export function generateMockCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let tail = '' + for (let i = 0; i < 6; i += 1) { + tail += chars[Math.floor(Math.random() * chars.length)] + } + return `CLOUD-${tail}` +} diff --git a/admin/src/features/settings/recharge/index.tsx b/admin/src/features/settings/recharge/index.tsx new file mode 100644 index 00000000..51cb5b21 --- /dev/null +++ b/admin/src/features/settings/recharge/index.tsx @@ -0,0 +1,14 @@ +import { ContentSection } from '../components/content-section' +import { RechargePanel } from './recharge' + +export function SettingsRecharge() { + return ( + + + + ) +} diff --git a/admin/src/features/settings/recharge/mock.ts b/admin/src/features/settings/recharge/mock.ts new file mode 100644 index 00000000..7bf45b03 --- /dev/null +++ b/admin/src/features/settings/recharge/mock.ts @@ -0,0 +1,119 @@ +// 纯前端 mock 数据,不涉及后端。 +// 充值页面参考汽水音乐:套餐卡片 + 自定义金额 + 支付方式 + 充值记录。 + +export type PaymentMethod = 'wechat' | 'alipay' | 'card' + +export type RechargePackage = { + id: string + amount: number // 实付金额(元) + bonus: number // 赠送金额(元) + tag?: string // 角标文案,如「热门」「超值」 +} + +export type RechargeOrderStatus = 'success' | 'pending' | 'failed' + +export type RechargeOrder = { + id: string + orderNo: string + amount: number + bonus: number + method: PaymentMethod + createdAt: string + status: RechargeOrderStatus +} + +export type RechargeBalance = { + balance: number + totalRecharged: number + totalConsumed: number +} + +export const mockBalance: RechargeBalance = { + balance: 36.5, + totalRecharged: 300, + totalConsumed: 263.5, +} + +export const mockPackages: RechargePackage[] = [ + { id: 'p6', amount: 6, bonus: 0 }, + { id: 'p18', amount: 18, bonus: 1, tag: '入门' }, + { id: 'p68', amount: 68, bonus: 8, tag: '热门' }, + { id: 'p128', amount: 128, bonus: 18, tag: '超值' }, + { id: 'p298', amount: 298, bonus: 48 }, + { id: 'p648', amount: 648, bonus: 128, tag: '豪华' }, +] + +export const mockOrders: RechargeOrder[] = [ + { + id: '1', + orderNo: 'CS20260902103012001', + amount: 68, + bonus: 8, + method: 'wechat', + createdAt: '2026-09-02 10:30:12', + status: 'success', + }, + { + id: '2', + orderNo: 'CS20260828192247012', + amount: 18, + bonus: 1, + method: 'alipay', + createdAt: '2026-08-28 19:22:47', + status: 'success', + }, + { + id: '3', + orderNo: 'CS20260820140555003', + amount: 128, + bonus: 18, + method: 'wechat', + createdAt: '2026-08-20 14:05:55', + status: 'success', + }, + { + id: '4', + orderNo: 'CS20260815081130004', + amount: 6, + bonus: 0, + method: 'card', + createdAt: '2026-08-15 08:11:30', + status: 'failed', + }, + { + id: '5', + orderNo: 'CS20260801233810005', + amount: 298, + bonus: 48, + method: 'alipay', + createdAt: '2026-08-01 23:38:10', + status: 'success', + }, +] + +export const paymentMethodLabel: Record = { + wechat: '微信支付', + alipay: '支付宝', + card: '银行卡', +} + +export const orderStatusLabel: Record = { + success: '成功', + pending: '处理中', + failed: '失败', +} + +// 生成一个 mock 订单号 +export function generateOrderNo(): string { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + const stamp = + d.getFullYear().toString() + + pad(d.getMonth() + 1) + + pad(d.getDate()) + + pad(d.getHours()) + + pad(d.getMinutes()) + + pad(d.getSeconds()) + const rand = String(Math.floor(Math.random() * 900) + 100) + return `CS${stamp}${rand}` +} diff --git a/admin/src/features/settings/recharge/recharge.tsx b/admin/src/features/settings/recharge/recharge.tsx new file mode 100644 index 00000000..aabd1650 --- /dev/null +++ b/admin/src/features/settings/recharge/recharge.tsx @@ -0,0 +1,411 @@ +import { useMemo, useState } from 'react' +import { CreditCard, Wallet } from 'lucide-react' +import { toast } from 'sonner' +import { formatDateTime } from '@/lib/datetime' +import { cn } from '@/lib/utils' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { + generateOrderNo, + mockBalance, + mockOrders, + mockPackages, + orderStatusLabel, + paymentMethodLabel, + type PaymentMethod, + type RechargeOrder, + type RechargeOrderStatus, +} from './mock' + +const methods: { value: PaymentMethod; label: string }[] = [ + { value: 'wechat', label: '微信支付' }, + { value: 'alipay', label: '支付宝' }, + { value: 'card', label: '银行卡' }, +] + +const statusFilterOptions: { + value: 'all' | RechargeOrderStatus + label: string +}[] = [ + { value: 'all', label: '全部' }, + { value: 'success', label: '成功' }, + { value: 'pending', label: '处理中' }, + { value: 'failed', label: '失败' }, +] + +function yuan(n: number) { + return `¥${n.toFixed(n % 1 === 0 ? 0 : 2)}` +} + +export function RechargePanel() { + const [balance, setBalance] = useState(mockBalance) + const [orders, setOrders] = useState(mockOrders) + const [selectedPkgId, setSelectedPkgId] = useState( + mockPackages[2]?.id ?? null + ) + const [customAmount, setCustomAmount] = useState('') + const [method, setMethod] = useState('wechat') + const [confirmOpen, setConfirmOpen] = useState(false) + const [paying, setPaying] = useState(false) + const [statusFilter, setStatusFilter] = useState<'all' | RechargeOrderStatus>( + 'all' + ) + + const selectedPkg = useMemo( + () => mockPackages.find((p) => p.id === selectedPkgId) ?? null, + [selectedPkgId] + ) + + // 当前生效的金额与赠送(套餐优先,其次自定义) + const effectiveAmount = useMemo(() => { + if (selectedPkg) + return { amount: selectedPkg.amount, bonus: selectedPkg.bonus } + const n = Number(customAmount) + if (customAmount && Number.isFinite(n) && n > 0) { + return { amount: Math.floor(n), bonus: 0 } + } + return { amount: 0, bonus: 0 } + }, [selectedPkg, customAmount]) + + const canPay = effectiveAmount.amount > 0 + + const onPickPackage = (id: string) => { + setSelectedPkgId(id) + setCustomAmount('') + } + + const onInputCustom = (v: string) => { + // 只允许正整数 + const cleaned = v.replace(/[^\d]/g, '') + setCustomAmount(cleaned) + if (cleaned) setSelectedPkgId(null) + } + + const onPay = () => { + if (!canPay) return + setConfirmOpen(true) + } + + const onConfirmPay = async () => { + setPaying(true) + // 模拟支付耗时 + await new Promise((r) => setTimeout(r, 600)) + const order: RechargeOrder = { + id: String(Date.now()), + orderNo: generateOrderNo(), + amount: effectiveAmount.amount, + bonus: effectiveAmount.bonus, + method, + createdAt: new Date().toISOString().replace('T', ' ').slice(0, 19), + status: 'success', + } + setOrders((prev) => [order, ...prev]) + setBalance((prev) => ({ + ...prev, + balance: prev.balance + order.amount + order.bonus, + totalRecharged: prev.totalRecharged + order.amount, + })) + setPaying(false) + setConfirmOpen(false) + setCustomAmount('') + toast.success( + `充值成功 ${yuan(order.amount)}${ + order.bonus > 0 ? ` + 赠送 ${yuan(order.bonus)}` : '' + }` + ) + } + + const filteredOrders = useMemo(() => { + if (statusFilter === 'all') return orders + return orders.filter((o) => o.status === statusFilter) + }, [orders, statusFilter]) + + return ( + + + 充值 + 充值记录 + + + {/* ===== Tab: 充值 ===== */} + + {/* 余额卡片 */} +
+ + + 当前余额 + + {yuan(balance.balance)} + + + + + + 累计充值 + + {yuan(balance.totalRecharged)} + + + + + + 累计消费 + + {yuan(balance.totalConsumed)} + + + +
+ + {/* 套餐选择 */} + + + + + 选择充值套餐 + + + 选择对应套餐,部分套餐含赠送金额。 + + + +
+ {mockPackages.map((p) => { + const active = selectedPkgId === p.id + return ( + + ) + })} +
+ + {/* 自定义金额 */} +
+ + 自定义金额: + +
+ + ¥ + + onInputCustom(e.target.value)} + /> +
+ + (整数,无赠送) + +
+
+
+ + {/* 支付方式 + 提交 */} + + + + + 支付方式 + + + +
+ {methods.map((m) => { + const active = method === m.value + return ( + + ) + })} +
+ +
+
+ 实付 + + {yuan(effectiveAmount.amount)} + + {effectiveAmount.bonus > 0 ? ( + + 到账 {yuan(effectiveAmount.amount + effectiveAmount.bonus)} + (含赠送 {yuan(effectiveAmount.bonus)}) + + ) : null} +
+ +
+
+
+
+ + {/* ===== Tab: 充值记录 ===== */} + + + +
+
+ 充值记录 + 查看历史充值订单与状态。 +
+ +
+
+ + + + + 订单号 + 实付 + 赠送 + 支付方式 + 时间 + 状态 + + + + {filteredOrders.length === 0 ? ( + + + 暂无记录 + + + ) : ( + filteredOrders.map((o) => ( + + + {o.orderNo} + + {yuan(o.amount)} + {o.bonus > 0 ? yuan(o.bonus) : '—'} + {paymentMethodLabel[o.method]} + {formatDateTime(o.createdAt)} + + + {orderStatusLabel[o.status]} + + + + )) + )} + +
+
+
+
+ + +
+ 实付金额:{yuan(effectiveAmount.amount)} +
+ {effectiveAmount.bonus > 0 ? ( +
赠送金额:{yuan(effectiveAmount.bonus)}
+ ) : null} +
支付方式:{paymentMethodLabel[method]}
+
+ 此为演示页面,不会发生真实扣款。 +
+ + } + confirmText={paying ? '支付中…' : '确认支付'} + isLoading={paying} + handleConfirm={onConfirmPay} + /> +
+ ) +} diff --git a/admin/src/features/user-cloze-passages/index.tsx b/admin/src/features/user-cloze-passages/index.tsx index 54ed341d..1ca948fa 100644 --- a/admin/src/features/user-cloze-passages/index.tsx +++ b/admin/src/features/user-cloze-passages/index.tsx @@ -87,7 +87,13 @@ export function UserClozePassagesPage() { value={userId} onChange={(e) => setUserId(e.target.value)} /> - @@ -111,13 +117,19 @@ export function UserClozePassagesPage() { {list.map((row) => ( - {row.title} + + {row.title} + {row.username || row.email || row.userId} {row.level} {row.blankCount ?? '—'}
- -
@@ -156,8 +178,8 @@ export function UserClozePassagesPage() { desc={`确定删除「${deleteTarget?.title}」?`} confirmText='删除' destructive - loading={deleting} - onConfirm={() => void handleDeleteConfirm()} + isLoading={deleting} + handleConfirm={() => void handleDeleteConfirm()} /> ) diff --git a/admin/src/main.tsx b/admin/src/main.tsx index c31f4653..dd6f35cf 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -75,6 +75,7 @@ const queryClient = new QueryClient({ // Create a new router instance const router = createRouter({ routeTree, + basepath: import.meta.env.VITE_ROUTER_BASE_PATH || '/', context: { queryClient }, defaultPreload: 'intent', defaultPreloadStaleTime: 0, diff --git a/admin/src/routeTree.gen.ts b/admin/src/routeTree.gen.ts index b32da364..3a3d48d7 100644 --- a/admin/src/routeTree.gen.ts +++ b/admin/src/routeTree.gen.ts @@ -59,7 +59,9 @@ import { Route as ClerkAuthenticatedUserManagementRouteImport } from './routes/c import { Route as ClerkauthSignUpRouteImport } from './routes/clerk/(auth)/sign-up' import { Route as ClerkauthSignInRouteImport } from './routes/clerk/(auth)/sign-in' import { Route as AuthenticatedWordbooksBookIdRouteImport } from './routes/_authenticated/wordbooks/$bookId' +import { Route as AuthenticatedSettingsRechargeRouteImport } from './routes/_authenticated/settings/recharge' import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications' +import { Route as AuthenticatedSettingsInviteCodeRouteImport } from './routes/_authenticated/settings/invite-code' import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display' import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance' import { Route as AuthenticatedSettingsAccountRouteImport } from './routes/_authenticated/settings/account' @@ -347,12 +349,24 @@ const AuthenticatedWordbooksBookIdRoute = path: '/wordbooks/$bookId', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedSettingsRechargeRoute = + AuthenticatedSettingsRechargeRouteImport.update({ + id: '/recharge', + path: '/recharge', + getParentRoute: () => AuthenticatedSettingsRouteRoute, + } as any) const AuthenticatedSettingsNotificationsRoute = AuthenticatedSettingsNotificationsRouteImport.update({ id: '/notifications', path: '/notifications', getParentRoute: () => AuthenticatedSettingsRouteRoute, } as any) +const AuthenticatedSettingsInviteCodeRoute = + AuthenticatedSettingsInviteCodeRouteImport.update({ + id: '/invite-code', + path: '/invite-code', + getParentRoute: () => AuthenticatedSettingsRouteRoute, + } as any) const AuthenticatedSettingsDisplayRoute = AuthenticatedSettingsDisplayRouteImport.update({ id: '/display', @@ -452,7 +466,9 @@ export interface FileRoutesByFullPath { '/settings/account': typeof AuthenticatedSettingsAccountRoute '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute '/settings/display': typeof AuthenticatedSettingsDisplayRoute + '/settings/invite-code': typeof AuthenticatedSettingsInviteCodeRoute '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute + '/settings/recharge': typeof AuthenticatedSettingsRechargeRoute '/wordbooks/$bookId': typeof AuthenticatedWordbooksBookIdRoute '/clerk/sign-in': typeof ClerkauthSignInRoute '/clerk/sign-up': typeof ClerkauthSignUpRoute @@ -513,7 +529,9 @@ export interface FileRoutesByTo { '/settings/account': typeof AuthenticatedSettingsAccountRoute '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute '/settings/display': typeof AuthenticatedSettingsDisplayRoute + '/settings/invite-code': typeof AuthenticatedSettingsInviteCodeRoute '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute + '/settings/recharge': typeof AuthenticatedSettingsRechargeRoute '/wordbooks/$bookId': typeof AuthenticatedWordbooksBookIdRoute '/clerk/sign-in': typeof ClerkauthSignInRoute '/clerk/sign-up': typeof ClerkauthSignUpRoute @@ -579,7 +597,9 @@ export interface FileRoutesById { '/_authenticated/settings/account': typeof AuthenticatedSettingsAccountRoute '/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute '/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute + '/_authenticated/settings/invite-code': typeof AuthenticatedSettingsInviteCodeRoute '/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute + '/_authenticated/settings/recharge': typeof AuthenticatedSettingsRechargeRoute '/_authenticated/wordbooks/$bookId': typeof AuthenticatedWordbooksBookIdRoute '/clerk/(auth)/sign-in': typeof ClerkauthSignInRoute '/clerk/(auth)/sign-up': typeof ClerkauthSignUpRoute @@ -643,7 +663,9 @@ export interface FileRouteTypes { | '/settings/account' | '/settings/appearance' | '/settings/display' + | '/settings/invite-code' | '/settings/notifications' + | '/settings/recharge' | '/wordbooks/$bookId' | '/clerk/sign-in' | '/clerk/sign-up' @@ -704,7 +726,9 @@ export interface FileRouteTypes { | '/settings/account' | '/settings/appearance' | '/settings/display' + | '/settings/invite-code' | '/settings/notifications' + | '/settings/recharge' | '/wordbooks/$bookId' | '/clerk/sign-in' | '/clerk/sign-up' @@ -769,7 +793,9 @@ export interface FileRouteTypes { | '/_authenticated/settings/account' | '/_authenticated/settings/appearance' | '/_authenticated/settings/display' + | '/_authenticated/settings/invite-code' | '/_authenticated/settings/notifications' + | '/_authenticated/settings/recharge' | '/_authenticated/wordbooks/$bookId' | '/clerk/(auth)/sign-in' | '/clerk/(auth)/sign-up' @@ -1175,6 +1201,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedWordbooksBookIdRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/settings/recharge': { + id: '/_authenticated/settings/recharge' + path: '/recharge' + fullPath: '/settings/recharge' + preLoaderRoute: typeof AuthenticatedSettingsRechargeRouteImport + parentRoute: typeof AuthenticatedSettingsRouteRoute + } '/_authenticated/settings/notifications': { id: '/_authenticated/settings/notifications' path: '/notifications' @@ -1182,6 +1215,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSettingsNotificationsRouteImport parentRoute: typeof AuthenticatedSettingsRouteRoute } + '/_authenticated/settings/invite-code': { + id: '/_authenticated/settings/invite-code' + path: '/invite-code' + fullPath: '/settings/invite-code' + preLoaderRoute: typeof AuthenticatedSettingsInviteCodeRouteImport + parentRoute: typeof AuthenticatedSettingsRouteRoute + } '/_authenticated/settings/display': { id: '/_authenticated/settings/display' path: '/display' @@ -1273,7 +1313,9 @@ interface AuthenticatedSettingsRouteRouteChildren { AuthenticatedSettingsAccountRoute: typeof AuthenticatedSettingsAccountRoute AuthenticatedSettingsAppearanceRoute: typeof AuthenticatedSettingsAppearanceRoute AuthenticatedSettingsDisplayRoute: typeof AuthenticatedSettingsDisplayRoute + AuthenticatedSettingsInviteCodeRoute: typeof AuthenticatedSettingsInviteCodeRoute AuthenticatedSettingsNotificationsRoute: typeof AuthenticatedSettingsNotificationsRoute + AuthenticatedSettingsRechargeRoute: typeof AuthenticatedSettingsRechargeRoute AuthenticatedSettingsIndexRoute: typeof AuthenticatedSettingsIndexRoute AuthenticatedSettingsLoginHistoryIndexRoute: typeof AuthenticatedSettingsLoginHistoryIndexRoute AuthenticatedSettingsOperationLogsIndexRoute: typeof AuthenticatedSettingsOperationLogsIndexRoute @@ -1284,8 +1326,10 @@ const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteCh AuthenticatedSettingsAccountRoute: AuthenticatedSettingsAccountRoute, AuthenticatedSettingsAppearanceRoute: AuthenticatedSettingsAppearanceRoute, AuthenticatedSettingsDisplayRoute: AuthenticatedSettingsDisplayRoute, + AuthenticatedSettingsInviteCodeRoute: AuthenticatedSettingsInviteCodeRoute, AuthenticatedSettingsNotificationsRoute: AuthenticatedSettingsNotificationsRoute, + AuthenticatedSettingsRechargeRoute: AuthenticatedSettingsRechargeRoute, AuthenticatedSettingsIndexRoute: AuthenticatedSettingsIndexRoute, AuthenticatedSettingsLoginHistoryIndexRoute: AuthenticatedSettingsLoginHistoryIndexRoute, diff --git a/admin/src/routes/_authenticated/settings/invite-code.tsx b/admin/src/routes/_authenticated/settings/invite-code.tsx new file mode 100644 index 00000000..f7d8cb9f --- /dev/null +++ b/admin/src/routes/_authenticated/settings/invite-code.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsInviteCode } from '@/features/settings/invite-code' + +export const Route = createFileRoute('/_authenticated/settings/invite-code')({ + component: SettingsInviteCode, +}) diff --git a/admin/src/routes/_authenticated/settings/recharge.tsx b/admin/src/routes/_authenticated/settings/recharge.tsx new file mode 100644 index 00000000..4870421a --- /dev/null +++ b/admin/src/routes/_authenticated/settings/recharge.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsRecharge } from '@/features/settings/recharge' + +export const Route = createFileRoute('/_authenticated/settings/recharge')({ + component: SettingsRecharge, +}) diff --git a/admin/vite.config.ts b/admin/vite.config.ts index ffbbe04c..91efecb7 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -8,6 +8,9 @@ import { playwright } from '@vitest/browser-playwright' // https://vite.dev/config/ export default defineConfig({ + // GitHub Pages 部署时通过 VITE_BASE_PATH 注入子路径(如 /CloudSteps/), + // 本地 dev 不设置则默认相对路径 './',不影响开发。 + base: process.env.VITE_BASE_PATH || './', plugins: [ tanstackRouter({ target: 'react', diff --git a/web/download (4)/font_5228186_75je4ry4ypr/iconfont.ttf b/web/download (4)/font_5228186_75je4ry4ypr/iconfont.ttf new file mode 100644 index 00000000..748dc814 Binary files /dev/null and b/web/download (4)/font_5228186_75je4ry4ypr/iconfont.ttf differ diff --git a/web/download (4)/font_5228186_75je4ry4ypr/iconfont.woff b/web/download (4)/font_5228186_75je4ry4ypr/iconfont.woff new file mode 100644 index 00000000..cea7827d Binary files /dev/null and b/web/download (4)/font_5228186_75je4ry4ypr/iconfont.woff differ diff --git a/web/download (4)/font_5228186_75je4ry4ypr/iconfont.woff2 b/web/download (4)/font_5228186_75je4ry4ypr/iconfont.woff2 new file mode 100644 index 00000000..26341574 Binary files /dev/null and b/web/download (4)/font_5228186_75je4ry4ypr/iconfont.woff2 differ diff --git a/web/package.json b/web/package.json index 04e42801..877f7f7b 100644 --- a/web/package.json +++ b/web/package.json @@ -62,6 +62,7 @@ "lucide-react": "0.487.0", "motion": "12.23.24", "next-themes": "0.4.6", + "qrcode": "1.5.4", "rbush": "^4.0.1", "react": "^18.3.1", "react-day-picker": "8.10.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c82002f6..5c0e0ba9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + qrcode: + specifier: 1.5.4 + version: 1.5.4 rbush: specifier: ^4.0.1 version: 4.0.1 @@ -1955,6 +1958,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2016,6 +2027,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} @@ -2066,6 +2081,9 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2083,6 +2101,10 @@ packages: color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} @@ -2197,6 +2219,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -2236,6 +2262,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dnd-core@16.0.1: resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} @@ -2265,6 +2294,9 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2361,6 +2393,10 @@ packages: find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + focus-lock@1.3.6: resolution: {integrity: sha512-Ik/6OCk9RQQ0T5Xw+hKNLWrjSMtv51dD4GRmJjbD5a58TIEpI5a5iXagKVl3Z5UuyslMCA8Xwnu76jQob62Yhg==} engines: {node: '>=10'} @@ -2411,6 +2447,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2529,6 +2569,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -2652,6 +2696,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -2904,6 +2952,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2918,6 +2978,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2939,6 +3003,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -2965,6 +3033,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -3169,6 +3242,13 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -3214,6 +3294,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -3269,12 +3352,20 @@ packages: string-convert@0.2.1: resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -3554,6 +3645,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3567,6 +3661,10 @@ packages: resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} engines: {node: '>=0.8'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3594,6 +3692,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3605,6 +3706,14 @@ packages: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + zrender@6.1.0: resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} @@ -5413,6 +5522,12 @@ snapshots: agent-base@7.1.4: optional: true + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -5479,6 +5594,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001781: {} canvas-confetti@1.9.4: {} @@ -5525,6 +5642,12 @@ snapshots: classnames@2.5.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -5545,6 +5668,10 @@ snapshots: dependencies: color-name: 1.1.3 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + color-name@1.1.3: {} color-name@1.1.4: {} @@ -5646,6 +5773,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} decimal.js@10.6.0: @@ -5677,6 +5806,8 @@ snapshots: dependencies: dequal: 2.0.3 + dijkstrajs@1.0.3: {} + dnd-core@16.0.1: dependencies: '@react-dnd/asap': 5.0.2 @@ -5713,6 +5844,8 @@ snapshots: embla-carousel@8.6.0: {} + emoji-regex@8.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -5818,6 +5951,11 @@ snapshots: find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + focus-lock@1.3.6: dependencies: tslib: 2.8.1 @@ -5854,6 +5992,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5993,6 +6133,8 @@ snapshots: is-decimal@2.0.1: {} + is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} @@ -6110,6 +6252,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.debounce@4.0.8: {} lodash@4.17.23: {} @@ -6555,6 +6701,16 @@ snapshots: wrappy: 1.0.2 optional: true + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6581,6 +6737,8 @@ snapshots: entities: 6.0.1 optional: true + path-exists@4.0.0: {} + path-parse@1.0.7: {} path-type@4.0.0: {} @@ -6593,6 +6751,8 @@ snapshots: picomatch@4.0.4: {} + pngjs@5.0.0: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -6634,6 +6794,12 @@ snapshots: punycode@2.3.1: optional: true + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quickselect@3.0.0: {} rbush@4.0.1: @@ -6877,6 +7043,10 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -6945,6 +7115,8 @@ snapshots: semver@7.8.5: optional: true + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} shallowequal@1.1.0: {} @@ -6995,6 +7167,12 @@ snapshots: string-convert@0.2.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -7005,6 +7183,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@2.0.1: optional: true @@ -7323,6 +7505,8 @@ snapshots: webidl-conversions: 7.0.0 optional: true + which-module@2.0.1: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -7332,6 +7516,12 @@ snapshots: word@0.3.0: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: optional: true @@ -7354,12 +7544,33 @@ snapshots: xmlchars@2.2.0: optional: true + y18n@4.0.3: {} + yallist@3.1.1: {} yallist@5.0.0: {} yaml@1.10.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + zrender@6.1.0: dependencies: tslib: 2.3.0 diff --git a/web/src/pages/CoachCenter.tsx b/web/src/pages/CoachCenter.tsx index 1b41f904..6c907205 100644 --- a/web/src/pages/CoachCenter.tsx +++ b/web/src/pages/CoachCenter.tsx @@ -8,6 +8,8 @@ import { Mars, Venus, Loader2, + Ticket, + Wallet, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; @@ -169,6 +171,22 @@ export default function CoachCenter() { tint: "cream" as const, path: "/settings", }, + { + id: 5, + icon: Ticket, + label: "邀请码", + description: "邀请好友一起学习", + tint: "sky" as const, + path: "/invite-code", + }, + { + id: 6, + icon: Wallet, + label: "账户充值", + description: "充值余额,解锁学习服务", + tint: "mint" as const, + path: "/recharge", + }, ]; if (!isCoach) return base; return [ diff --git a/web/src/pages/InviteCode.tsx b/web/src/pages/InviteCode.tsx new file mode 100644 index 00000000..c0e6105d --- /dev/null +++ b/web/src/pages/InviteCode.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from "react"; +import QRCode from "qrcode"; +import { Copy, Download, Gift, Share2, Users } from "lucide-react"; +import { PageBackHeader } from "../components/PageBackHeader"; +import { CloudButton } from "../components/cloudsteps"; +import { CloudCard } from "../components/cloudsteps/arco"; +import { showToast } from "../utils/toast"; +import { Dialog, DialogContent, DialogFooter } from "../components/ui/dialog"; + +const mockRecords = [ + { name: "138****2041", date: "2026-08-30", status: "已激活" }, + { name: "159****7762", date: "2026-08-28", status: "已激活" }, + { name: "小马同学", date: "2026-08-25", status: "已注册" }, + { name: "186****1190", date: "2026-08-21", status: "已激活" }, +]; + +const inviteUrl = (code: string) => { + const url = new URL("login", window.location.origin + import.meta.env.BASE_URL); + url.searchParams.set("register", "1"); + url.searchParams.set("inviteCode", code); + return url.toString(); +}; + +async function makePoster(code: string): Promise { + const size = 900; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext("2d"); + if (!context) throw new Error("无法生成分享图片"); + + context.fillStyle = "#e8f8f5"; + context.fillRect(0, 0, size, size); + + context.fillStyle = "#ffffff"; + context.roundRect(55, 55, size - 110, size - 110, 36); + context.fill(); + + const logoImage = new Image(); + await new Promise((resolve) => { + logoImage.onload = () => resolve(); + logoImage.onerror = () => resolve(); + logoImage.src = `${import.meta.env.BASE_URL}logo.png`; + }); + context.fillStyle = "#25344a"; + context.textAlign = "center"; + context.font = "600 42px sans-serif"; + context.fillText("一起加入云阶学习", size / 2, 150); + context.fillStyle = "#667085"; + context.font = "24px sans-serif"; + context.fillText("扫码即可使用我的邀请码", size / 2, 198); + + const qrDataUrl = await QRCode.toDataURL(inviteUrl(code), { + width: 430, + margin: 2, + color: { dark: "#25344a", light: "#ffffff" }, + }); + const qrImage = new Image(); + await new Promise((resolve, reject) => { + qrImage.onload = () => resolve(); + qrImage.onerror = () => reject(new Error("二维码生成失败")); + qrImage.src = qrDataUrl; + }); + context.drawImage(qrImage, 235, 245, 430, 430); + if (logoImage.complete && logoImage.naturalWidth > 0) { + const logoWidth = 46; + const logoHeight = (logoImage.naturalHeight / logoImage.naturalWidth) * logoWidth; + const logoX = size / 2 - logoWidth / 2; + const logoY = size / 2 - logoHeight / 2; + context.fillStyle = "#ffffff"; + context.roundRect(logoX - 9, logoY - 9, logoWidth + 18, logoHeight + 18, 10); + context.fill(); + context.drawImage(logoImage, logoX, logoY, logoWidth, logoHeight); + } + context.fillStyle = "#25344a"; + context.font = "700 38px monospace"; + context.fillText(code, size / 2, 755); + context.fillStyle = "#667085"; + context.font = "22px sans-serif"; + context.fillText("CloudSteps · 云阶", size / 2, 805); + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("图片导出失败"))), "image/png"); + }); +} + +export default function InviteCode() { + const [code, setCode] = useState("CLOUD-7K9F2A"); + const [sharing, setSharing] = useState(false); + const [posterUrl, setPosterUrl] = useState(null); + const qrCanvasRef = useRef(null); + const link = inviteUrl(code); + + useEffect(() => { + if (!qrCanvasRef.current) return; + void QRCode.toCanvas(qrCanvasRef.current, link, { + width: 170, + margin: 2, + color: { dark: "#25344a", light: "#ffffff" }, + }); + }, [link]); + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + showToast.success(`${label}已复制`); + } catch { + showToast.error("复制失败,请手动复制"); + } + }; + + const downloadQr = () => { + const canvas = qrCanvasRef.current; + if (!canvas) return; + canvas.toBlob((blob) => { + if (!blob) { + showToast.error("二维码保存失败"); + return; + } + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `云阶二维码-${code}.png`; + anchor.click(); + URL.revokeObjectURL(url); + showToast.success("二维码已保存"); + }, "image/png"); + }; + + const downloadPoster = async () => { + setSharing(true); + try { + const blob = await makePoster(code); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `云阶邀请码-${code}.png`; + anchor.click(); + URL.revokeObjectURL(url); + showToast.success("分享图片已保存"); + } catch { + showToast.error("分享图片生成失败"); + } finally { + setSharing(false); + } + }; + + const openPosterPreview = async () => { + setSharing(true); + try { + const blob = await makePoster(code); + const url = URL.createObjectURL(blob); + setPosterUrl((previous) => { + if (previous) URL.revokeObjectURL(previous); + return url; + }); + } catch { + showToast.error("分享图片生成失败"); + } finally { + setSharing(false); + } + }; + + const sharePoster = async () => { + if (!posterUrl) return; + try { + const response = await fetch(posterUrl); + const blob = await response.blob(); + const file = new File([blob], `云阶邀请码-${code}.png`, { type: "image/png" }); + if (navigator.share && (!navigator.canShare || navigator.canShare({ files: [file] }))) { + await navigator.share({ title: "云阶邀请码", text: "扫码加入云阶学习", files: [file] }); + } else { + await downloadPoster(); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) showToast.error("分享图片失败"); + } + }; + + return ( +
+ +
+
+ + +
+
+
+

我的专属邀请码

+

{code}

+
+ void copy(code, "邀请码")}>复制邀请码 + setCode(`CLOUD-${Math.random().toString(36).slice(2, 8).toUpperCase()}`)}>换一个 +
+
+ + + +
+
+

分享图片邀请好友

生成带二维码的分享海报,适合在链接被屏蔽的地方发送。

void openPosterPreview()} disabled={sharing}>{sharing ? "生成中…" : "分享图片"}保存二维码 void downloadPoster()} disabled={sharing}>保存分享图
+
+
{link} void copy(link, "邀请链接")}>
+
+ +
+

累计邀请

8

+

已激活

5

+
+ + +

邀请记录

+
{mockRecords.map((record) =>
{record.name}{record.date}{record.status}
)}
+
+
+
+ + { if (!open && posterUrl) { URL.revokeObjectURL(posterUrl); setPosterUrl(null); } }}> + +
+ {posterUrl ? 云阶邀请码分享图片预览 : null} +
+ + void sharePoster()}>分享 + void downloadPoster()}>保存图片 + +
+
+
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 4a8ba5b6..6e239986 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -38,7 +38,10 @@ export default function Login() { const { t } = useTranslation(); const doLogin = useAuthStore((s) => s.login); const isLoading = useAuthStore((s) => s.isLoading); - const [screen, setScreen] = useState("login"); + const [screen, setScreen] = useState(() => { + const params = new URLSearchParams(window.location.search); + return params.get("register") === "1" ? "register" : "login"; + }); const [showWechat, setShowWechat] = useState(false); const [account, setAccount] = useState(""); const [password, setPassword] = useState(""); diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx new file mode 100644 index 00000000..454dadf1 --- /dev/null +++ b/web/src/pages/Recharge.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import { Check, ChevronLeft, ChevronRight, CircleHelp, Crown, Headphones, LockKeyhole, ShieldCheck, Sparkles, Tag } from "lucide-react"; +import { useNavigate } from "react-router"; +import { CloudButton } from "../components/cloudsteps"; +import { CloudCard } from "../components/cloudsteps/arco"; +import { showToast } from "../utils/toast"; + +type Plan = { + id: string; + tab: string; + name: string; + period: string; + price: number; + monthly: string; + save?: string; + features: string[]; +}; + +const plans: Plan[] = [ + { id: "monthly", tab: "月付", name: "月度会员", period: "1个月", price: 58, monthly: "¥58 / 月", features: ["全部功能无限制", "无限学习", "开通推广返佣", "优先客服支持"] }, + { id: "quarterly", tab: "季付", name: "季度会员", period: "3个月", price: 98, monthly: "¥32.7 / 月", save: "省 ¥76", features: ["无限学习", "全部功能无限制", "开通推广返佣", "赠送 300 积分", "优先客服支持"] }, + { id: "yearly", tab: "年付", name: "年度会员", period: "12个月", price: 198, monthly: "¥16.5 / 月", save: "比月付省 72%", features: ["无限学习", "全部功能无限制", "开通推广返佣", "赠送 1200 积分", "优先客服支持"] }, + { id: "lifetime", tab: "永久会员", name: "永久会员", period: "永久有效", price: 498, monthly: "一次购买", save: "买断最划算", features: ["无限学习", "全部功能无限制", "开通推广返佣", "赠送 3000 积分", "优先客服支持", "后续内容持续更新"] }, +]; + +const comparison = [ + ["价格", "免费", "¥58", "¥98", "¥198", "¥498"], + ["有效期", "长期", "1个月", "3个月", "12个月", "永久"], + ["学生数量", "1名", "无限", "无限", "无限", "无限"], + ["核心功能", "部分可用", "全部功能", "全部功能", "全部功能", "全部功能"], + ["开通积分", "—", "100", "300", "1200", "3000"], + ["推广返佣", "—", "20%", "20%", "20%", "20%"], +]; + +const paymentMethods = [ + { id: "wechat", label: "微信支付", icon: "icon-weixinzhifu", color: "text-success" }, + { id: "alipay", label: "支付宝", icon: "icon-zhifubaozhifu", color: "text-secondary-brand" }, + { id: "bank", label: "信用卡银行卡", icon: "icon-xinyongkayinhangka", color: "text-primary" }, +]; + +const money = (value: number) => `¥${value.toFixed(0)}`; + +export default function Recharge() { + const navigate = useNavigate(); + const [selectedId, setSelectedId] = useState("yearly"); + const [coupon, setCoupon] = useState(""); + const [couponChecked, setCouponChecked] = useState(false); + const [method, setMethod] = useState("微信支付"); + const selected = useMemo(() => plans.find((plan) => plan.id === selectedId) ?? plans[2], [selectedId]); + const finalPrice = couponChecked ? selected.price * 0.9 : selected.price; + + const checkCoupon = (value = coupon) => { + if (value.length !== 6) { + showToast.error("请输入 6 位优惠码"); + return; + } + setCouponChecked(true); + showToast.success("优惠码可用,已享 9 折"); + }; + + const submit = () => { + if (!window.confirm(`确认开通${selected.name}?一次性购买,不会自动续费。`)) return; + showToast.success(`${selected.name}开通成功:${money(finalPrice)}(mock)`); + }; + + return ( +
+
+
+ +
+

会员中心

+ 选择适合你的会员方案 +
+ +
+
+ +
+
+
+
+

CloudSteps Plus

+

解锁完整学习能力

+

一次购买,立即享受全部会员权益。

+
+ 安全支付 +
+ +
+ +
+

选择套餐

+ 按需选择,随时升级 +
+
+ {plans.map((plan) => ( + + ))} +
+ +
+
+
+ +

{selected.name}

+

{selected.period} · {selected.monthly}

+
+
+ {selected.save ? {selected.save} : null} +

{couponChecked ? <>{money(selected.price)}{money(finalPrice)} : money(selected.price)}

+

一次性支付

+
+
+
+
    + {selected.features.map((feature, index) =>
  • {feature}
  • )} +
+
+ +
+

会员服务保障

开通即生效
+
+

安全支付

支付信息全程加密

+

持续更新

新内容持续加入

+

专属支持

遇到问题随时咨询

+
+
+ + +