| 1 | /** |
| 2 | * ShortLink 中转页 |
| 3 | * 用于 PC 端抖音发布后,扫码通过深度链接唤起抖音 App |
| 4 | * API 返回 302 重定向到 snssdk1128:// scheme URL |
| 5 | * 采用多策略唤起:iframe + location.href + 超时降级 |
| 6 | * |
| 7 | * 注意:layout.tsx 创建了独立的 <html><body>,未引入全局 CSS, |
| 8 | * 因此本页面所有样式必须使用内联 style,不可使用 Tailwind 类名。 |
| 9 | */ |
| 10 | 'use client' |
| 11 | |
| 12 | import Image from 'next/image' |
| 13 | import { useSearchParams } from 'next/navigation' |
| 14 | import { Suspense, useCallback, useEffect, useRef, useState } from 'react' |
| 15 | |
| 16 | import logo from '@/assets/images/logo.png' |
| 17 | import douyinIcon from '@/assets/svgs/plat/douyin.svg' |
| 18 | import { openApp } from '@/utils/appLaunch' |
| 19 | |
| 20 | type LaunchStatus = 'loading' | 'launching' | 'failed' |
| 21 | |
| 22 | /* ─── 色彩常量(Tailwind 中性灰风格) ─── */ |
| 23 | const colors = { |
| 24 | bg: '#f8fafc', |
| 25 | card: '#ffffff', |
| 26 | border: '#e2e8f0', |
| 27 | text: '#1e293b', |
| 28 | textMuted: '#64748b', |
| 29 | error: '#ef4444', |
| 30 | errorBg: 'rgba(239,68,68,0.1)', |
| 31 | warning: '#f97316', |
| 32 | warningBg: 'rgba(249,115,22,0.1)', |
| 33 | btnBg: '#0f172a', |
| 34 | btnText: '#ffffff', |
| 35 | iconCircleBg: 'rgba(148,163,184,0.15)', |
| 36 | } |
| 37 | |
| 38 | /* ─── 内联 SVG 图标 ─── */ |
| 39 | function CircleAlertIcon({ color, size = 40 }: { color: string, size?: number }) { |
| 40 | return ( |
| 41 | <svg |
| 42 | width={size} |
| 43 | height={size} |
| 44 | viewBox="0 0 24 24" |
| 45 | fill="none" |
| 46 | stroke={color} |
| 47 | strokeWidth="2" |
| 48 | strokeLinecap="round" |
| 49 | strokeLinejoin="round" |
| 50 | > |
| 51 | <circle cx="12" cy="12" r="10" /> |
| 52 | <line x1="12" y1="8" x2="12" y2="12" /> |
| 53 | <line x1="12" y1="16" x2="12.01" y2="16" /> |
| 54 | </svg> |
| 55 | ) |
| 56 | } |
| 57 | |
| 58 | function LoaderIcon({ color, size = 24 }: { color: string, size?: number }) { |
| 59 | return ( |
| 60 | <svg |
| 61 | width={size} |
| 62 | height={size} |
| 63 | viewBox="0 0 24 24" |
| 64 | fill="none" |
| 65 | stroke={color} |
| 66 | strokeWidth="2" |
| 67 | strokeLinecap="round" |
| 68 | strokeLinejoin="round" |
| 69 | style={{ animation: 'shortlink-spin 1s linear infinite' }} |
| 70 | > |
| 71 | <path d="M21 12a9 9 0 1 1-6.219-8.56" /> |
| 72 | </svg> |
| 73 | ) |
| 74 | } |
| 75 | |
| 76 | function RefreshIcon({ color, size = 16 }: { color: string, size?: number }) { |
| 77 | return ( |
| 78 | <svg |
| 79 | width={size} |
| 80 | height={size} |
| 81 | viewBox="0 0 24 24" |
| 82 | fill="none" |
| 83 | stroke={color} |
| 84 | strokeWidth="2" |
| 85 | strokeLinecap="round" |
| 86 | strokeLinejoin="round" |
| 87 | > |
| 88 | <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /> |
| 89 | <path d="M21 3v5h-5" /> |
| 90 | <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /> |
| 91 | <path d="M3 21v-5h5" /> |
| 92 | </svg> |
| 93 | ) |
| 94 | } |
| 95 | |
| 96 | /** 注入 keyframes 动画的 style 标签 */ |
| 97 | function AnimationStyles() { |
| 98 | return ( |
| 99 | <style> |
| 100 | {` |
| 101 | @keyframes shortlink-spin { |
| 102 | from { transform: rotate(0deg); } |
| 103 | to { transform: rotate(360deg); } |
| 104 | } |
| 105 | `} |
| 106 | </style> |
| 107 | ) |
| 108 | } |
| 109 | |
| 110 | /** 品牌 Logo 区域 */ |
| 111 | function BrandHeader() { |
| 112 | return ( |
| 113 | <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}> |
| 114 | <Image src={logo} alt="AiToEarn" width={32} height={32} style={{ borderRadius: 8 }} /> |
| 115 | <span style={{ fontSize: 18, fontWeight: 600, color: colors.text, letterSpacing: '-0.025em' }}>AiToEarn</span> |
| 116 | </div> |
| 117 | ) |
| 118 | } |
| 119 | |
| 120 | /** 抖音平台图标 */ |
| 121 | function DouyinIcon() { |
| 122 | return ( |
| 123 | <div style={{ position: 'relative', width: 80, height: 80, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> |
| 124 | <div style={{ position: 'absolute', inset: 0, borderRadius: '50%', backgroundColor: colors.iconCircleBg }} /> |
| 125 | <Image src={douyinIcon} alt="抖音" width={48} height={48} style={{ position: 'relative', zIndex: 1 }} /> |
| 126 | </div> |
| 127 | ) |
| 128 | } |
| 129 | |
| 130 | /** 页面外壳:居中卡片布局 */ |
| 131 | function PageShell({ children }: { children: React.ReactNode }) { |
| 132 | return ( |
| 133 | <div |
| 134 | style={{ |
| 135 | display: 'flex', |
| 136 | minHeight: '100vh', |
| 137 | alignItems: 'center', |
| 138 | justifyContent: 'center', |
| 139 | backgroundColor: colors.bg, |
| 140 | padding: '32px 16px', |
| 141 | boxSizing: 'border-box', |
| 142 | fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', |
| 143 | }} |
| 144 | > |
| 145 | <AnimationStyles /> |
| 146 | <div |
| 147 | style={{ |
| 148 | width: '100%', |
| 149 | maxWidth: 384, |
| 150 | borderRadius: 16, |
| 151 | border: `1px solid ${colors.border}`, |
| 152 | backgroundColor: colors.card, |
| 153 | padding: 32, |
| 154 | boxShadow: '0 1px 3px rgba(0,0,0,0.06)', |
| 155 | boxSizing: 'border-box', |
| 156 | }} |
| 157 | > |
| 158 | {children} |
| 159 | </div> |
| 160 | </div> |
| 161 | ) |
| 162 | } |
| 163 | |
| 164 | function ShortLinkContent() { |
| 165 | const searchParams = useSearchParams() |
| 166 | const [error, setError] = useState<string>() |
| 167 | const [status, setStatus] = useState<LaunchStatus>('loading') |
| 168 | const apiLinkRef = useRef<string>('') |
| 169 | |
| 170 | const handleLaunch = useCallback((apiUrl: string) => { |
| 171 | setStatus('launching') |
| 172 | openApp(apiUrl, () => setStatus('failed')) |
| 173 | }, []) |
| 174 | |
| 175 | const handleRetry = useCallback(() => { |
| 176 | if (apiLinkRef.current) { |
| 177 | handleLaunch(apiLinkRef.current) |
| 178 | } |
| 179 | }, [handleLaunch]) |
| 180 | |
| 181 | useEffect(() => { |
| 182 | const apiLink = searchParams.get('apiLink') |
| 183 | if (!apiLink) { |
| 184 | setError('缺少链接参数') |
| 185 | return |
| 186 | } |
| 187 | |
| 188 | apiLinkRef.current = apiLink |
| 189 | handleLaunch(apiLink) |
| 190 | }, [searchParams, handleLaunch]) |
| 191 | |
| 192 | const containerStyle: React.CSSProperties = { |
| 193 | display: 'flex', |
| 194 | flexDirection: 'column', |
| 195 | alignItems: 'center', |
| 196 | gap: 24, |
| 197 | } |
| 198 | |
| 199 | const iconCircleStyle = (bgColor: string): React.CSSProperties => ({ |
| 200 | display: 'flex', |
| 201 | width: 80, |
| 202 | height: 80, |
| 203 | alignItems: 'center', |
| 204 | justifyContent: 'center', |
| 205 | borderRadius: '50%', |
| 206 | backgroundColor: bgColor, |
| 207 | }) |
| 208 | |
| 209 | const titleStyle: React.CSSProperties = { |
| 210 | fontSize: 16, |
| 211 | fontWeight: 500, |
| 212 | color: colors.text, |
| 213 | margin: 0, |
| 214 | } |
| 215 | |
| 216 | const subtitleStyle: React.CSSProperties = { |
| 217 | fontSize: 14, |
| 218 | color: colors.textMuted, |
| 219 | marginTop: 6, |
| 220 | } |
| 221 | |
| 222 | const buttonStyle: React.CSSProperties = { |
| 223 | display: 'inline-flex', |
| 224 | alignItems: 'center', |
| 225 | justifyContent: 'center', |
| 226 | gap: 8, |
| 227 | minHeight: 44, |
| 228 | padding: '0 24px', |
| 229 | borderRadius: 8, |
| 230 | border: 'none', |
| 231 | backgroundColor: colors.btnBg, |
| 232 | color: colors.btnText, |
| 233 | fontSize: 14, |
| 234 | fontWeight: 500, |
| 235 | cursor: 'pointer', |
| 236 | WebkitTapHighlightColor: 'transparent', |
| 237 | } |
| 238 | |
| 239 | // Error 状态 |
| 240 | if (error) { |
| 241 | return ( |
| 242 | <PageShell> |
| 243 | <div style={containerStyle}> |
| 244 | <BrandHeader /> |
| 245 | <div style={iconCircleStyle(colors.errorBg)}> |
| 246 | <CircleAlertIcon color={colors.error} /> |
| 247 | </div> |
| 248 | <div style={{ textAlign: 'center' }}> |
| 249 | <p style={titleStyle}>{error}</p> |
| 250 | <p style={subtitleStyle}>请检查链接是否正确</p> |
| 251 | </div> |
| 252 | </div> |
| 253 | </PageShell> |
| 254 | ) |
| 255 | } |
| 256 | |
| 257 | // Failed 状态 |
| 258 | if (status === 'failed') { |
| 259 | return ( |
| 260 | <PageShell> |
| 261 | <div style={containerStyle}> |
| 262 | <BrandHeader /> |
| 263 | <div style={iconCircleStyle(colors.warningBg)}> |
| 264 | <CircleAlertIcon color={colors.warning} /> |
| 265 | </div> |
| 266 | <div style={{ textAlign: 'center' }}> |
| 267 | <p style={titleStyle}>唤起抖音失败</p> |
| 268 | <p style={subtitleStyle}>请手动打开抖音 App</p> |
| 269 | </div> |
| 270 | <button onClick={handleRetry} style={buttonStyle}> |
| 271 | <RefreshIcon color={colors.btnText} /> |
| 272 | 重试 |
| 273 | </button> |
| 274 | </div> |
| 275 | </PageShell> |
| 276 | ) |
| 277 | } |
| 278 | |
| 279 | // Loading / Launching 状态 |
| 280 | return ( |
| 281 | <PageShell> |
| 282 | <div style={containerStyle}> |
| 283 | <BrandHeader /> |
| 284 | <DouyinIcon /> |
| 285 | <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}> |
| 286 | <LoaderIcon color={colors.textMuted} /> |
| 287 | <p style={{ fontSize: 14, color: colors.textMuted, margin: 0 }}> |
| 288 | {status === 'loading' ? '正在加载...' : '正在唤起抖音...'} |
| 289 | </p> |
| 290 | </div> |
| 291 | </div> |
| 292 | </PageShell> |
| 293 | ) |
| 294 | } |
| 295 | |
| 296 | export default function ShortLinkPage() { |
| 297 | return ( |
| 298 | <Suspense |
| 299 | fallback={( |
| 300 | <PageShell> |
| 301 | <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24 }}> |
| 302 | <BrandHeader /> |
| 303 | <DouyinIcon /> |
| 304 | <LoaderIcon color={colors.textMuted} /> |
| 305 | </div> |
| 306 | </PageShell> |
| 307 | )} |
| 308 | > |
| 309 | <ShortLinkContent /> |
| 310 | </Suspense> |
| 311 | ) |
| 312 | } |
| 313 |