返回 AiToEarn
page.tsx
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
313 lines Plain Text