From 6a7985a646c3509a2419dfa341c351c481f1b16d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 12:02:20 +0800 Subject: [PATCH 01/26] feat(admin): add invite-code and recharge settings pages with mock data Add two new settings pages (frontend-only, no backend changes): - /settings/invite-code: invite code display, copy, regenerate, and invite records - /settings/recharge: balance cards, package selection, payment method, and recharge history Also fix two pre-existing tsc errors to keep CI green: - scenario-sessions: remove unused Input import - user-cloze-passages: fix ConfirmDialog props (loading->isLoading, onConfirm->handleConfirm) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/features/scenario-sessions/index.tsx | 9 +- admin/src/features/settings/index.tsx | 20 +- .../features/settings/invite-code/index.tsx | 14 + .../settings/invite-code/invite-code.tsx | 195 +++++++++ .../src/features/settings/invite-code/mock.ts | 88 ++++ .../src/features/settings/recharge/index.tsx | 14 + admin/src/features/settings/recharge/mock.ts | 119 +++++ .../features/settings/recharge/recharge.tsx | 411 ++++++++++++++++++ .../features/user-cloze-passages/index.tsx | 36 +- admin/src/routeTree.gen.ts | 44 ++ .../_authenticated/settings/invite-code.tsx | 6 + .../_authenticated/settings/recharge.tsx | 6 + 12 files changed, 947 insertions(+), 15 deletions(-) create mode 100644 admin/src/features/settings/invite-code/index.tsx create mode 100644 admin/src/features/settings/invite-code/invite-code.tsx create mode 100644 admin/src/features/settings/invite-code/mock.ts create mode 100644 admin/src/features/settings/recharge/index.tsx create mode 100644 admin/src/features/settings/recharge/mock.ts create mode 100644 admin/src/features/settings/recharge/recharge.tsx create mode 100644 admin/src/routes/_authenticated/settings/invite-code.tsx create mode 100644 admin/src/routes/_authenticated/settings/recharge.tsx 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/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, +}) From 8fe652c9cae33ebe6316aaa9feaf266b9ce33072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 12:37:29 +0800 Subject: [PATCH 02/26] ci: deploy admin to GitHub Pages with demo mode Switch deploy-pages.yml from web/ to admin/ so the admin dashboard is served at the Pages root (https://1628755394.github.io/CloudSteps/). - admin/vite.config.ts: read VITE_BASE_PATH for Pages subpath - authenticated-layout: VITE_DEMO_MODE=1 bypasses login for UI preview - deploy-pages.yml: build admin with demo mode, deploy to Pages root Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/deploy-pages.yml | 25 ++++++++++--------- .../layout/authenticated-layout.tsx | 20 +++++++++++++-- admin/vite.config.ts | 3 +++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 4359889c..712e910b 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,10 +1,10 @@ -name: Deploy web to GitHub Pages +name: Deploy admin to GitHub Pages on: push: branches: [main] paths: - - "web/**" + - "admin/**" - ".github/workflows/deploy-pages.yml" workflow_dispatch: @@ -17,8 +17,8 @@ concurrency: group: pages cancel-in-progress: true -# 该工作流来自分叉仓库的 GitHub Pages 预览,核心仓库 LingByte/CloudSteps 未启用 Pages。 -# 在官方仓库跳过,避免 deploy-pages 404;fork 仍可自行部署。 +# 部署 admin 后台到 GitHub Pages,用于 UI 预览(demo 模式,无后端)。 +# 核心仓库 LingByte/CloudSteps 未启用 Pages,fork 仍可自行部署。 jobs: build: if: github.repository != 'LingByte/CloudSteps' @@ -34,27 +34,28 @@ jobs: with: node-version: "20" cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml + cache-dependency-path: admin/pnpm-lock.yaml - name: 安装依赖 - working-directory: web + working-directory: admin run: pnpm install --frozen-lockfile - - name: 构建(项目页子路径 + 后端 API 地址) - working-directory: web + - name: 构建(项目页子路径 + demo 模式) + working-directory: admin env: - # 仓库名作为 GitHub Pages 项目页子路径,如需自定义域名可改为 "/" + # 仓库名作为 GitHub Pages 项目页子路径 VITE_BASE_PATH: "/${{ github.event.repository.name }}/" - VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} + # demo 模式:跳过登录校验,直接放行看 UI + VITE_DEMO_MODE: "1" run: pnpm run build - name: SPA 路由回退(history 模式在 GitHub Pages 刷新深层路径会 404) - working-directory: web + working-directory: admin run: cp dist/index.html dist/404.html - uses: actions/upload-pages-artifact@v3 with: - path: web/dist + path: admin/dist deploy: if: github.repository != 'LingByte/CloudSteps' 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/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', From 8c1f063d50720a3d1b7bbd8c2b1667105d4311ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 12:57:51 +0800 Subject: [PATCH 03/26] fix: configure router base path for GitHub Pages Make the admin app resolve routes under the CloudSteps Pages project path so the deployed root and settings pages no longer render the app 404 page. --- .github/workflows/deploy-pages.yml | 1 + admin/src/main.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 712e910b..532d0813 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -45,6 +45,7 @@ jobs: env: # 仓库名作为 GitHub Pages 项目页子路径 VITE_BASE_PATH: "/${{ github.event.repository.name }}/" + VITE_ROUTER_BASE_PATH: "/${{ github.event.repository.name }}" # demo 模式:跳过登录校验,直接放行看 UI VITE_DEMO_MODE: "1" run: pnpm run build 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, From 8f4913f4adcbed54e92a98c8a2512780a23501f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:05:10 +0800 Subject: [PATCH 04/26] fix: restore CloudSteps web Pages deployment --- .github/workflows/deploy-pages.yml | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 532d0813..4359889c 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,10 +1,10 @@ -name: Deploy admin to GitHub Pages +name: Deploy web to GitHub Pages on: push: branches: [main] paths: - - "admin/**" + - "web/**" - ".github/workflows/deploy-pages.yml" workflow_dispatch: @@ -17,8 +17,8 @@ concurrency: group: pages cancel-in-progress: true -# 部署 admin 后台到 GitHub Pages,用于 UI 预览(demo 模式,无后端)。 -# 核心仓库 LingByte/CloudSteps 未启用 Pages,fork 仍可自行部署。 +# 该工作流来自分叉仓库的 GitHub Pages 预览,核心仓库 LingByte/CloudSteps 未启用 Pages。 +# 在官方仓库跳过,避免 deploy-pages 404;fork 仍可自行部署。 jobs: build: if: github.repository != 'LingByte/CloudSteps' @@ -34,29 +34,27 @@ jobs: with: node-version: "20" cache: pnpm - cache-dependency-path: admin/pnpm-lock.yaml + cache-dependency-path: web/pnpm-lock.yaml - name: 安装依赖 - working-directory: admin + working-directory: web run: pnpm install --frozen-lockfile - - name: 构建(项目页子路径 + demo 模式) - working-directory: admin + - name: 构建(项目页子路径 + 后端 API 地址) + working-directory: web env: - # 仓库名作为 GitHub Pages 项目页子路径 + # 仓库名作为 GitHub Pages 项目页子路径,如需自定义域名可改为 "/" VITE_BASE_PATH: "/${{ github.event.repository.name }}/" - VITE_ROUTER_BASE_PATH: "/${{ github.event.repository.name }}" - # demo 模式:跳过登录校验,直接放行看 UI - VITE_DEMO_MODE: "1" + VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} run: pnpm run build - name: SPA 路由回退(history 模式在 GitHub Pages 刷新深层路径会 404) - working-directory: admin + working-directory: web run: cp dist/index.html dist/404.html - uses: actions/upload-pages-artifact@v3 with: - path: admin/dist + path: web/dist deploy: if: github.repository != 'LingByte/CloudSteps' From 1a1882b3fdc3edd103fea272528e38820ced0f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:17:13 +0800 Subject: [PATCH 05/26] feat(web): add mock invite and recharge pages --- web/src/pages/CoachCenter.tsx | 18 ++++++++++ web/src/pages/InviteCode.tsx | 66 +++++++++++++++++++++++++++++++++++ web/src/pages/Recharge.tsx | 47 +++++++++++++++++++++++++ web/src/router/routes.tsx | 4 +++ 4 files changed, 135 insertions(+) create mode 100644 web/src/pages/InviteCode.tsx create mode 100644 web/src/pages/Recharge.tsx 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..d22ec7fb --- /dev/null +++ b/web/src/pages/InviteCode.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { Copy, Gift, Ticket, 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"; + +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: "已激活" }, +]; + +export default function InviteCode() { + const [code, setCode] = useState("CLOUD-7K9F2A"); + const link = `https://cloudsteps.example.com/i/${code.split("-")[1]}`; + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + showToast.success(`${label}已复制`); + } catch { + showToast.error("复制失败,请手动复制"); + } + }; + + return ( +
+ +
+
+ +
+

我的专属邀请码

+

{code}

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

邀请链接

+
+ {link} + void copy(link, "邀请链接")}> +
+
+ +
+

