Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6a7985a
feat(admin): add invite-code and recharge settings pages with mock data
17793426456 Sep 2, 2026
8fe652c
ci: deploy admin to GitHub Pages with demo mode
17793426456 Sep 2, 2026
8c1f063
fix: configure router base path for GitHub Pages
17793426456 Sep 2, 2026
8f4913f
fix: restore CloudSteps web Pages deployment
17793426456 Sep 2, 2026
1a1882b
feat(web): add mock invite and recharge pages
17793426456 Sep 2, 2026
67f7b41
feat(web): switch recharge page to membership plans
17793426456 Sep 2, 2026
12752d6
feat(web): add QR invite poster sharing
17793426456 Sep 2, 2026
fa75f4a
feat(web): make invite QR shareable and saveable
17793426456 Sep 2, 2026
bc9e20c
feat(web): redesign membership center with quarterly plan
17793426456 Sep 2, 2026
5afc86e
style(web): use CloudSteps theme colors on membership page
17793426456 Sep 2, 2026
216d6fe
style(web): soften invite card with CloudSteps colors
17793426456 Sep 2, 2026
bbfa820
feat(web): make membership pages standalone
17793426456 Sep 2, 2026
7d089a6
style(web): match membership header layout
17793426456 Sep 2, 2026
f0a2cf0
feat(web): show CloudSteps logo on invite card
17793426456 Sep 2, 2026
d748241
feat(web): preview invite poster before sharing
17793426456 Sep 2, 2026
cd79121
feat(web): polish membership purchase conversion flow
17793426456 Sep 2, 2026
36cadd0
feat(web): refine membership plan presentation
17793426456 Sep 2, 2026
859e99d
fix(web): align membership header and tabs
17793426456 Sep 2, 2026
7e1398b
refactor(web): ????????????
17793426456 Sep 2, 2026
b79e068
fix(web): ??????????
17793426456 Sep 2, 2026
59548cd
feat(web): ??????????
17793426456 Sep 2, 2026
c01f2d5
style(web): ??????????
17793426456 Sep 2, 2026
2d72e5b
feat(web): ??????????
17793426456 Sep 2, 2026
6d9740a
fix(web): ??????????
17793426456 Sep 2, 2026
3935e6d
style(web): ????????????
17793426456 Sep 2, 2026
9e72148
feat(web): ????????????
17793426456 Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions admin/src/components/layout/authenticated-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 (
<div className='flex h-svh items-center justify-center text-sm text-muted-foreground'>
正在验证登录状态…
Expand Down
9 changes: 2 additions & 7 deletions admin/src/features/scenario-sessions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -98,11 +97,7 @@ export function ScenarioSessionsPage() {
</select>
</div>
{userId && (
<Button
size='sm'
variant='ghost'
onClick={() => setUserId('')}
>
<Button size='sm' variant='ghost' onClick={() => setUserId('')}>
清除用户
</Button>
)}
Expand Down
20 changes: 19 additions & 1 deletion admin/src/features/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,6 +23,16 @@ const sidebarNavItems = [
href: '/settings',
icon: <UserCog size={18} />,
},
{
title: '邀请码',
href: '/settings/invite-code',
icon: <Ticket size={18} />,
},
{
title: '账户充值',
href: '/settings/recharge',
icon: <Wallet size={18} />,
},
{
title: '通知',
href: '/settings/notifications',
Expand Down
14 changes: 14 additions & 0 deletions admin/src/features/settings/invite-code/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ContentSection } from '../components/content-section'
import { InviteCodePanel } from './invite-code'

export function SettingsInviteCode() {
return (
<ContentSection
wide
title='邀请码'
desc='查看你的专属邀请码与邀请记录,分享给好友即可邀请注册。'
>
<InviteCodePanel />
</ContentSection>
)
}
195 changes: 195 additions & 0 deletions admin/src/features/settings/invite-code/invite-code.tsx
Original file line number Diff line number Diff line change
@@ -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<InviteRecord['status'], string> = {
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<InviteRecord[]>(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 (
<div className='space-y-6'>
{/* ===== 我的邀请码 ===== */}
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Ticket size={18} />
我的邀请码
</CardTitle>
<CardDescription>
把邀请码或邀请链接分享给好友,好友注册后即可在下方看到记录。
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-baseline gap-3'>
<span className='font-mono text-2xl font-bold tracking-wider'>
{code}
</span>
<span className='text-xs text-muted-foreground'>
生成于 {formatDateTime(mockInviteCode.createdAt)}
</span>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => copyText(code, '邀请码')}
>
<Copy size={14} />
复制邀请码
</Button>
<Button
variant='outline'
size='sm'
onClick={onRegenerate}
disabled={regenerating}
>
<RefreshCw size={14} />
{regenerating ? '生成中…' : '生成新码'}
</Button>
</div>
</div>

<div className='flex flex-col gap-2 rounded-lg border bg-muted/40 p-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0'>
<div className='text-xs text-muted-foreground'>专属邀请链接</div>
<div className='truncate font-mono text-sm'>{link}</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => copyText(link, '邀请链接')}
>
<Copy size={14} />
复制链接
</Button>
</div>
</CardContent>
</Card>

{/* ===== 统计 ===== */}
<div className='grid gap-4 sm:grid-cols-2'>
<Card>
<CardHeader>
<CardDescription>累计邀请</CardDescription>
<CardTitle className='text-3xl'>{totalInvited}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>已激活</CardDescription>
<CardTitle className='text-3xl'>{totalActivated}</CardTitle>
</CardHeader>
</Card>
</div>

{/* ===== 邀请记录 ===== */}
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Users size={18} />
邀请记录
</CardTitle>
<CardDescription>
好友通过你的邀请码注册后会出现于此。
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>被邀请人</TableHead>
<TableHead>注册时间</TableHead>
<TableHead>状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className='h-24 text-center text-muted-foreground'
>
暂无邀请记录
</TableCell>
</TableRow>
) : (
records.map((r) => (
<TableRow key={r.id}>
<TableCell className='font-medium'>{r.invitee}</TableCell>
<TableCell>{formatDateTime(r.registeredAt)}</TableCell>
<TableCell>
<Badge
variant={
r.status === 'activated' ? 'default' : 'secondary'
}
>
{statusLabel[r.status]}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
88 changes: 88 additions & 0 deletions admin/src/features/settings/invite-code/mock.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
Loading
Loading