@windrun-huaiin/third-ui 14.0.3 → 14.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clerk/fingerprint/fingerprint-provider.js +150 -26
- package/dist/clerk/fingerprint/fingerprint-provider.mjs +151 -27
- package/dist/clerk/fingerprint/types.d.ts +1 -0
- package/dist/clerk/fingerprint/use-fingerprint.js +20 -1
- package/dist/clerk/fingerprint/use-fingerprint.mjs +21 -2
- package/package.json +1 -1
- package/src/clerk/fingerprint/fingerprint-provider.tsx +349 -87
- package/src/clerk/fingerprint/types.ts +2 -1
- package/src/clerk/fingerprint/use-fingerprint.ts +23 -2
|
@@ -21,6 +21,7 @@ function useFingerprint(config) {
|
|
|
21
21
|
isLoading: false,
|
|
22
22
|
isInitialized: false,
|
|
23
23
|
error: 'Server-side rendering is not supported',
|
|
24
|
+
clearError: () => { },
|
|
24
25
|
initializeAnonymousUser: () => tslib_es6.__awaiter(this, void 0, void 0, function* () { }),
|
|
25
26
|
refreshUserData: () => tslib_es6.__awaiter(this, void 0, void 0, function* () { }),
|
|
26
27
|
};
|
|
@@ -32,6 +33,11 @@ function useFingerprint(config) {
|
|
|
32
33
|
const [isLoading, setIsLoading] = React.useState(true);
|
|
33
34
|
const [isInitialized, setIsInitialized] = React.useState(false);
|
|
34
35
|
const [error, setError] = React.useState(null);
|
|
36
|
+
const isInitializingAnonymousUserRef = React.useRef(false);
|
|
37
|
+
const requestedAnonymousFingerprintRef = React.useRef(null);
|
|
38
|
+
const clearError = React.useCallback(() => {
|
|
39
|
+
setError(null);
|
|
40
|
+
}, []);
|
|
35
41
|
/**
|
|
36
42
|
* 第一阶段:初始化fingerprint ID
|
|
37
43
|
*/
|
|
@@ -60,7 +66,17 @@ function useFingerprint(config) {
|
|
|
60
66
|
setError('Cannot initialize user: Missing fingerprint ID');
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
69
|
+
if (isInitializingAnonymousUserRef.current) {
|
|
70
|
+
console.log('Skipping anonymous user initialization because a request is already in flight:', fingerprintId);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (requestedAnonymousFingerprintRef.current === fingerprintId && isInitialized) {
|
|
74
|
+
console.log('Skipping anonymous user initialization because fingerprint is already initialized:', fingerprintId);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
63
77
|
try {
|
|
78
|
+
isInitializingAnonymousUserRef.current = true;
|
|
79
|
+
requestedAnonymousFingerprintRef.current = fingerprintId;
|
|
64
80
|
setIsLoading(true);
|
|
65
81
|
setError(null);
|
|
66
82
|
console.log('Initializing anonymous user with fingerprintId:', fingerprintId);
|
|
@@ -92,13 +108,15 @@ function useFingerprint(config) {
|
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
catch (err) {
|
|
111
|
+
requestedAnonymousFingerprintRef.current = null;
|
|
95
112
|
console.error('Failed to initialize anonymous user:', err);
|
|
96
113
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
97
114
|
}
|
|
98
115
|
finally {
|
|
116
|
+
isInitializingAnonymousUserRef.current = false;
|
|
99
117
|
setIsLoading(false);
|
|
100
118
|
}
|
|
101
|
-
}), [fingerprintId, config.apiEndpoint]);
|
|
119
|
+
}), [fingerprintId, config.apiEndpoint, isInitialized]);
|
|
102
120
|
/**
|
|
103
121
|
* 刷新用户数据 - 使用POST请求(后端支持upsert逻辑)
|
|
104
122
|
*/
|
|
@@ -157,6 +175,7 @@ function useFingerprint(config) {
|
|
|
157
175
|
isLoading,
|
|
158
176
|
isInitialized,
|
|
159
177
|
error,
|
|
178
|
+
clearError,
|
|
160
179
|
initializeAnonymousUser,
|
|
161
180
|
refreshUserData,
|
|
162
181
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { __awaiter } from '../../node_modules/.pnpm/@rollup_plugin-typescript@12.1.4_rollup@4.46.2_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.mjs';
|
|
3
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
3
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
4
|
import { getOrCreateFirstTouchData, getOrGenerateFingerprintId, createFingerprintHeaders } from './fingerprint-client.mjs';
|
|
5
5
|
import { FINGERPRINT_SOURCE_REFER } from './fingerprint-shared.mjs';
|
|
6
6
|
|
|
@@ -19,6 +19,7 @@ function useFingerprint(config) {
|
|
|
19
19
|
isLoading: false,
|
|
20
20
|
isInitialized: false,
|
|
21
21
|
error: 'Server-side rendering is not supported',
|
|
22
|
+
clearError: () => { },
|
|
22
23
|
initializeAnonymousUser: () => __awaiter(this, void 0, void 0, function* () { }),
|
|
23
24
|
refreshUserData: () => __awaiter(this, void 0, void 0, function* () { }),
|
|
24
25
|
};
|
|
@@ -30,6 +31,11 @@ function useFingerprint(config) {
|
|
|
30
31
|
const [isLoading, setIsLoading] = useState(true);
|
|
31
32
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
32
33
|
const [error, setError] = useState(null);
|
|
34
|
+
const isInitializingAnonymousUserRef = useRef(false);
|
|
35
|
+
const requestedAnonymousFingerprintRef = useRef(null);
|
|
36
|
+
const clearError = useCallback(() => {
|
|
37
|
+
setError(null);
|
|
38
|
+
}, []);
|
|
33
39
|
/**
|
|
34
40
|
* 第一阶段:初始化fingerprint ID
|
|
35
41
|
*/
|
|
@@ -58,7 +64,17 @@ function useFingerprint(config) {
|
|
|
58
64
|
setError('Cannot initialize user: Missing fingerprint ID');
|
|
59
65
|
return;
|
|
60
66
|
}
|
|
67
|
+
if (isInitializingAnonymousUserRef.current) {
|
|
68
|
+
console.log('Skipping anonymous user initialization because a request is already in flight:', fingerprintId);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (requestedAnonymousFingerprintRef.current === fingerprintId && isInitialized) {
|
|
72
|
+
console.log('Skipping anonymous user initialization because fingerprint is already initialized:', fingerprintId);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
61
75
|
try {
|
|
76
|
+
isInitializingAnonymousUserRef.current = true;
|
|
77
|
+
requestedAnonymousFingerprintRef.current = fingerprintId;
|
|
62
78
|
setIsLoading(true);
|
|
63
79
|
setError(null);
|
|
64
80
|
console.log('Initializing anonymous user with fingerprintId:', fingerprintId);
|
|
@@ -90,13 +106,15 @@ function useFingerprint(config) {
|
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
catch (err) {
|
|
109
|
+
requestedAnonymousFingerprintRef.current = null;
|
|
93
110
|
console.error('Failed to initialize anonymous user:', err);
|
|
94
111
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
95
112
|
}
|
|
96
113
|
finally {
|
|
114
|
+
isInitializingAnonymousUserRef.current = false;
|
|
97
115
|
setIsLoading(false);
|
|
98
116
|
}
|
|
99
|
-
}), [fingerprintId, config.apiEndpoint]);
|
|
117
|
+
}), [fingerprintId, config.apiEndpoint, isInitialized]);
|
|
100
118
|
/**
|
|
101
119
|
* 刷新用户数据 - 使用POST请求(后端支持upsert逻辑)
|
|
102
120
|
*/
|
|
@@ -155,6 +173,7 @@ function useFingerprint(config) {
|
|
|
155
173
|
isLoading,
|
|
156
174
|
isInitialized,
|
|
157
175
|
error,
|
|
176
|
+
clearError,
|
|
158
177
|
initializeAnonymousUser,
|
|
159
178
|
refreshUserData,
|
|
160
179
|
};
|
package/package.json
CHANGED
|
@@ -7,6 +7,8 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState
|
|
|
7
7
|
import type { FingerprintContextType, FingerprintProviderProps } from './types';
|
|
8
8
|
import { useFingerprint } from './use-fingerprint';
|
|
9
9
|
import { CopyableText } from '@windrun-huaiin/base-ui/ui';
|
|
10
|
+
import { createFingerprintHeaders, setFingerprintId } from './fingerprint-client';
|
|
11
|
+
import { FINGERPRINT_SOURCE_REFER } from './fingerprint-shared';
|
|
10
12
|
|
|
11
13
|
const FingerprintContext = createContext<FingerprintContextType | undefined>(undefined);
|
|
12
14
|
|
|
@@ -73,10 +75,16 @@ export function FingerprintStatus() {
|
|
|
73
75
|
xUser,
|
|
74
76
|
xCredit,
|
|
75
77
|
xSubscription,
|
|
76
|
-
error
|
|
78
|
+
error,
|
|
79
|
+
clearError,
|
|
80
|
+
initializeAnonymousUser,
|
|
77
81
|
} = useFingerprintContext();
|
|
78
82
|
|
|
79
83
|
const [isOpen, setIsOpen] = useState(false);
|
|
84
|
+
const [panelMode, setPanelMode] = useState<'info' | 'test'>('info');
|
|
85
|
+
const [testFingerprintId, setTestFingerprintId] = useState('');
|
|
86
|
+
const [testResult, setTestResult] = useState<string>('');
|
|
87
|
+
const [isRunningTest, setIsRunningTest] = useState(false);
|
|
80
88
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
81
89
|
|
|
82
90
|
const handleToggle = () => setIsOpen(prev => !prev);
|
|
@@ -99,12 +107,21 @@ export function FingerprintStatus() {
|
|
|
99
107
|
}
|
|
100
108
|
}, [xUser]);
|
|
101
109
|
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (testFingerprintId) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const defaultFingerprintId = buildDebugFingerprintId();
|
|
116
|
+
setTestFingerprintId(defaultFingerprintId);
|
|
117
|
+
}, [testFingerprintId]);
|
|
118
|
+
|
|
102
119
|
const creditBuckets = useMemo(() => {
|
|
103
120
|
if (!xCredit) return [];
|
|
104
121
|
return [
|
|
105
122
|
{
|
|
106
123
|
key: 'paid',
|
|
107
|
-
label: '
|
|
124
|
+
label: 'Paid',
|
|
108
125
|
icon: <icons.Settings2 className="size-4 text-green-500 dark:text-green-300" />,
|
|
109
126
|
balance: xCredit.balancePaid,
|
|
110
127
|
total: xCredit.totalPaidLimit,
|
|
@@ -113,7 +130,7 @@ export function FingerprintStatus() {
|
|
|
113
130
|
},
|
|
114
131
|
{
|
|
115
132
|
key: 'oneTimePaid',
|
|
116
|
-
label: '
|
|
133
|
+
label: 'OneTimePaid',
|
|
117
134
|
icon: <icons.Coins className="size-4 text-amber-500 dark:text-amber-300" />,
|
|
118
135
|
balance: xCredit.balanceOneTimePaid,
|
|
119
136
|
total: xCredit.totalOneTimePaidLimit,
|
|
@@ -122,7 +139,7 @@ export function FingerprintStatus() {
|
|
|
122
139
|
},
|
|
123
140
|
{
|
|
124
141
|
key: 'free',
|
|
125
|
-
label: '
|
|
142
|
+
label: 'Free',
|
|
126
143
|
icon: <icons.Gift className="size-4 text-purple-500 dark:text-purple-300" />,
|
|
127
144
|
balance: xCredit.balanceFree,
|
|
128
145
|
total: xCredit.totalFreeLimit,
|
|
@@ -135,10 +152,10 @@ export function FingerprintStatus() {
|
|
|
135
152
|
const subscriptionStatus = useMemo(() => {
|
|
136
153
|
if (!xSubscription) {
|
|
137
154
|
return {
|
|
138
|
-
status: '
|
|
155
|
+
status: 'Never',
|
|
139
156
|
priceName: '--',
|
|
140
157
|
creditsAllocated: '--',
|
|
141
|
-
period: '
|
|
158
|
+
period: 'Unavailable',
|
|
142
159
|
};
|
|
143
160
|
}
|
|
144
161
|
return {
|
|
@@ -154,6 +171,114 @@ export function FingerprintStatus() {
|
|
|
154
171
|
const userStatus = xUser?.status || '--';
|
|
155
172
|
const totalCredits = formatNumber(xCredit?.totalBalance);
|
|
156
173
|
const subStatus = subscriptionStatus.status;
|
|
174
|
+
const themedGhostButtonClass = cn(
|
|
175
|
+
'border-slate-200 bg-white/90 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950/80 dark:hover:bg-slate-900',
|
|
176
|
+
'hover:border-current',
|
|
177
|
+
themeIconColor
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const runContextParallelInitTest = async () => {
|
|
181
|
+
setIsRunningTest(true);
|
|
182
|
+
setTestResult('Running context parallel init x3...');
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await Promise.all([
|
|
186
|
+
initializeAnonymousUser(),
|
|
187
|
+
initializeAnonymousUser(),
|
|
188
|
+
initializeAnonymousUser(),
|
|
189
|
+
]);
|
|
190
|
+
setTestResult(`Context parallel init finished. Active fingerprint: ${fingerprintId || '--'}`);
|
|
191
|
+
} catch (testError) {
|
|
192
|
+
setTestResult(`Context parallel init failed: ${formatErrorMessage(testError)}`);
|
|
193
|
+
} finally {
|
|
194
|
+
setIsRunningTest(false);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const runRawParallelPostTest = async () => {
|
|
199
|
+
const normalizedFingerprintId = testFingerprintId.trim();
|
|
200
|
+
if (!normalizedFingerprintId) {
|
|
201
|
+
setTestResult('Please input a valid test fingerprint id.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setIsRunningTest(true);
|
|
206
|
+
setTestResult(`Running raw POST x3 with fingerprint: ${normalizedFingerprintId}`);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const fingerprintHeaders = await createFingerprintHeaders();
|
|
210
|
+
const requests = Array.from({ length: 3 }, () => fetch('/api/user/anonymous/init', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: {
|
|
213
|
+
'Content-Type': 'application/json',
|
|
214
|
+
[FINGERPRINT_SOURCE_REFER]: document.referrer || '',
|
|
215
|
+
...fingerprintHeaders,
|
|
216
|
+
'x-fingerprint-id-v8': normalizedFingerprintId,
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify({ fingerprintId: normalizedFingerprintId }),
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
const responses = await Promise.all(requests);
|
|
222
|
+
const payloads = await Promise.all(responses.map(async (response) => {
|
|
223
|
+
const data = await response.json().catch(() => ({}));
|
|
224
|
+
return {
|
|
225
|
+
ok: response.ok,
|
|
226
|
+
status: response.status,
|
|
227
|
+
isNewUser: typeof data?.isNewUser === 'boolean' ? data.isNewUser : null,
|
|
228
|
+
userId: typeof data?.xUser?.userId === 'string' ? data.xUser.userId : '--',
|
|
229
|
+
fingerprintId: typeof data?.xUser?.fingerprintId === 'string' ? data.xUser.fingerprintId : normalizedFingerprintId,
|
|
230
|
+
error: typeof data?.error === 'string' ? data.error : null,
|
|
231
|
+
};
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
const createdUserIds = payloads
|
|
235
|
+
.filter((payload) => payload.ok && payload.isNewUser === true && payload.userId !== '--')
|
|
236
|
+
.map((payload) => payload.userId);
|
|
237
|
+
const reusedUserIds = payloads
|
|
238
|
+
.filter((payload) => payload.ok && payload.isNewUser === false && payload.userId !== '--')
|
|
239
|
+
.map((payload) => payload.userId);
|
|
240
|
+
const failedStatuses = payloads
|
|
241
|
+
.filter((payload) => !payload.ok)
|
|
242
|
+
.map((payload) => `${payload.status}${payload.error ? `:${payload.error}` : ''}`);
|
|
243
|
+
|
|
244
|
+
setTestResult(
|
|
245
|
+
[
|
|
246
|
+
`Raw POST x3 done.`,
|
|
247
|
+
`created=${createdUserIds.length}`,
|
|
248
|
+
`reused=${reusedUserIds.length}`,
|
|
249
|
+
`failed=${failedStatuses.length}`,
|
|
250
|
+
`createdUserIds=[${createdUserIds.join(', ')}]`,
|
|
251
|
+
failedStatuses.length > 0 ? `failedStatuses=[${failedStatuses.join(', ')}]` : null,
|
|
252
|
+
].filter(Boolean).join('\n')
|
|
253
|
+
);
|
|
254
|
+
} catch (testError) {
|
|
255
|
+
setTestResult(`Raw POST test failed: ${formatErrorMessage(testError)}`);
|
|
256
|
+
} finally {
|
|
257
|
+
setIsRunningTest(false);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const applyTestFingerprintAndReload = () => {
|
|
262
|
+
const normalizedFingerprintId = testFingerprintId.trim();
|
|
263
|
+
if (!normalizedFingerprintId) {
|
|
264
|
+
setTestResult('Please input a valid test fingerprint id before applying.');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
setFingerprintId(normalizedFingerprintId);
|
|
270
|
+
setTestResult(`Applied test fingerprint and reloading: ${normalizedFingerprintId}`);
|
|
271
|
+
window.location.reload();
|
|
272
|
+
} catch (testError) {
|
|
273
|
+
setTestResult(`Apply test fingerprint failed: ${formatErrorMessage(testError)}`);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const regenerateTestFingerprint = () => {
|
|
278
|
+
const nextFingerprintId = buildDebugFingerprintId();
|
|
279
|
+
setTestFingerprintId(nextFingerprintId);
|
|
280
|
+
setTestResult(`Generated test fingerprint: ${nextFingerprintId}`);
|
|
281
|
+
};
|
|
157
282
|
|
|
158
283
|
return (
|
|
159
284
|
<>
|
|
@@ -193,94 +318,213 @@ export function FingerprintStatus() {
|
|
|
193
318
|
<icons.ShieldUser className="size-4" />
|
|
194
319
|
Fingerprint Debug Panel
|
|
195
320
|
</div>
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
321
|
+
<div className="flex items-center gap-2">
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
onClick={() => setPanelMode((prev) => prev === 'info' ? 'test' : 'info')}
|
|
325
|
+
className={cn(
|
|
326
|
+
'inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold shadow-sm transition-all duration-200',
|
|
327
|
+
panelMode === 'test'
|
|
328
|
+
? cn('border-transparent text-white', themeButtonGradientClass, themeButtonGradientHoverClass)
|
|
329
|
+
: themedGhostButtonClass
|
|
330
|
+
)}
|
|
331
|
+
aria-pressed={panelMode === 'test'}
|
|
332
|
+
>
|
|
333
|
+
<span>Concurrent Test</span>
|
|
334
|
+
<span
|
|
335
|
+
className={cn(
|
|
336
|
+
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
|
337
|
+
panelMode === 'test'
|
|
338
|
+
? 'bg-white/25'
|
|
339
|
+
: 'bg-slate-300 dark:bg-slate-700'
|
|
340
|
+
)}
|
|
341
|
+
>
|
|
342
|
+
<span
|
|
343
|
+
className={cn(
|
|
344
|
+
'inline-block size-4 rounded-full shadow-sm transition-transform',
|
|
345
|
+
panelMode === 'test' ? 'bg-white' : 'bg-white dark:bg-slate-100',
|
|
346
|
+
panelMode === 'test' ? 'translate-x-4' : 'translate-x-0.5'
|
|
347
|
+
)}
|
|
348
|
+
/>
|
|
349
|
+
</span>
|
|
350
|
+
</button>
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
aria-label="Close fingerprint panel"
|
|
354
|
+
className="rounded-full p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
|
|
355
|
+
onClick={() => setIsOpen(false)}
|
|
356
|
+
>
|
|
357
|
+
<icons.X className="size-4" />
|
|
358
|
+
</button>
|
|
359
|
+
</div>
|
|
204
360
|
</div>
|
|
205
361
|
</header>
|
|
206
362
|
|
|
207
363
|
<section className="space-y-1">
|
|
208
|
-
{
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
364
|
+
{panelMode === 'info' ? (
|
|
365
|
+
<>
|
|
366
|
+
<PanelSection
|
|
367
|
+
icon={<icons.Fingerprint className="size-4" />}
|
|
368
|
+
title="User"
|
|
369
|
+
rightInfo={<StatusTag value={userStatus} />}
|
|
370
|
+
items={[
|
|
371
|
+
{ label: 'UserID', value: <CopyableText text={xUser?.userId || ''} /> },
|
|
372
|
+
{ label: 'NickName', value: <CopyableText text={xUser?.userName || ''} /> },
|
|
373
|
+
{ label: 'FingerprintID', value: <CopyableText text={xUser?.fingerprintId || fingerprintId || ''} /> },
|
|
374
|
+
{ label: 'ClerkUserID', value: <CopyableText text={xUser?.clerkUserId || ''} /> },
|
|
375
|
+
{ label: 'Email', value: <CopyableText text={xUser?.email || ''} /> },
|
|
376
|
+
{ label: 'StripeCusID', value: <CopyableText text={xUser?.stripeCusId || ''} /> },
|
|
377
|
+
{ label: 'CreatedAt', value: xUser?.createdAt || '--' },
|
|
378
|
+
]}
|
|
379
|
+
/>
|
|
380
|
+
|
|
381
|
+
<div className="space-y-2 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/12 dark:bg-slate-900/50">
|
|
382
|
+
<PanelHeader
|
|
383
|
+
icon={<icons.Gem className="size-4" />}
|
|
384
|
+
title="Credits Info"
|
|
385
|
+
rightInfo={<span className={cn("font-semibold", themeIconColor)}>{totalCredits}</span>}
|
|
386
|
+
/>
|
|
387
|
+
<div className="space-y-3">
|
|
388
|
+
{creditBuckets.length > 0 ? (
|
|
389
|
+
creditBuckets.map((bucket) => {
|
|
390
|
+
const percent = Math.round(computeProgress(bucket.balance, bucket.total) * 100);
|
|
391
|
+
return (
|
|
392
|
+
<div key={bucket.key} className="rounded-lg border border-slate-200/70 bg-white/70 p-3 dark:border-white/10 dark:bg-slate-900/40">
|
|
393
|
+
<div className="flex items-center justify-between text-xs font-medium text-slate-600 dark:text-slate-300">
|
|
394
|
+
<div className="flex items-center gap-1.5">
|
|
395
|
+
{bucket.icon}
|
|
396
|
+
<span>{bucket.label}</span>
|
|
397
|
+
</div>
|
|
398
|
+
<span className="font-semibold text-slate-700 dark:text-slate-100">
|
|
399
|
+
{formatNumber(bucket.balance)} / {formatNumber(bucket.total)}
|
|
400
|
+
</span>
|
|
401
|
+
</div>
|
|
402
|
+
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-200 dark:bg-slate-800">
|
|
403
|
+
<div
|
|
404
|
+
className="h-full rounded-full bg-linear-to-r from-purple-500 via-pink-500 to-rose-400 transition-[width]"
|
|
405
|
+
style={{ width: `${percent}%` }}
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
|
|
409
|
+
<span>{formatRangeText(bucket.start, bucket.end)}</span>
|
|
410
|
+
<span>{percent}%</span>
|
|
411
|
+
</div>
|
|
241
412
|
</div>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
413
|
+
);
|
|
414
|
+
})
|
|
415
|
+
) : (
|
|
416
|
+
<EmptyPlaceholder label="No Credits Yet" icon={<icons.DatabaseZap className="size-4" />} />
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<PanelSection
|
|
422
|
+
icon={<icons.Bell className="size-4" />}
|
|
423
|
+
title="Subscription"
|
|
424
|
+
rightInfo={<StatusTag value={subStatus} />}
|
|
425
|
+
items={[
|
|
426
|
+
{ label: 'Plan', value: subscriptionStatus.priceName },
|
|
427
|
+
{ label: 'Period', value: subscriptionStatus.period },
|
|
428
|
+
{ label: 'Allocated', value: subscriptionStatus.creditsAllocated },
|
|
429
|
+
{ label: 'SubID', value: <CopyableText text={xSubscription?.paySubscriptionId || ''} /> },
|
|
430
|
+
{ label: 'OrderID', value: <CopyableText text={xSubscription?.orderId || ''} /> },
|
|
431
|
+
{ label: 'PriceID', value: <CopyableText text={xSubscription?.priceId || ''} /> },
|
|
432
|
+
]}
|
|
433
|
+
/>
|
|
434
|
+
</>
|
|
435
|
+
) : (
|
|
436
|
+
<div className="space-y-3 rounded-xl border border-slate-200/70 bg-white/85 p-4 shadow-sm dark:border-white/12 dark:bg-slate-900/45">
|
|
437
|
+
<PanelHeader
|
|
438
|
+
icon={<icons.DatabaseZap className="size-4" />}
|
|
439
|
+
title="Concurrent Base Info"
|
|
440
|
+
rightInfo={<StatusTag value={isRunningTest ? 'pending' : 'idle'} />}
|
|
441
|
+
/>
|
|
442
|
+
|
|
443
|
+
<div className="space-y-2 text-xs text-slate-500 dark:text-slate-300">
|
|
444
|
+
<div className="flex items-center justify-between gap-3">
|
|
445
|
+
<span className="text-slate-400 dark:text-slate-500">Current Browser</span>
|
|
446
|
+
<CopyableText text={fingerprintId || ''} />
|
|
447
|
+
</div>
|
|
448
|
+
<div className="space-y-1">
|
|
449
|
+
<span className="text-slate-400 dark:text-slate-500">Generate New</span>
|
|
450
|
+
<div className="flex items-center gap-2 py-1">
|
|
451
|
+
<input
|
|
452
|
+
value={testFingerprintId}
|
|
453
|
+
onChange={(e) => setTestFingerprintId(e.target.value)}
|
|
454
|
+
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-[0.5rem] sm:text-[0.625rem] md:text-xs leading-tight text-slate-700 outline-none transition focus:border-slate-400 dark:border-white/10 dark:bg-slate-950 dark:text-slate-100"
|
|
455
|
+
placeholder="fp_test_dbg_20260322_xxx"
|
|
456
|
+
/>
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
disabled={isRunningTest}
|
|
460
|
+
onClick={regenerateTestFingerprint}
|
|
461
|
+
aria-label="Generate new test fingerprint"
|
|
462
|
+
className="inline-flex size-9 items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-slate-950 dark:text-slate-100 dark:hover:bg-slate-900"
|
|
463
|
+
>
|
|
464
|
+
<icons.RefreshCcw className="size-4" />
|
|
465
|
+
</button>
|
|
466
|
+
<button
|
|
467
|
+
type="button"
|
|
468
|
+
disabled={isRunningTest}
|
|
469
|
+
onClick={applyTestFingerprintAndReload}
|
|
470
|
+
aria-label="Apply test fingerprint"
|
|
471
|
+
className="inline-flex size-9 items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-700 transition hover:border-slate-300 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-slate-950 dark:text-slate-100 dark:hover:bg-slate-900"
|
|
472
|
+
>
|
|
473
|
+
<icons.CheckCheck className="size-4" />
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
disabled={isRunningTest}
|
|
483
|
+
onClick={runContextParallelInitTest}
|
|
484
|
+
className={cn(
|
|
485
|
+
'shrink-0 rounded-full border px-3 py-2 text-xs font-semibold transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50',
|
|
486
|
+
themedGhostButtonClass
|
|
487
|
+
)}
|
|
488
|
+
>
|
|
489
|
+
Frontend Prevention Test
|
|
490
|
+
</button>
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
disabled={isRunningTest}
|
|
494
|
+
onClick={runRawParallelPostTest}
|
|
495
|
+
className={cn(
|
|
496
|
+
'shrink-0 rounded-full border px-3 py-2 text-xs font-semibold text-white shadow-sm transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50',
|
|
497
|
+
'border-transparent',
|
|
498
|
+
themeButtonGradientClass,
|
|
499
|
+
themeButtonGradientHoverClass
|
|
500
|
+
)}
|
|
501
|
+
>
|
|
502
|
+
Backend Idempotency Test
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/80 p-3 dark:border-white/10 dark:bg-slate-950/50">
|
|
507
|
+
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-5 text-slate-600 dark:text-slate-300">
|
|
508
|
+
{testResult || 'No test executed yet.'}
|
|
509
|
+
</pre>
|
|
510
|
+
</div>
|
|
262
511
|
</div>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
{/* 订阅信息 */}
|
|
266
|
-
<PanelSection
|
|
267
|
-
icon={<icons.Bell className="size-4" />}
|
|
268
|
-
title="订阅信息"
|
|
269
|
-
rightInfo={<StatusTag value={subStatus} />}
|
|
270
|
-
items={[
|
|
271
|
-
{ label: '订阅方案', value: subscriptionStatus.priceName },
|
|
272
|
-
{ label: '有效期', value: subscriptionStatus.period },
|
|
273
|
-
{ label: '分配额度', value: subscriptionStatus.creditsAllocated },
|
|
274
|
-
{ label: '订阅ID', value: <CopyableText text={xSubscription?.paySubscriptionId || ''} /> },
|
|
275
|
-
{ label: 'OrderID', value: <CopyableText text={xSubscription?.orderId || ''} /> },
|
|
276
|
-
{ label: 'Price ID', value: <CopyableText text={xSubscription?.priceId || ''} /> },
|
|
277
|
-
]}
|
|
278
|
-
/>
|
|
512
|
+
)}
|
|
279
513
|
|
|
280
514
|
{error && (
|
|
281
|
-
<div className="flex items-start gap-
|
|
282
|
-
<
|
|
283
|
-
|
|
515
|
+
<div className="flex items-start justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200">
|
|
516
|
+
<div className="flex items-start gap-2">
|
|
517
|
+
<icons.X className="mt-0.5 size-4 shrink-0" />
|
|
518
|
+
<span>{error}</span>
|
|
519
|
+
</div>
|
|
520
|
+
<button
|
|
521
|
+
type="button"
|
|
522
|
+
aria-label="Dismiss error"
|
|
523
|
+
onClick={clearError}
|
|
524
|
+
className="shrink-0 rounded-full p-1 text-amber-500 transition hover:bg-amber-100 hover:text-amber-700 dark:text-amber-200 dark:hover:bg-amber-500/10 dark:hover:text-amber-100"
|
|
525
|
+
>
|
|
526
|
+
<icons.X className="size-4" />
|
|
527
|
+
</button>
|
|
284
528
|
</div>
|
|
285
529
|
)}
|
|
286
530
|
</section>
|
|
@@ -367,7 +611,7 @@ function formatRangeText(start: string | null | undefined, end: string | null |
|
|
|
367
611
|
const safeEnd = end && end.trim() ? end : '';
|
|
368
612
|
|
|
369
613
|
if (!safeStart && !safeEnd) {
|
|
370
|
-
return '
|
|
614
|
+
return 'No records';
|
|
371
615
|
}
|
|
372
616
|
|
|
373
617
|
if (!safeStart) {
|
|
@@ -418,3 +662,21 @@ function StatusTag({ value }: { value: string | undefined | null }) {
|
|
|
418
662
|
</span>
|
|
419
663
|
);
|
|
420
664
|
}
|
|
665
|
+
|
|
666
|
+
function buildDebugFingerprintId() {
|
|
667
|
+
const timestamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
|
|
668
|
+
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
669
|
+
return `fp_test_dbg_${timestamp}_${randomSuffix}`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function formatErrorMessage(error: unknown) {
|
|
673
|
+
if (error instanceof Error) {
|
|
674
|
+
return error.message;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (typeof error === 'string') {
|
|
678
|
+
return error;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return 'Unknown error';
|
|
682
|
+
}
|