累计邀请

8

+

已激活

5

+
+ + +

邀请记录

+
+ {mockRecords.map((record) =>
{record.name}{record.date}{record.status}
)} +
+
+
+
+
+ ); +} diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx new file mode 100644 index 00000000..0e2a7018 --- /dev/null +++ b/web/src/pages/Recharge.tsx @@ -0,0 +1,47 @@ +import { useMemo, useState } from "react"; +import { Check, CreditCard, Wallet } from "lucide-react"; +import { PageBackHeader } from "../components/PageBackHeader"; +import { CloudButton } from "../components/cloudsteps"; +import { CloudCard } from "../components/cloudsteps/arco"; +import { showToast } from "../utils/toast"; + +const packages = [ + { amount: 6, bonus: 0 }, + { amount: 18, bonus: 1, tag: "入门" }, + { amount: 68, bonus: 8, tag: "热门" }, + { amount: 128, bonus: 18, tag: "超值" }, + { amount: 298, bonus: 48 }, + { amount: 648, bonus: 128, tag: "豪华" }, +]; + +const money = (value: number) => `¥${value.toFixed(value % 1 ? 2 : 0)}`; + +export default function Recharge() { + const [selected, setSelected] = useState(2); + const [custom, setCustom] = useState(""); + const [method, setMethod] = useState("微信支付"); + const current = useMemo(() => selected >= 0 ? packages[selected] : { amount: Number(custom) || 0, bonus: 0 }, [selected, custom]); + + const selectCustom = (value: string) => { + setCustom(value.replace(/[^\d]/g, "")); + if (value) setSelected(-1); + }; + + const recharge = () => { + if (!current.amount) return; + showToast.success(`充值成功:${money(current.amount)}${current.bonus ? `,赠送 ${money(current.bonus)}` : ""}`); + }; + + return ( +
+ +
+
+

当前余额

¥36.50

+

选择充值套餐

部分套餐含赠送金额,到账后可用于学习服务。

{packages.map((item, index) => )}
自定义金额 selectCustom(event.target.value)} inputMode="numeric" placeholder="请输入整数金额" className="w-full rounded-xl border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary" />
+

支付方式

{["微信支付", "支付宝", "银行卡"].map((item) => )}
实付 {money(current.amount)}{current.bonus ? 到账 {money(current.amount + current.bonus)} : null}
确认充值

演示页面,仅使用 mock 数据,不会真实扣款

+
+
+
+ ); +} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index d14c2e8b..6c2d6429 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -35,6 +35,8 @@ import Announcements from "../pages/Announcements"; import Login from "../pages/Login"; import Forbidden from "../pages/Forbidden"; import ProfileEdit from "../pages/ProfileEdit"; +import InviteCode from "../pages/InviteCode"; +import Recharge from "../pages/Recharge"; import About from "../pages/About"; import Terms from "../pages/Terms"; import Privacy from "../pages/Privacy"; @@ -119,6 +121,8 @@ export const router = createBrowserRouter( ), }, { path: "profile/edit", element: }, + { path: "invite-code", element: }, + { path: "recharge", element: }, { path: "notifications", element: }, ], }, From 67f7b41f68e7136bdbab06a20bc7d4f2d1862991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:26:55 +0800 Subject: [PATCH 06/26] feat(web): switch recharge page to membership plans --- web/src/pages/Recharge.tsx | 102 +++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index 0e2a7018..e97e3486 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -1,45 +1,95 @@ -import { useMemo, useState } from "react"; -import { Check, CreditCard, Wallet } from "lucide-react"; +import { useState } from "react"; +import { Check, CreditCard, Crown, Sparkles } from "lucide-react"; import { PageBackHeader } from "../components/PageBackHeader"; import { CloudButton } from "../components/cloudsteps"; import { CloudCard } from "../components/cloudsteps/arco"; import { showToast } from "../utils/toast"; -const packages = [ - { amount: 6, bonus: 0 }, - { amount: 18, bonus: 1, tag: "入门" }, - { amount: 68, bonus: 8, tag: "热门" }, - { amount: 128, bonus: 18, tag: "超值" }, - { amount: 298, bonus: 48 }, - { amount: 648, bonus: 128, tag: "豪华" }, +type MembershipPlan = { + id: string; + name: string; + period: string; + price: number; + description: string; + features: string[]; + tag?: string; +}; + +const plans: MembershipPlan[] = [ + { + id: "monthly", + name: "包月会员", + period: "1个月", + price: 18, + description: "灵活订阅,随时开始学习", + features: ["全站学习内容", "智能复习计划", "学习数据统计"], + }, + { + id: "yearly", + name: "包年会员", + period: "12个月", + price: 168, + description: "全年畅学,平均每月仅 ¥14", + features: ["全站学习内容", "智能复习计划", "学习数据统计", "专属会员标识"], + tag: "最受欢迎", + }, + { + id: "lifetime", + name: "永久会员", + period: "永久有效", + price: 498, + description: "一次购买,终身享受会员权益", + features: ["全站学习内容", "智能复习计划", "学习数据统计", "专属会员标识", "后续内容持续更新"], + tag: "超值推荐", + }, ]; -const money = (value: number) => `¥${value.toFixed(value % 1 ? 2 : 0)}`; +const money = (value: number) => `¥${value.toFixed(0)}`; export default function Recharge() { - const [selected, setSelected] = useState(2); - const [custom, setCustom] = useState(""); + const [selectedId, setSelectedId] = useState("yearly"); const [method, setMethod] = useState("微信支付"); - const current = useMemo(() => selected >= 0 ? packages[selected] : { amount: Number(custom) || 0, bonus: 0 }, [selected, custom]); - - const selectCustom = (value: string) => { - setCustom(value.replace(/[^\d]/g, "")); - if (value) setSelected(-1); - }; + const selected = plans.find((plan) => plan.id === selectedId) ?? plans[1]; - const recharge = () => { - if (!current.amount) return; - showToast.success(`充值成功:${money(current.amount)}${current.bonus ? `,赠送 ${money(current.bonus)}` : ""}`); + const submit = () => { + showToast.success(`${selected.name}购买成功,支付方式:${method}`); }; return (
- +
-
-

当前余额

¥36.50

-

选择充值套餐

部分套餐含赠送金额,到账后可用于学习服务。

{packages.map((item, index) => )}
自定义金额 selectCustom(event.target.value)} inputMode="numeric" placeholder="请输入整数金额" className="w-full rounded-xl border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary" />
-

支付方式

{["微信支付", "支付宝", "银行卡"].map((item) => )}
实付 {money(current.amount)}{current.bonus ? 到账 {money(current.amount + current.bonus)} : null}
确认充值

演示页面,仅使用 mock 数据,不会真实扣款

+
+ +
+
+
+

当前会员状态

普通用户

开通会员,解锁全部学习权益

+
+ + + +

选择会员套餐

+

包月、包年、永久会员,按需选择。

+
+ {plans.map((plan) => { + const active = selected.id === plan.id; + return ; + })} +
+
+ + +

支付方式

+
{["微信支付", "支付宝", "银行卡"].map((item) => )}
+

已选 {selected.name}

{money(selected.price)}
立即开通
+

演示页面,仅使用 mock 数据,不会真实扣款

+
From 12752d612b82ef3378043dbb53e0ac2be2764d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:35:50 +0800 Subject: [PATCH 07/26] feat(web): add QR invite poster sharing --- web/package.json | 1 + web/pnpm-lock.yaml | 211 +++++++++++++++++++++++++++++++++++ web/src/pages/InviteCode.tsx | 124 ++++++++++++++++++-- 3 files changed, 325 insertions(+), 11 deletions(-) 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/InviteCode.tsx b/web/src/pages/InviteCode.tsx index d22ec7fb..f553ebf1 100644 --- a/web/src/pages/InviteCode.tsx +++ b/web/src/pages/InviteCode.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; -import { Copy, Gift, Ticket, Users } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import QRCode from "qrcode"; +import { Copy, Download, Gift, Share2, Ticket, Users } from "lucide-react"; import { PageBackHeader } from "../components/PageBackHeader"; import { CloudButton } from "../components/cloudsteps"; import { CloudCard } from "../components/cloudsteps/arco"; @@ -12,9 +13,71 @@ const mockRecords = [ { name: "186****1190", date: "2026-08-21", status: "已激活" }, ]; +const inviteUrl = (code: string) => `https://cloudsteps.example.com/i/${code.split("-")[1]}`; + +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("无法生成分享图片"); + + const gradient = context.createLinearGradient(0, 0, size, size); + gradient.addColorStop(0, "#e6f8f1"); + gradient.addColorStop(1, "#d9f0ff"); + context.fillStyle = gradient; + context.fillRect(0, 0, size, size); + + context.fillStyle = "#ffffff"; + context.roundRect(55, 55, size - 110, size - 110, 36); + context.fill(); + 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); + 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 link = `https://cloudsteps.example.com/i/${code.split("-")[1]}`; + const [sharing, setSharing] = useState(false); + 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 { @@ -25,6 +88,47 @@ export default function InviteCode() { } }; + 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 sharePoster = async () => { + setSharing(true); + try { + const blob = await makePoster(code); + 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 { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = file.name; + anchor.click(); + URL.revokeObjectURL(url); + showToast.success("当前设备不支持直接分享,已下载图片"); + } + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) showToast.error("分享图片生成失败"); + } finally { + setSharing(false); + } + }; + return (
@@ -34,18 +138,18 @@ export default function InviteCode() {

我的专属邀请码

{code}

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

邀请链接

-
- {link} - void copy(link, "邀请链接")}> +
+
+

分享图片邀请好友

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

void sharePoster()} disabled={sharing}>{sharing ? "生成中…" : "分享图片"} void downloadPoster()} disabled={sharing}>保存图片
+
{link} void copy(link, "邀请链接")}>
@@ -55,9 +159,7 @@ export default function InviteCode() {

邀请记录

-
- {mockRecords.map((record) =>
{record.name}{record.date}{record.status}
)} -
+
{mockRecords.map((record) =>
{record.name}{record.date}{record.status}
)}
From fa75f4a4de4cd66ada0d9d65e7ea8df8a858c0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:38:44 +0800 Subject: [PATCH 08/26] feat(web): make invite QR shareable and saveable --- web/src/pages/InviteCode.tsx | 27 +++++++++++++++++++++++++-- web/src/pages/Login.tsx | 5 ++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/web/src/pages/InviteCode.tsx b/web/src/pages/InviteCode.tsx index f553ebf1..196f5cfd 100644 --- a/web/src/pages/InviteCode.tsx +++ b/web/src/pages/InviteCode.tsx @@ -13,7 +13,12 @@ const mockRecords = [ { name: "186****1190", date: "2026-08-21", status: "已激活" }, ]; -const inviteUrl = (code: string) => `https://cloudsteps.example.com/i/${code.split("-")[1]}`; +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; @@ -88,6 +93,24 @@ export default function InviteCode() { } }; + 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 { @@ -147,7 +170,7 @@ export default function InviteCode() {
-

分享图片邀请好友

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

void sharePoster()} disabled={sharing}>{sharing ? "生成中…" : "分享图片"} void downloadPoster()} disabled={sharing}>保存图片
+

