mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add TV Quick Connect to connection page for Android TV / Apple TV
When user selects Android TV or Apple TV platform (from non-TV device), shows 5-char code input + QR scanner to send subscription to TV via Happ TV API. Hybrid QR strategy: native Telegram scanner on mobile, html5-qrcode CDN fallback on desktop. i18n keys for ru/en/zh/fa. Based on PR #415 by @smediainfo.
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
|
||||
import type { BlockRendererProps } from './blocks';
|
||||
import TvQuickConnect from './TvQuickConnect';
|
||||
|
||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
|
||||
|
||||
@@ -145,6 +146,12 @@ export default function InstallationGuide({
|
||||
],
|
||||
);
|
||||
|
||||
const selectedIsTv =
|
||||
(activePlatformKey || availablePlatforms[0]) === 'androidTV' ||
|
||||
(activePlatformKey || availablePlatforms[0]) === 'appleTV';
|
||||
const userIsOnTv = detectedPlatform === 'androidTV' || detectedPlatform === 'appleTV';
|
||||
const isTvPlatform = selectedIsTv && !userIsOnTv;
|
||||
|
||||
const currentPlatformKey = activePlatformKey || availablePlatforms[0];
|
||||
const currentPlatformData = currentPlatformKey
|
||||
? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined)
|
||||
@@ -328,8 +335,32 @@ export default function InstallationGuide({
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Blocks */}
|
||||
{selectedApp && (
|
||||
{/* Blocks — for TV: first block, Quick Connect, last block */}
|
||||
{selectedApp && isTvPlatform && appConfig.subscriptionUrl ? (
|
||||
<>
|
||||
{selectedApp.blocks.length > 0 && (
|
||||
<Renderer
|
||||
blocks={selectedApp.blocks.slice(0, 1)}
|
||||
isMobile={isMobile}
|
||||
isLight={isLight}
|
||||
getLocalizedText={getLocalizedText}
|
||||
getSvgHtml={getSvgHtml}
|
||||
renderBlockButtons={renderBlockButtons}
|
||||
/>
|
||||
)}
|
||||
<TvQuickConnect subscriptionUrl={appConfig.subscriptionUrl} isLight={isLight} />
|
||||
{selectedApp.blocks.length > 1 && (
|
||||
<Renderer
|
||||
blocks={selectedApp.blocks.slice(-1)}
|
||||
isMobile={isMobile}
|
||||
isLight={isLight}
|
||||
getLocalizedText={getLocalizedText}
|
||||
getSvgHtml={getSvgHtml}
|
||||
renderBlockButtons={renderBlockButtons}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : selectedApp ? (
|
||||
<Renderer
|
||||
blocks={selectedApp.blocks}
|
||||
isMobile={isMobile}
|
||||
@@ -338,7 +369,7 @@ export default function InstallationGuide({
|
||||
getSvgHtml={getSvgHtml}
|
||||
renderBlockButtons={renderBlockButtons}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
354
src/components/connection/TvQuickConnect.tsx
Normal file
354
src/components/connection/TvQuickConnect.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
openQrScanner,
|
||||
isQrScannerSupported,
|
||||
retrieveLaunchParams,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
|
||||
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
|
||||
|
||||
function isTelegramMobile(): boolean {
|
||||
try {
|
||||
const platform = retrieveLaunchParams().tgWebAppPlatform;
|
||||
return TG_MOBILE_PLATFORMS.has(platform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const HAPP_TV_API = 'https://check.happ.su/sendtv';
|
||||
const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js';
|
||||
|
||||
interface Props {
|
||||
subscriptionUrl: string;
|
||||
isLight: boolean;
|
||||
}
|
||||
|
||||
interface Html5QrcodeInstance {
|
||||
start: (
|
||||
cameraIdOrConfig: { facingMode: string } | string,
|
||||
config: { fps: number; qrbox: { width: number; height: number } },
|
||||
onSuccess: (decoded: string) => void,
|
||||
onError: () => void,
|
||||
) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [tgNative, setTgNative] = useState(false);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const scannerRef = useRef<Html5QrcodeInstance | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setTgNative(isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable());
|
||||
} catch {
|
||||
setTgNative(false);
|
||||
}
|
||||
return () => {
|
||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current
|
||||
.stop()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
scannerRef.current?.clear();
|
||||
scannerRef.current = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((text: string, type: 'success' | 'error') => {
|
||||
setToast({ text, type });
|
||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
|
||||
const sendToTV = useCallback(
|
||||
async (tvCode: string) => {
|
||||
if (sending) return;
|
||||
const clean = tvCode.trim().toUpperCase();
|
||||
if (!(clean.length === 5 && /^[A-Z0-9]+$/.test(clean))) {
|
||||
showToast(t('subscription.tvQuickConnect.badCode'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const b64 = btoa(unescape(encodeURIComponent(subscriptionUrl)));
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
|
||||
const res = await fetch(`${HAPP_TV_API}/${encodeURIComponent(clean)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: b64 }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.ok) {
|
||||
showToast(t('subscription.tvQuickConnect.sent'), 'success');
|
||||
setCode('');
|
||||
} else {
|
||||
showToast(t('subscription.tvQuickConnect.error'), 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast(t('subscription.tvQuickConnect.error'), 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[sending, subscriptionUrl, showToast, t],
|
||||
);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current
|
||||
.stop()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
scannerRef.current?.clear();
|
||||
scannerRef.current = null;
|
||||
});
|
||||
}
|
||||
setScanning(false);
|
||||
}, []);
|
||||
|
||||
const onScanDecoded = useCallback(
|
||||
(decoded: string) => {
|
||||
const parsed = parseQRCode(decoded);
|
||||
if (!parsed) return;
|
||||
stopScan();
|
||||
setCode(parsed);
|
||||
showToast(`${t('subscription.tvQuickConnect.codeFound')}: ${parsed}`, 'success');
|
||||
setTimeout(() => sendToTV(parsed), 500);
|
||||
},
|
||||
[stopScan, showToast, sendToTV, t],
|
||||
);
|
||||
|
||||
const startScan = useCallback(async () => {
|
||||
// Telegram Mini App: native scanner
|
||||
if (tgNative) {
|
||||
try {
|
||||
const qr = await openQrScanner({
|
||||
text: t('subscription.tvQuickConnect.scanDescription'),
|
||||
capture: (s: string) => parseQRCode(s) !== null,
|
||||
});
|
||||
if (qr) {
|
||||
const parsed = parseQRCode(qr);
|
||||
if (parsed) {
|
||||
setCode(parsed);
|
||||
showToast(t('subscription.tvQuickConnect.codeFound'), 'success');
|
||||
sendToTV(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showToast(t('subscription.tvQuickConnect.error'), 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode
|
||||
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
|
||||
const w = window as WindowWithHtml5;
|
||||
if (!w.Html5Qrcode) {
|
||||
const script = document.createElement('script');
|
||||
script.src = HTML5_QRCODE_CDN;
|
||||
document.head.appendChild(script);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject();
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (!w.Html5Qrcode) {
|
||||
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
const scanner = new w.Html5Qrcode('tv-qr-reader');
|
||||
scannerRef.current = scanner;
|
||||
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
|
||||
try {
|
||||
await scanner.start({ facingMode: 'environment' }, config, onScanDecoded, () => undefined);
|
||||
} catch {
|
||||
try {
|
||||
await scanner.start({ facingMode: 'user' }, config, onScanDecoded, () => undefined);
|
||||
} catch {
|
||||
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
|
||||
scannerRef.current = null;
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
}, [tgNative, sendToTV, showToast, onScanDecoded, t]);
|
||||
|
||||
const cardClass = isLight
|
||||
? 'rounded-2xl border border-dark-700/60 bg-white/80 shadow-sm p-4 sm:p-5'
|
||||
: 'rounded-2xl border border-dark-700/50 bg-dark-800/50 p-4 sm:p-5';
|
||||
|
||||
const inputClass = isLight
|
||||
? 'w-full rounded-xl border border-dark-700/60 bg-white px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500'
|
||||
: 'w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Code input */}
|
||||
<div className={cardClass}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10">
|
||||
<svg
|
||||
className="h-5 w-5 text-accent-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
{t('subscription.tvQuickConnect.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{t('subscription.tvQuickConnect.description')}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={5}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
|
||||
placeholder="A1B2C"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="text"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendToTV(code)}
|
||||
disabled={sending || code.length !== 5}
|
||||
className="btn-primary w-full justify-center py-3 disabled:opacity-50"
|
||||
>
|
||||
{sending ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
t('subscription.tvQuickConnect.sendBtn')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Scanner */}
|
||||
<div className={cardClass}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
{t('subscription.tvQuickConnect.scanTitle')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{t('subscription.tvQuickConnect.scanDescription')}
|
||||
</p>
|
||||
|
||||
{!scanning && (
|
||||
<button onClick={startScan} className="btn-secondary mt-3 w-full justify-center py-3">
|
||||
<svg
|
||||
className="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('subscription.tvQuickConnect.scanBtn')}
|
||||
</button>
|
||||
)}
|
||||
<div className={scanning ? 'mt-3 space-y-2' : 'hidden'}>
|
||||
<div id="tv-qr-reader" className="overflow-hidden rounded-xl" />
|
||||
{scanning && (
|
||||
<button onClick={stopScan} className="btn-secondary w-full justify-center py-2.5">
|
||||
{t('subscription.tvQuickConnect.stopScan')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl px-5 py-3 text-sm font-medium shadow-lg transition-all ${
|
||||
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
|
||||
}`}
|
||||
>
|
||||
{toast.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseQRCode(data: string): string | null {
|
||||
if (data.length === 5 && /^[A-Z0-9]+$/i.test(data)) {
|
||||
return data.toUpperCase();
|
||||
}
|
||||
try {
|
||||
const url = new URL(data);
|
||||
const parts = url.pathname.split('/').filter((p) => p.length > 0);
|
||||
if (parts.length > 0) {
|
||||
const last = parts[parts.length - 1];
|
||||
if (last.length === 5 && /^[A-Z0-9]+$/i.test(last)) return last.toUpperCase();
|
||||
}
|
||||
const codeParam = url.searchParams.get('code');
|
||||
if (codeParam?.length === 5 && /^[A-Z0-9]+$/i.test(codeParam)) return codeParam.toUpperCase();
|
||||
} catch {
|
||||
const m = data.match(/[/=]([A-Z0-9]{5})(?:[/?&\s]|$)/i);
|
||||
if (m?.[1]) return m[1].toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -593,6 +593,20 @@
|
||||
"label": "Traffic",
|
||||
"selectVolume": "Select traffic volume",
|
||||
"default": "Default: {{label}}"
|
||||
},
|
||||
"tvQuickConnect": {
|
||||
"title": "Connect TV",
|
||||
"description": "Enter the 5-character code from your TV screen",
|
||||
"sendBtn": "Send to TV",
|
||||
"badCode": "Enter a 5-character code (letters and digits)",
|
||||
"sent": "Sent to TV!",
|
||||
"error": "Failed to send to TV",
|
||||
"scanTitle": "Scan QR Code",
|
||||
"scanDescription": "Scan the QR code from your TV screen",
|
||||
"scanBtn": "Scan QR from TV",
|
||||
"stopScan": "Close camera",
|
||||
"noCamera": "Camera access denied",
|
||||
"codeFound": "Code found"
|
||||
}
|
||||
},
|
||||
"balance": {
|
||||
|
||||
@@ -472,6 +472,20 @@
|
||||
"resumeBtn": "ادامه",
|
||||
"title": "توقف اشتراک",
|
||||
"willBeCharged": "کسر خواهد شد"
|
||||
},
|
||||
"tvQuickConnect": {
|
||||
"title": "اتصال تلویزیون",
|
||||
"description": "کد ۵ رقمی نمایش داده شده روی صفحه تلویزیون را وارد کنید",
|
||||
"sendBtn": "ارسال به تلویزیون",
|
||||
"badCode": "کد ۵ رقمی وارد کنید (حروف و اعداد)",
|
||||
"sent": "به تلویزیون ارسال شد!",
|
||||
"error": "خطا در ارسال به تلویزیون",
|
||||
"scanTitle": "اسکن کد QR",
|
||||
"scanDescription": "کد QR روی صفحه تلویزیون را اسکن کنید",
|
||||
"scanBtn": "اسکن QR از تلویزیون",
|
||||
"stopScan": "بستن دوربین",
|
||||
"noCamera": "دسترسی به دوربین امکانپذیر نیست",
|
||||
"codeFound": "کد پیدا شد"
|
||||
}
|
||||
},
|
||||
"balance": {
|
||||
|
||||
@@ -621,6 +621,20 @@
|
||||
"label": "Трафик",
|
||||
"selectVolume": "Выбрать объём трафика",
|
||||
"default": "По умолчанию: {{label}}"
|
||||
},
|
||||
"tvQuickConnect": {
|
||||
"title": "Подключить TV",
|
||||
"description": "Введите 5-значный код с экрана телевизора",
|
||||
"sendBtn": "Отправить на TV",
|
||||
"badCode": "Введите 5-значный код (буквы и цифры)",
|
||||
"sent": "Отправлено на TV!",
|
||||
"error": "Ошибка отправки на TV",
|
||||
"scanTitle": "Сканировать QR-код",
|
||||
"scanDescription": "Отсканируйте QR-код с экрана телевизора",
|
||||
"scanBtn": "Сканировать QR с TV",
|
||||
"stopScan": "Закрыть камеру",
|
||||
"noCamera": "Нет доступа к камере",
|
||||
"codeFound": "Код найден"
|
||||
}
|
||||
},
|
||||
"balance": {
|
||||
|
||||
@@ -472,6 +472,20 @@
|
||||
"resumeBtn": "恢复",
|
||||
"title": "暂停订阅",
|
||||
"willBeCharged": "将扣费"
|
||||
},
|
||||
"tvQuickConnect": {
|
||||
"title": "连接电视",
|
||||
"description": "输入电视屏幕上显示的5位代码",
|
||||
"sendBtn": "发送到电视",
|
||||
"badCode": "请输入5位代码(字母和数字)",
|
||||
"sent": "已发送到电视!",
|
||||
"error": "发送到电视失败",
|
||||
"scanTitle": "扫描二维码",
|
||||
"scanDescription": "扫描电视屏幕上的二维码",
|
||||
"scanBtn": "扫描电视二维码",
|
||||
"stopScan": "关闭摄像头",
|
||||
"noCamera": "无法访问摄像头",
|
||||
"codeFound": "找到代码"
|
||||
}
|
||||
},
|
||||
"balance": {
|
||||
|
||||
Reference in New Issue
Block a user