From 431e7163c16e275a68ff12e40c9b1590bf3b4941 Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Thu, 25 Dec 2025 18:00:18 -0500 Subject: [PATCH] feat: Add Cloudflare Tunnel integration --- README-zh.md | 1 + README.md | 1 + frontend/src/components/Header/Logo.tsx | 26 +++- .../Settings/CloudflareSettings.tsx | 127 ++++++++++++++++++ .../components/Settings/GeneralSettings.tsx | 93 +------------ frontend/src/hooks/useCloudflareStatus.ts | 24 ++++ frontend/src/pages/SettingsPage.tsx | 15 ++- frontend/src/utils/locales/ar.ts | 15 +++ frontend/src/utils/locales/de.ts | 15 +++ frontend/src/utils/locales/en.ts | 11 +- frontend/src/utils/locales/es.ts | 15 +++ frontend/src/utils/locales/fr.ts | 15 +++ frontend/src/utils/locales/ja.ts | 15 +++ frontend/src/utils/locales/ko.ts | 15 +++ frontend/src/utils/locales/pt.ts | 15 +++ frontend/src/utils/locales/ru.ts | 15 +++ frontend/src/utils/locales/zh.ts | 9 ++ 17 files changed, 334 insertions(+), 93 deletions(-) create mode 100644 frontend/src/components/Settings/CloudflareSettings.tsx create mode 100644 frontend/src/hooks/useCloudflareStatus.ts diff --git a/README-zh.md b/README-zh.md index 34fc4ea..7bbdc92 100644 --- a/README-zh.md +++ b/README-zh.md @@ -41,6 +41,7 @@ - **yt-dlp 配置**: 通过用户界面自定义全局 `yt-dlp` 参数、网络代理及其他高级设置。 - **访客模式**:启用只读模式,允许查看视频但无法进行修改。非常适合与他人分享您的视频库。 - **云存储集成**:下载后自动将视频和缩略图上传到云存储(OpenList/Alist)。 +- **Cloudflare Tunnel 集成**: 内置 Cloudflare Tunnel 支持,无需端口转发即可轻松将本地 MyTube 实例暴露到互联网。 ## 目录结构 diff --git a/README.md b/README.md index db71fb4..ca86497 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ A YouTube/Bilibili/MissAV video downloader and player that supports channel subs - **yt-dlp Configuration**: Customize global `yt-dlp` arguments, network proxy, and other advanced settings via settings page. - **Visitor Mode**: Enable read-only mode to allow viewing videos without modification capabilities. Perfect for sharing your library with others. - **Cloud Storage Integration**: Automatically upload videos and thumbnails to cloud storage (OpenList/Alist) after download. +- **Cloudflare Tunnel Integration**: Built-in Cloudflare Tunnel support to easily expose your local MyTube instance to the internet without port forwarding. ## Directory Structure diff --git a/frontend/src/components/Header/Logo.tsx b/frontend/src/components/Header/Logo.tsx index 7017275..38fab42 100644 --- a/frontend/src/components/Header/Logo.tsx +++ b/frontend/src/components/Header/Logo.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@mui/material'; import { Link } from 'react-router-dom'; import logo from '../../assets/logo.svg'; +import { useCloudflareStatus } from '../../hooks/useCloudflareStatus'; interface LogoProps { websiteName: string; @@ -8,9 +9,32 @@ interface LogoProps { } const Logo: React.FC = ({ websiteName, onResetSearch }) => { + // Only check status if we think it might be enabled, or just check always (it handles enabled=false internally somewhat, but better to query only if needed) + // Since we don't have easy access to settings here without adding another context, we'll check status always for now or ideally use a context. + // However, the hook defaults to enabled=true. Let's rely on the hook handling null if not running. + // Actually, checking status constantly might be overkill if disabled. But without global settings context readily available in Logo without refactor, we'll assume we want to check. + // Better strategy: The user explicitly asked for "When cloudflare Status is Running". + const { data: cloudflaredStatus } = useCloudflareStatus(true); + return ( - MyTube Logo + + MyTube Logo + {cloudflaredStatus?.isRunning && ( + + )} + {websiteName} diff --git a/frontend/src/components/Settings/CloudflareSettings.tsx b/frontend/src/components/Settings/CloudflareSettings.tsx new file mode 100644 index 0000000..4895d37 --- /dev/null +++ b/frontend/src/components/Settings/CloudflareSettings.tsx @@ -0,0 +1,127 @@ +import { Alert, Box, CircularProgress, FormControlLabel, Switch, TextField, Tooltip, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { useLanguage } from '../../contexts/LanguageContext'; + + + +import { useCloudflareStatus } from '../../hooks/useCloudflareStatus'; + +interface CloudflareSettingsProps { + enabled?: boolean; + token?: string; + onChange: (field: string, value: string | number | boolean) => void; +} + +const CloudflareSettings: React.FC = ({ enabled, token, onChange }) => { + const { t } = useLanguage(); + const [showCopied, setShowCopied] = useState(false); + + const handleCopyUrl = (url: string) => { + navigator.clipboard.writeText(url); + setShowCopied(true); + setTimeout(() => setShowCopied(false), 2000); + }; + + // Poll for Cloudflare Tunnel status + const { data: cloudflaredStatus, isLoading } = useCloudflareStatus(enabled ?? false); + + return ( + + + {t('cloudflaredTunnel')} + + onChange('cloudflaredTunnelEnabled', e.target.checked)} + /> + } + label={t('enableCloudflaredTunnel')} + /> + + {(enabled) && ( + onChange('cloudflaredToken', e.target.value)} + margin="normal" + helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."} + /> + )} + + {enabled && (isLoading || (!cloudflaredStatus && enabled) || (cloudflaredStatus?.isRunning && !token && !cloudflaredStatus.publicUrl)) ? ( + + + {cloudflaredStatus?.isRunning && !token && !cloudflaredStatus.publicUrl && ( + + {t('waitingForUrl')} + + )} + + ) : (enabled && cloudflaredStatus && ( + + + {t('status') || 'Status'}: + + {cloudflaredStatus.isRunning ? t('running') : t('stopped')} + + + + {cloudflaredStatus.tunnelId && ( + + {t('tunnelId')}: + + {cloudflaredStatus.tunnelId} + + + )} + + {cloudflaredStatus.accountTag && ( + + {t('accountTag')}: + + {cloudflaredStatus.accountTag} + + + )} + + {cloudflaredStatus.publicUrl && ( + + {t('publicUrl')}: + + + handleCopyUrl(cloudflaredStatus.publicUrl!)} + > + {cloudflaredStatus.publicUrl} + + + + + {t('quickTunnelWarning')} + + + )} + + {!cloudflaredStatus.publicUrl && ( + + {t('managedInDashboard')} + + )} + + ))} + + ); +}; + +export default CloudflareSettings; diff --git a/frontend/src/components/Settings/GeneralSettings.tsx b/frontend/src/components/Settings/GeneralSettings.tsx index 5f962a8..33dc1c0 100644 --- a/frontend/src/components/Settings/GeneralSettings.tsx +++ b/frontend/src/components/Settings/GeneralSettings.tsx @@ -1,5 +1,5 @@ -import { Alert, Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; import React, { useEffect, useState } from 'react'; import { useLanguage } from '../../contexts/LanguageContext'; @@ -16,13 +16,11 @@ interface GeneralSettingsProps { savedVisitorMode?: boolean; infiniteScroll?: boolean; videoColumns?: number; - cloudflaredTunnelEnabled?: boolean; - cloudflaredToken?: string; onChange: (field: string, value: string | number | boolean) => void; } const GeneralSettings: React.FC = (props) => { - const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, cloudflaredTunnelEnabled, cloudflaredToken, onChange } = props; + const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, onChange } = props; const { t } = useLanguage(); const queryClient = useQueryClient(); @@ -34,16 +32,7 @@ const GeneralSettings: React.FC = (props) => { const [baseError, setBaseError] = useState(''); // Poll for Cloudflare Tunnel status - const { data: cloudflaredStatus } = useQuery({ - queryKey: ['cloudflaredStatus'], - queryFn: async () => { - if (!cloudflaredTunnelEnabled) return null; - const res = await axios.get(`${API_URL}/settings/cloudflared/status`); - return res.data; - }, - enabled: !!cloudflaredTunnelEnabled, - refetchInterval: 5000 // Poll every 5 seconds - }); + // Use saved value for visibility, current value for toggle state const isVisitorMode = savedVisitorMode ?? visitorMode ?? false; @@ -245,81 +234,7 @@ const GeneralSettings: React.FC = (props) => { )} - - - {t('cloudflaredTunnel')} - - onChange('cloudflaredTunnelEnabled', e.target.checked)} - /> - } - label={t('enableCloudflaredTunnel')} - /> - {(cloudflaredTunnelEnabled) && ( - onChange('cloudflaredToken', e.target.value)} - margin="normal" - helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."} - /> - )} - - {cloudflaredTunnelEnabled && cloudflaredStatus && ( - - - Status: - - {cloudflaredStatus.isRunning ? 'Running' : 'Stopped'} - - - - {cloudflaredStatus.tunnelId && ( - - Tunnel ID: - - {cloudflaredStatus.tunnelId} - - - )} - - {cloudflaredStatus.accountTag && ( - - Account Tag: - - {cloudflaredStatus.accountTag} - - - )} - - {cloudflaredStatus.publicUrl && ( - - Public URL: - - - {cloudflaredStatus.publicUrl} - - - - Quick Tunnel URLs change every time the tunnel restarts. - - - )} - - {!cloudflaredStatus.publicUrl && ( - - Public hostname is managed in your Cloudflare Zero Trust Dashboard. - - )} - - )} - diff --git a/frontend/src/hooks/useCloudflareStatus.ts b/frontend/src/hooks/useCloudflareStatus.ts new file mode 100644 index 0000000..5971c0e --- /dev/null +++ b/frontend/src/hooks/useCloudflareStatus.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +const API_URL = import.meta.env.VITE_API_URL; + +interface CloudflareStatus { + isRunning: boolean; + tunnelId: string | null; + accountTag: string | null; + publicUrl: string | null; +} + +export const useCloudflareStatus = (enabled: boolean = true) => { + return useQuery({ + queryKey: ['cloudflaredStatus'], + queryFn: async () => { + if (!enabled) return { isRunning: false, tunnelId: null, accountTag: null, publicUrl: null }; + const res = await axios.get(`${API_URL}/settings/cloudflared/status`); + return res.data; + }, + enabled: !!enabled, + refetchInterval: 5000 // Poll every 5 seconds + }); +}; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2dd5d04..4a9b49e 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -17,6 +17,7 @@ import React, { useEffect, useRef, useState } from 'react'; import ConfirmationModal from '../components/ConfirmationModal'; import AdvancedSettings from '../components/Settings/AdvancedSettings'; import CloudDriveSettings from '../components/Settings/CloudDriveSettings'; +import CloudflareSettings from '../components/Settings/CloudflareSettings'; import CookieSettings from '../components/Settings/CookieSettings'; import DatabaseSettings from '../components/Settings/DatabaseSettings'; import DownloadSettings from '../components/Settings/DownloadSettings'; @@ -481,8 +482,18 @@ const SettingsPage: React.FC = () => { savedVisitorMode={settingsData?.visitorMode} infiniteScroll={settings.infiniteScroll} videoColumns={settings.videoColumns} - cloudflaredTunnelEnabled={settings.cloudflaredTunnelEnabled} - cloudflaredToken={settings.cloudflaredToken} + + onChange={(field, value) => handleChange(field as keyof Settings, value)} + /> + + + + + {/* Cloudflare Settings */} + + handleChange(field as keyof Settings, value)} /> diff --git a/frontend/src/utils/locales/ar.ts b/frontend/src/utils/locales/ar.ts index f41b945..c45211b 100644 --- a/frontend/src/utils/locales/ar.ts +++ b/frontend/src/utils/locales/ar.ts @@ -571,4 +571,19 @@ export const ar = { deleteAuthorConfirmation: "هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.", authorDeletedSuccessfully: "تم حذف المؤلف بنجاح", failedToDeleteAuthor: "فشل حذف المؤلف", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/de.ts b/frontend/src/utils/locales/de.ts index a85e9f3..6536fed 100644 --- a/frontend/src/utils/locales/de.ts +++ b/frontend/src/utils/locales/de.ts @@ -552,4 +552,19 @@ export const de = { restoreFromLastBackupFailed: "Fehler beim Wiederherstellen aus Backup", lastBackupDate: "Datum des letzten Backups", noBackupAvailable: "Kein Backup verfügbar", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/en.ts b/frontend/src/utils/locales/en.ts index 1bf284d..3987303 100644 --- a/frontend/src/utils/locales/en.ts +++ b/frontend/src/utils/locales/en.ts @@ -564,8 +564,17 @@ export const en = { // Cloudflare Tunnel cloudflaredTunnel: "Cloudflare Tunnel", enableCloudflaredTunnel: "Enable Cloudflare Tunnel", - cloudflaredToken: "Tunnel Token", + cloudflaredToken: "Tunnel Token (Optional)", cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", // Database Export/Import exportImportDatabase: "Export/Import Database", diff --git a/frontend/src/utils/locales/es.ts b/frontend/src/utils/locales/es.ts index df874c9..e4fd642 100644 --- a/frontend/src/utils/locales/es.ts +++ b/frontend/src/utils/locales/es.ts @@ -558,4 +558,19 @@ export const es = { restoreFromLastBackupFailed: "Error al restaurar desde la copia de respaldo", lastBackupDate: "Fecha de última copia de respaldo", noBackupAvailable: "No hay copia de respaldo disponible", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/fr.ts b/frontend/src/utils/locales/fr.ts index 1ef417c..03fbe8a 100644 --- a/frontend/src/utils/locales/fr.ts +++ b/frontend/src/utils/locales/fr.ts @@ -605,4 +605,19 @@ export const fr = { restoreFromLastBackupFailed: "Échec de la restauration depuis la sauvegarde", lastBackupDate: "Date de la dernière sauvegarde", noBackupAvailable: "Aucune sauvegarde disponible", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/ja.ts b/frontend/src/utils/locales/ja.ts index 3e0c604..af3447a 100644 --- a/frontend/src/utils/locales/ja.ts +++ b/frontend/src/utils/locales/ja.ts @@ -580,4 +580,19 @@ export const ja = { deleteAuthorConfirmation: "著者 {author} を削除してもよろしいですか?これにより、この著者に関連するすべての動画が削除されます。", authorDeletedSuccessfully: "著者が正常に削除されました", failedToDeleteAuthor: "著者の削除に失敗しました", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/ko.ts b/frontend/src/utils/locales/ko.ts index 0e52d6e..65273f7 100644 --- a/frontend/src/utils/locales/ko.ts +++ b/frontend/src/utils/locales/ko.ts @@ -571,4 +571,19 @@ export const ko = { deleteAuthorConfirmation: "작성자 {author}님을 삭제하시겠습니까? 이 작성자와 관련된 모든 동영상이 삭제됩니다.", authorDeletedSuccessfully: "작성자가 성공적으로 삭제되었습니다", failedToDeleteAuthor: "작성자 삭제 실패", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/pt.ts b/frontend/src/utils/locales/pt.ts index 973313f..ac66ff5 100644 --- a/frontend/src/utils/locales/pt.ts +++ b/frontend/src/utils/locales/pt.ts @@ -583,4 +583,19 @@ export const pt = { restoreFromLastBackupFailed: "Falha ao restaurar do backup", lastBackupDate: "Data do último backup", noBackupAvailable: "Nenhum backup disponível", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/ru.ts b/frontend/src/utils/locales/ru.ts index 71855b5..4c5fc46 100644 --- a/frontend/src/utils/locales/ru.ts +++ b/frontend/src/utils/locales/ru.ts @@ -577,4 +577,19 @@ export const ru = { restoreFromLastBackupFailed: "Не удалось восстановить из резервной копии", lastBackupDate: "Дата последней резервной копии", noBackupAvailable: "Резервная копия недоступна", + + // Cloudflare Tunnel + cloudflaredTunnel: "Cloudflare Tunnel", + enableCloudflaredTunnel: "Enable Cloudflare Tunnel", + cloudflaredToken: "Tunnel Token (Optional)", + cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.", + waitingForUrl: "Waiting for Quick Tunnel URL...", + running: "Running", + stopped: "Stopped", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "Copied!", + clickToCopy: "Click to copy", + quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.", + managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.", }; diff --git a/frontend/src/utils/locales/zh.ts b/frontend/src/utils/locales/zh.ts index 1922d3a..75e7ccb 100644 --- a/frontend/src/utils/locales/zh.ts +++ b/frontend/src/utils/locales/zh.ts @@ -548,6 +548,15 @@ export const zh = { enableCloudflaredTunnel: "启用 Cloudflare 穿透", cloudflaredToken: "Token", cloudflaredTokenHelper: "在此粘贴您的 Token,或留空以使用随机 Quick Tunnel。", + waitingForUrl: "等待 Quick Tunnel URL...", + running: "运行中", + stopped: "已停止", + tunnelId: "Tunnel ID", + accountTag: "Account Tag", + copied: "已复制!", + clickToCopy: "点击复制", + quickTunnelWarning: "每次重启隧道时,Quick Tunnel URL 都会更改。", + managedInDashboard: "公开主机名在您的 Cloudflare Zero Trust 仪表板中管理。", // Database Export/Import exportImportDatabase: "导出/导入数据库",