分享图片邀请好友

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

void sharePoster()} disabled={sharing}>{sharing ? "生成中…" : "分享图片"}保存二维码 void downloadPoster()} disabled={sharing}>保存分享图
{link} void copy(link, "邀请链接")}>
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(""); From bc9e20c23d8c03c20f107e8aacb857d29b5b6814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:42:04 +0800 Subject: [PATCH 09/26] feat(web): redesign membership center with quarterly plan --- web/src/pages/Recharge.tsx | 125 ++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 65 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index e97e3486..f86a2419 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -1,95 +1,90 @@ -import { useState } from "react"; -import { Check, CreditCard, Crown, Sparkles } from "lucide-react"; -import { PageBackHeader } from "../components/PageBackHeader"; +import { useMemo, useState } from "react"; +import { ArrowLeft, Check, ChevronRight, Crown, LockKeyhole, Sparkles } 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 MembershipPlan = { +type Plan = { id: string; + tab: string; name: string; period: string; price: number; - description: string; - features: string[]; + monthly: string; + save?: string; tag?: string; + features: string[]; }; -const plans: MembershipPlan[] = [ - { - id: "monthly", - name: "包月会员", - period: "1个月", - price: 18, - description: "灵活订阅,随时开始学习", - features: ["全站学习内容", "智能复习计划", "学习数据统计"], - }, - { - id: "yearly", - name: "包年会员", - period: "12个月", - price: 168, - description: "全年畅学,平均每月仅 ¥14", - features: ["全站学习内容", "智能复习计划", "学习数据统计", "专属会员标识"], - tag: "最受欢迎", - }, - { - id: "lifetime", - name: "永久会员", - period: "永久有效", - price: 498, - description: "一次购买,终身享受会员权益", - features: ["全站学习内容", "智能复习计划", "学习数据统计", "专属会员标识", "后续内容持续更新"], - tag: "超值推荐", - }, +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%", tag: "推荐", 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 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 = plans.find((plan) => plan.id === selectedId) ?? plans[1]; + const selected = useMemo(() => plans.find((plan) => plan.id === selectedId) ?? plans[2], [selectedId]); + + const checkCoupon = () => { + if (coupon.length !== 6) { + showToast.error("请输入 6 位优惠码"); + return; + } + setCouponChecked(true); + showToast.success("优惠码可用,已享 9 折"); + }; const submit = () => { - showToast.success(`${selected.name}购买成功,支付方式:${method}`); + const finalPrice = couponChecked ? selected.price * 0.9 : selected.price; + showToast.success(`${selected.name}开通成功:${money(finalPrice)}(mock)`); }; return ( -
- -
-
- -
-
-
-

当前会员状态

普通用户

开通会员,解锁全部学习权益

-
- +
+
+ +

会员中心

+
•••
+
+ +
+
+ +

选择会员套餐

按需选择,开通即享完整权益

安全支付
+
{plans.map((plan) => )}
+ +
+

{selected.name}

{selected.period}

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

{money(selected.price)}

{selected.monthly}

+
+
{selected.features.map((feature) =>

{feature}

)}
- -

选择会员套餐

-

包月、包年、永久会员,按需选择。

-
- {plans.map((plan) => { - const active = selected.id === plan.id; - return ; - })} +

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ setCoupon(event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()); setCouponChecked(false); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-[#e5d4b4] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#d99d3c]" />

仅限从未购买过会员的用户,续费和升级不参与

+ + 立即开通 {selected.name}

一次性购买,不会自动续费

- -

支付方式

-
{["微信支付", "支付宝", "银行卡"].map((item) => )}
-

已选 {selected.name}

{money(selected.price)}
立即开通
-

演示页面,仅使用 mock 数据,不会真实扣款

-
+

会员权益对比

选择更适合你的方案

左右滑动查看全部
{comparison[0].map((item, index) => )}{comparison.slice(1).map((row) => {row.map((item, index) => )})}
{item}
{item}
+ +
选择支付方式
{["微信支付", "支付宝", "银行卡"].map((item) => )}

当前选择:{method}

From 5afc86e1e873dcc748917867c7ebed5f7017eeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 13:55:00 +0800 Subject: [PATCH 10/26] style(web): use CloudSteps theme colors on membership page --- web/src/pages/Recharge.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index f86a2419..de4c49ed 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -58,8 +58,8 @@ export default function Recharge() { }; return ( -
-
+
+

会员中心

•••
@@ -68,23 +68,23 @@ export default function Recharge() {
-

选择会员套餐

按需选择,开通即享完整权益

安全支付
-
{plans.map((plan) => )}
+

选择会员套餐

按需选择,开通即享完整权益

安全支付
+
{plans.map((plan) => )}
-
-

{selected.name}

{selected.period}

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

{money(selected.price)}

{selected.monthly}

+
+

{selected.name}

{selected.period}

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

{money(selected.price)}

{selected.monthly}

-
{selected.features.map((feature) =>

{feature}

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

{feature}

)}
-

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ setCoupon(event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()); setCouponChecked(false); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-[#e5d4b4] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#d99d3c]" />

仅限从未购买过会员的用户,续费和升级不参与

+

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ setCoupon(event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()); setCouponChecked(false); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-input bg-white px-3 py-2.5 text-sm outline-none focus:border-primary" />

仅限从未购买过会员的用户,续费和升级不参与

- 立即开通 {selected.name}

一次性购买,不会自动续费

+ 立即开通 {selected.name}

一次性购买,不会自动续费

-

会员权益对比

选择更适合你的方案

左右滑动查看全部
{comparison[0].map((item, index) => )}{comparison.slice(1).map((row) => {row.map((item, index) => )})}
{item}
{item}
+

会员权益对比

选择更适合你的方案

左右滑动查看全部
{comparison[0].map((item, index) => )}{comparison.slice(1).map((row) => {row.map((item, index) => )})}
{item}
{item}
-
选择支付方式
{["微信支付", "支付宝", "银行卡"].map((item) => )}

当前选择:{method}

+
选择支付方式
{["微信支付", "支付宝", "银行卡"].map((item) => )}

当前选择:{method}

From 216d6fe54b9614d2672f1bd12a8758fe0638d7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:00:25 +0800 Subject: [PATCH 11/26] style(web): soften invite card with CloudSteps colors --- web/src/pages/InviteCode.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/pages/InviteCode.tsx b/web/src/pages/InviteCode.tsx index 196f5cfd..3b8c1923 100644 --- a/web/src/pages/InviteCode.tsx +++ b/web/src/pages/InviteCode.tsx @@ -157,8 +157,10 @@ export default function InviteCode() {
- -
+ +
+
+

我的专属邀请码

{code}

From bbfa82035f18eaf040e3d04465472aa852c3fef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:06:11 +0800 Subject: [PATCH 12/26] feat(web): make membership pages standalone --- web/src/pages/InviteCode.tsx | 2 +- web/src/pages/Recharge.tsx | 2 +- web/src/router/routes.tsx | 18 ++++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/web/src/pages/InviteCode.tsx b/web/src/pages/InviteCode.tsx index 3b8c1923..4c68451e 100644 --- a/web/src/pages/InviteCode.tsx +++ b/web/src/pages/InviteCode.tsx @@ -155,7 +155,7 @@ export default function InviteCode() { return (
-
+
diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index de4c49ed..3029b85d 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -65,7 +65,7 @@ export default function Recharge() {
•••
-
+

选择会员套餐

按需选择,开通即享完整权益

安全支付
diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 6c2d6429..655547b3 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -121,8 +121,6 @@ export const router = createBrowserRouter( ), }, { path: "profile/edit", element: }, - { path: "invite-code", element: }, - { path: "recharge", element: }, { path: "notifications", element: }, ], }, @@ -167,6 +165,22 @@ export const router = createBrowserRouter( ), }, + { + path: "/invite-code", + element: ( + + + + ), + }, + { + path: "/recharge", + element: ( + + + + ), + }, { path: "/feedback", element: ( From 7d089a6298a2d20876aeb156b66a91592d890f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:08:24 +0800 Subject: [PATCH 13/26] style(web): match membership header layout --- web/src/pages/Recharge.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index 3029b85d..6c14c325 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -59,13 +59,16 @@ export default function Recharge() { return (
-
- -

会员中心

-
•••
+
+ +
+

会员中心

+

选择适合你的会员方案

+
+
•••
-
+

选择会员套餐

按需选择,开通即享完整权益

安全支付
From f0a2cf0000589a000c25b3c211b9809d77e394dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:15:07 +0800 Subject: [PATCH 14/26] feat(web): show CloudSteps logo on invite card --- web/src/pages/InviteCode.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/web/src/pages/InviteCode.tsx b/web/src/pages/InviteCode.tsx index 4c68451e..8d4645b6 100644 --- a/web/src/pages/InviteCode.tsx +++ b/web/src/pages/InviteCode.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import QRCode from "qrcode"; -import { Copy, Download, Gift, Share2, Ticket, Users } from "lucide-react"; +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"; @@ -37,6 +37,17 @@ async function makePoster(code: string): Promise { 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`; + }); + if (logoImage.complete && logoImage.naturalWidth > 0) { + context.drawImage(logoImage, 400, 75, 100, 55); + } + context.fillStyle = "#25344a"; context.textAlign = "center"; context.font = "600 42px sans-serif"; @@ -160,7 +171,7 @@ export default function InviteCode() {
-
+
云阶 Logo

我的专属邀请码

{code}

From d748241e421a611024d7244bc4d1b0c2828192eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:20:32 +0800 Subject: [PATCH 15/26] feat(web): preview invite poster before sharing --- web/src/pages/InviteCode.tsx | 49 ++++++++++++++++++++++++++---------- web/src/pages/Recharge.tsx | 2 +- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/web/src/pages/InviteCode.tsx b/web/src/pages/InviteCode.tsx index 8d4645b6..a5e9eeb2 100644 --- a/web/src/pages/InviteCode.tsx +++ b/web/src/pages/InviteCode.tsx @@ -5,6 +5,7 @@ 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: "已激活" }, @@ -83,6 +84,7 @@ async function makePoster(code: string): Promise { 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); @@ -132,7 +134,7 @@ export default function InviteCode() { anchor.download = `云阶邀请码-${code}.png`; anchor.click(); URL.revokeObjectURL(url); - showToast.success("分享图片已生成"); + showToast.success("分享图片已保存"); } catch { showToast.error("分享图片生成失败"); } finally { @@ -140,26 +142,35 @@ export default function InviteCode() { } }; - const sharePoster = async () => { + 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 { - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = file.name; - anchor.click(); - URL.revokeObjectURL(url); - showToast.success("当前设备不支持直接分享,已下载图片"); + await downloadPoster(); } } catch (error) { - if (!(error instanceof DOMException && error.name === "AbortError")) showToast.error("分享图片生成失败"); - } finally { - setSharing(false); + if (!(error instanceof DOMException && error.name === "AbortError")) showToast.error("分享图片失败"); } }; @@ -183,7 +194,7 @@ export default function InviteCode() {
-

分享图片邀请好友

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

void sharePoster()} disabled={sharing}>{sharing ? "生成中…" : "分享图片"}保存二维码 void downloadPoster()} disabled={sharing}>保存分享图
+

分享图片邀请好友

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

void openPosterPreview()} disabled={sharing}>{sharing ? "生成中…" : "分享图片"}保存二维码 void downloadPoster()} disabled={sharing}>保存分享图
{link} void copy(link, "邀请链接")}>
@@ -199,6 +210,18 @@ export default function InviteCode() {
+ + { if (!open && posterUrl) { URL.revokeObjectURL(posterUrl); setPosterUrl(null); } }}> + +
+ {posterUrl ? 云阶邀请码分享图片预览 : null} +
+ + void sharePoster()}>分享 + void downloadPoster()}>保存图片 + +
+
); } diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index 6c14c325..ee948f29 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -68,7 +68,7 @@ export default function Recharge() {
•••
-
+

选择会员套餐

按需选择,开通即享完整权益

安全支付
From cd7912141518f2b0433e1f0b702c0dbd7bb44d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:28:23 +0800 Subject: [PATCH 16/26] feat(web): polish membership purchase conversion flow --- web/src/pages/Recharge.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index ee948f29..c9a3a016 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -18,7 +18,7 @@ type Plan = { }; const plans: Plan[] = [ - { id: "monthly", tab: "月付", name: "月度会员", period: "1个月", price: 58, monthly: "¥58 / 月", features: ["无限学习", "全部功能无限制", "开通推广返佣", "优先客服支持"] }, + { 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%", tag: "推荐", features: ["无限学习", "全部功能无限制", "开通推广返佣", "赠送 1200 积分", "优先客服支持"] }, { id: "lifetime", tab: "永久会员", name: "永久会员", period: "永久有效", price: 498, monthly: "一次购买", save: "买断最划算", features: ["无限学习", "全部功能无限制", "开通推广返佣", "赠送 3000 积分", "优先客服支持", "后续内容持续更新"] }, @@ -42,9 +42,10 @@ export default function Recharge() { 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 = () => { - if (coupon.length !== 6) { + const checkCoupon = (value = coupon) => { + if (value.length !== 6) { showToast.error("请输入 6 位优惠码"); return; } @@ -53,7 +54,7 @@ export default function Recharge() { }; const submit = () => { - const finalPrice = couponChecked ? selected.price * 0.9 : selected.price; + if (!window.confirm(`确认开通${selected.name}?一次性购买,不会自动续费。`)) return; showToast.success(`${selected.name}开通成功:${money(finalPrice)}(mock)`); }; @@ -71,17 +72,17 @@ export default function Recharge() {
-

选择会员套餐

按需选择,开通即享完整权益

安全支付
-
{plans.map((plan) => )}
+

选择会员套餐,解锁全部学习能力

开通立即解锁全部会员权益

安全支付
+
{plans.map((plan) => )}
-

{selected.name}

{selected.period}

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

{money(selected.price)}

{selected.monthly}

+

{selected.name}

{selected.period}

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

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

折合 {selected.monthly} · {selected.period}有效期

{selected.features.map((feature) =>

{feature}

)}
-

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ setCoupon(event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()); setCouponChecked(false); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-input bg-white px-3 py-2.5 text-sm outline-none focus:border-primary" />

仅限从未购买过会员的用户,续费和升级不参与

+

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ const value = event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); setCoupon(value); setCouponChecked(false); if (value.length === 6) checkCoupon(value); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-input bg-white px-3 py-2.5 text-sm outline-none focus:border-primary" />

仅限从未购买过会员的用户,续费和升级不参与

- 立即开通 {selected.name}

一次性购买,不会自动续费

+ 立即开通 {selected.name}

一次性购买,不会自动续费

From 36cadd084873f583c6c1b395bf0bfea04cb5d4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:35:00 +0800 Subject: [PATCH 17/26] feat(web): refine membership plan presentation --- web/src/pages/Recharge.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index c9a3a016..dc808046 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -70,7 +70,7 @@ export default function Recharge() {
-
+

选择会员套餐,解锁全部学习能力

开通立即解锁全部会员权益

安全支付
{plans.map((plan) => )}
@@ -78,9 +78,9 @@ export default function Recharge() {

{selected.name}

{selected.period}

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

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

折合 {selected.monthly} · {selected.period}有效期

-
{selected.features.map((feature) =>

{feature}

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

{feature}

)}
-

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ const value = event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); setCoupon(value); setCouponChecked(false); if (value.length === 6) checkCoupon(value); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-input bg-white px-3 py-2.5 text-sm outline-none focus:border-primary" />

仅限从未购买过会员的用户,续费和升级不参与

+

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ const value = event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); setCoupon(value); setCouponChecked(false); if (value.length === 6) checkCoupon(value); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-input bg-card px-3 py-2.5 text-sm outline-none focus:border-primary" />

仅限从未购买过会员的用户,续费和升级不参与

立即开通 {selected.name}

一次性购买,不会自动续费

From 859e99d6ca2399bcb0d269c9540bd3afb5f22cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 14:43:51 +0800 Subject: [PATCH 18/26] fix(web): align membership header and tabs --- web/src/pages/Recharge.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index dc808046..8776fc07 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -60,20 +60,22 @@ export default function Recharge() { return (
-
- -
-

会员中心

-

选择适合你的会员方案

+
+
+ +
+

会员中心

+

选择适合你的会员方案

+
+
-
•••
-
+

选择会员套餐,解锁全部学习能力

开通立即解锁全部会员权益

安全支付
-
{plans.map((plan) => )}
+
{plans.map((plan) => )}

{selected.name}

{selected.period}

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

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

折合 {selected.monthly} · {selected.period}有效期

From 7e1398b8c73c4aa880db837257a56476bb2c98f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 15:43:24 +0800 Subject: [PATCH 19/26] refactor(web): ???????????? ???????????????????????????????????????????????????????? Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/Recharge.tsx | 107 ++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 26 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index 8776fc07..f6ab1554 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { ArrowLeft, Check, ChevronRight, Crown, LockKeyhole, Sparkles } from "lucide-react"; +import { ArrowLeft, Check, ChevronRight, CircleHelp, Crown, LockKeyhole, Sparkles, Tag } from "lucide-react"; import { useNavigate } from "react-router"; import { CloudButton } from "../components/cloudsteps"; import { CloudCard } from "../components/cloudsteps/arco"; @@ -13,14 +13,13 @@ type Plan = { price: number; monthly: string; save?: string; - tag?: 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%", tag: "推荐", features: ["无限学习", "全部功能无限制", "开通推广返佣", "赠送 1200 积分", "优先客服支持"] }, + { 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 积分", "优先客服支持", "后续内容持续更新"] }, ]; @@ -59,38 +58,94 @@ export default function Recharge() { }; return ( -
-
-
- -
-

会员中心

-

选择适合你的会员方案

+
+
+
+ +
+

会员中心

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

选择会员套餐,解锁全部学习能力

开通立即解锁全部会员权益

安全支付
-
{plans.map((plan) => )}
+
+
+
+
+

CloudSteps Plus

+

解锁完整学习能力

+

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

+
+ 安全支付 +
-
-

{selected.name}

{selected.period}

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

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

折合 {selected.monthly} · {selected.period}有效期

-
-
{selected.features.map((feature, index) =>

{feature}

)}
+
+ +
+

选择套餐

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

优惠码

使用有效优惠码,当前套餐可享 9 折

首单 9 折
{ const value = event.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); setCoupon(value); setCouponChecked(false); if (value.length === 6) checkCoupon(value); }} placeholder="输入 6 位优惠码" className="min-w-0 flex-1 rounded-lg border border-input bg-card px-3 py-2.5 text-sm outline-none focus:border-primary" />

仅限从未购买过会员的用户,续费和升级不参与

+
+
+
+ +

{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}
  • )} +
+
+ - 立即开通 {selected.name}

一次性购买,不会自动续费

-
-
+ +
-
选择支付方式
{["微信支付", "支付宝", "银行卡"].map((item) => )}

当前选择:{method}

+ +

会员权益对比

清晰比较不同方案,选择最适合你的会员

左右滑动查看
+
{comparison[0].map((item, index) => )}{comparison.slice(1).map((row) => {row.map((item, index) => )})}
会员套餐权益对比表
{item}
{item}
+
From b79e06812eb35da90b65d913f25a45c1b93f5891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 16:07:43 +0800 Subject: [PATCH 20/26] fix(web): ?????????? ???????????????????????????????????????? Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/Recharge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index f6ab1554..47053da4 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -85,8 +85,8 @@ export default function Recharge() { 安全支付
-
- +
+

选择套餐

按需选择,随时升级 From 59548cdc19825df3d50db1490b9320065468e3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com> Date: Wed, 2 Sep 2026 16:29:27 +0800 Subject: [PATCH 21/26] feat(web): ?????????? ??????????????????????????????????????????? Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/src/pages/Recharge.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/pages/Recharge.tsx b/web/src/pages/Recharge.tsx index 47053da4..7990f97c 100644 --- a/web/src/pages/Recharge.tsx +++ b/web/src/pages/Recharge.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { ArrowLeft, Check, ChevronRight, CircleHelp, Crown, LockKeyhole, Sparkles, Tag } from "lucide-react"; +import { ArrowLeft, Check, 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"; @@ -118,6 +118,15 @@ export default function Recharge() { {selected.features.map((feature, index) =>
  • {feature}
  • )}
    + +
    +

    会员服务保障

    开通即生效
    +
    +

    安全支付

    支付信息全程加密

    +

    持续更新

    新内容持续加入

    +

    专属支持

    遇到问题随时咨询

    +
    +