Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4940278ea5 | |||
| 6a46e3109a | |||
| c723bdd53e | |||
| 617b43083d | |||
| f9a87a9993 | |||
| 8c14913275 | |||
| f0d574bc44 | |||
| 3aeb2a8680 | |||
| c3b64e8d0f | |||
| 7794c73a51 | |||
| 6ce67176da | |||
| 48e7efe92a |
@@ -109,8 +109,11 @@ jobs:
|
||||
|
||||
- name: Compile (macOS System Extension app)
|
||||
env:
|
||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }}
|
||||
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }}
|
||||
# 左=脚本期望的 env 名(勿改),右=实际密钥名。Apple 证书/公证密钥是
|
||||
# 账号级、跨项目唯一 → 放【全局(用户级)密钥】用通用短名,pangolin/jiu 各自
|
||||
# 在此映射到自己脚本的 env。Developer ID Application 证书=站外分发,一张签所有 app。
|
||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12 }}
|
||||
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
MACOS_APP_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISION_PROFILE_BASE64 }}
|
||||
MACOS_SYSEXT_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_SYSEXT_PROVISION_PROFILE_BASE64 }}
|
||||
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||
@@ -137,8 +140,10 @@ jobs:
|
||||
|
||||
- name: Compile & upload to TestFlight (iOS)
|
||||
env:
|
||||
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }}
|
||||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
# 证书=账号级 →【全局】通用短名(Apple Distribution 证书,签 iOS App Store/TestFlight);
|
||||
# 描述文件=与 bundle id 绑定 →【项目级】pangolin 仓库密钥。
|
||||
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_P12 }}
|
||||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_PASSWORD }}
|
||||
IOS_APP_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_PROVISIONING_PROFILE_BASE64 }}
|
||||
IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 }}
|
||||
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||
|
||||
@@ -24,6 +24,16 @@ jobs:
|
||||
SITE_URL: https://pangolin.yanmeiai.com
|
||||
run: bash scripts/ci/compile-site.sh
|
||||
|
||||
# 用户中心(web/usercenter,basePath=/user,http 模式)构建 → out/。
|
||||
- name: Compile (用户中心 Next.js)
|
||||
run: bash scripts/ci/compile-usercenter.sh
|
||||
|
||||
# 合并:用户中心并入官网 dist/user/ + _headers 按 /user/* 分域(见脚本)。
|
||||
# 域名迁移:原独立子域 app.yanmeiai.com → 主站子路径 pangolin.yanmeiai.com/user/。
|
||||
- name: Combine (官网 + 用户中心)
|
||||
run: bash scripts/ci/combine-site.sh
|
||||
|
||||
# 单次部署合并产物到 pangolin-site:/ 官网、/user/ 用户中心。
|
||||
- name: Deploy → Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!--
|
||||
App 内更新:下载 APK 后拉起系统安装器(open_filex 内封 FileProvider)。
|
||||
Android 8+ 首次安装会引导用户授予「允许安装未知应用」。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="穿山甲"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// app_updater.dart — App 内下载并安装更新(不再开浏览器)。
|
||||
//
|
||||
// 对照 jiu core/update/app_updater_io.dart,但补上了 jiu 没有的 Android 原生安装:
|
||||
// - Android:http 流式下 APK 到临时目录 → open_filex 拉起系统安装器
|
||||
// (open_filex 内部封装 FileProvider;manifest 需 REQUEST_INSTALL_PACKAGES)
|
||||
// - Windows:下 exe → Process.start(detached) 起安装 → exit(0) 让其覆盖
|
||||
// - macOS :下 zip → open 解压 + 访达显示,提示手动拖入「应用程序」
|
||||
// (本 app 带系统扩展,不自动替换 bundle,避免破坏 sysext)
|
||||
// - iOS :不能 App 内装 → 外链(TestFlight/App Store)
|
||||
// 进度用本地 ValueNotifier 驱动一个不可关闭的进度对话框;失败降级浏览器下载。
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../l10n/app_text.dart';
|
||||
import '../../pangolin_theme.dart';
|
||||
import '../../state/update_provider.dart';
|
||||
import '../../widgets/pangolin_icons.dart';
|
||||
|
||||
/// 更新对话框「下载更新」入口:按平台 App 内下载并安装;iOS/无直链降级浏览器。
|
||||
Future<void> startInAppUpdate(BuildContext context, AppText t, AppUpdateInfo info) async {
|
||||
final url = platformDownloadUrl(info.downloadUrls);
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
// iOS 不允许 App 内安装 → 外链(download_urls['ios'] 应为 TestFlight/App Store)。
|
||||
if (Platform.isIOS) {
|
||||
await _openInBrowser(url);
|
||||
return;
|
||||
}
|
||||
|
||||
final progress = ValueNotifier<double>(0);
|
||||
final installing = ValueNotifier<bool>(false);
|
||||
var dialogOpen = true;
|
||||
void closeDialog() {
|
||||
if (context.mounted && dialogOpen) {
|
||||
dialogOpen = false;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
// 不可关闭的进度框。
|
||||
unawaited(showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => PopScope(
|
||||
canPop: false,
|
||||
child: _ProgressDialog(t: t, progress: progress, installing: installing),
|
||||
),
|
||||
));
|
||||
|
||||
try {
|
||||
final savePath = await _savePath(url);
|
||||
await _download(url, savePath, (p) => progress.value = p);
|
||||
installing.value = true;
|
||||
await _install(savePath); // Win/macOS 可能在此 exit(0),不再返回
|
||||
closeDialog();
|
||||
if (Platform.isMacOS && context.mounted) {
|
||||
await _showInfo(context, t, t.lang.updateMacReveal);
|
||||
}
|
||||
} catch (_) {
|
||||
closeDialog();
|
||||
if (context.mounted) await _showFailed(context, t, url);
|
||||
} finally {
|
||||
progress.dispose();
|
||||
installing.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 各平台下载文件的落盘路径。
|
||||
Future<String> _savePath(String url) async {
|
||||
final tmp = await getTemporaryDirectory();
|
||||
if (Platform.isWindows) return '${tmp.path}${Platform.pathSeparator}pangolin-update-setup.exe';
|
||||
if (Platform.isMacOS) {
|
||||
// 下到「下载」目录便于用户在访达里操作;取不到则回退临时目录。
|
||||
final dl = await getDownloadsDirectory();
|
||||
final dir = dl ?? tmp;
|
||||
return '${dir.path}/pangolin-update.zip';
|
||||
}
|
||||
return '${tmp.path}/pangolin-update.apk'; // Android
|
||||
}
|
||||
|
||||
/// http 流式下载 + 进度回调。失败抛异常(由调用方降级)。
|
||||
Future<void> _download(String url, String savePath, void Function(double) onProgress) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final req = http.Request('GET', Uri.parse(url));
|
||||
final resp = await client.send(req);
|
||||
if (resp.statusCode != 200) {
|
||||
throw HttpException('HTTP ${resp.statusCode}', uri: Uri.parse(url));
|
||||
}
|
||||
final total = resp.contentLength ?? 0;
|
||||
final file = File(savePath);
|
||||
final sink = file.openWrite();
|
||||
var received = 0;
|
||||
try {
|
||||
await for (final chunk in resp.stream) {
|
||||
received += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (total > 0) onProgress(received / total);
|
||||
}
|
||||
await sink.flush();
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载后触发安装/打开。
|
||||
Future<void> _install(String path) async {
|
||||
if (Platform.isAndroid) {
|
||||
await OpenFilex.open(path); // 系统安装器(open_filex 内封 FileProvider)
|
||||
return;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
await Process.start(path, const [], mode: ProcessStartMode.detached);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
exit(0); // 退出让安装器覆盖
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
// 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。
|
||||
await Process.run('open', <String>[path]); // Archive Utility 解压
|
||||
await Process.run('open', <String>['-R', path]); // 访达定位
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openInBrowser(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showInfo(BuildContext context, AppText t, String msg) {
|
||||
final c = context.pangolin;
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
content: Text(msg, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text('OK', style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showFailed(BuildContext context, AppText t, String url) {
|
||||
final c = context.pangolin;
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
content: Text(t.lang.updateDownloadFailed, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(t.lang.updateCancelBtn, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
await _openInBrowser(url);
|
||||
},
|
||||
child: Text(t.lang.updateOpenBrowser, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ProgressDialog extends StatelessWidget {
|
||||
const _ProgressDialog({required this.t, required this.progress, required this.installing});
|
||||
final AppText t;
|
||||
final ValueNotifier<double> progress;
|
||||
final ValueNotifier<bool> installing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
title: Row(children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
|
||||
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(t.updateDownload,
|
||||
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
]),
|
||||
content: ValueListenableBuilder<bool>(
|
||||
valueListenable: installing,
|
||||
builder: (_, inst, __) => ValueListenableBuilder<double>(
|
||||
valueListenable: progress,
|
||||
builder: (_, p, __) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
child: LinearProgressIndicator(
|
||||
value: inst || p <= 0 ? null : p,
|
||||
minHeight: 6,
|
||||
backgroundColor: c.bgSubtle,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(c.accent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
inst ? t.lang.updateInstalling : t.lang.updateDownloadingPercent((p * 100).clamp(0, 100).round()),
|
||||
style: PangolinText.sm.copyWith(color: c.fg2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,304 @@
|
||||
// 接口形态与 .arb 一致,后续可平滑迁移到官方 l10n 工具链。
|
||||
|
||||
/// 受支持语言。单显——任一时刻只渲染其一。
|
||||
enum AppLang { zh, en }
|
||||
/// zh 中文 · en English · ja 日本語 · ko 한국어 · ru Русский · es Español。
|
||||
enum AppLang { zh, en, ja, ko, ru, es }
|
||||
|
||||
/// 语言的本地名(用于语言切换 UI,各语言用自身文字标注)。
|
||||
extension AppLangLabel on AppLang {
|
||||
String get nativeLabel {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '中文';
|
||||
case AppLang.en:
|
||||
return 'English';
|
||||
case AppLang.ja:
|
||||
return '日本語';
|
||||
case AppLang.ko:
|
||||
return '한국어';
|
||||
case AppLang.ru:
|
||||
return 'Русский';
|
||||
case AppLang.es:
|
||||
return 'Español';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 少量「拿不到 AppText 实例的场景」(model / 非 Consumer 的 tile,只有 AppLang)
|
||||
/// 用到的零散文案。exhaustive switch 保证 6 语全覆盖,不会漏译回退英文。
|
||||
/// 仍归属 l10n 层(scan-redline 覆盖本文件),不违反「文案集中」铁律。
|
||||
extension AppLangMisc on AppLang {
|
||||
/// 节点不可用(agent 离线)。
|
||||
String get unavailable {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '不可用';
|
||||
case AppLang.en:
|
||||
return 'Unavailable';
|
||||
case AppLang.ja:
|
||||
return '利用不可';
|
||||
case AppLang.ko:
|
||||
return '사용 불가';
|
||||
case AppLang.ru:
|
||||
return 'Недоступно';
|
||||
case AppLang.es:
|
||||
return 'No disponible';
|
||||
}
|
||||
}
|
||||
|
||||
/// 节点标签:流媒体优化。
|
||||
String get tagStreaming {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '流媒体优化';
|
||||
case AppLang.en:
|
||||
return 'Streaming';
|
||||
case AppLang.ja:
|
||||
return 'ストリーミング最適化';
|
||||
case AppLang.ko:
|
||||
return '스트리밍 최적화';
|
||||
case AppLang.ru:
|
||||
return 'Оптимизация стриминга';
|
||||
case AppLang.es:
|
||||
return 'Optimización de streaming';
|
||||
}
|
||||
}
|
||||
|
||||
/// 节点尚未就绪(列表加载中/为空)。
|
||||
String get nodesNotReady {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '节点尚未就绪,请稍候重试';
|
||||
case AppLang.en:
|
||||
return 'Nodes not ready, please retry';
|
||||
case AppLang.ja:
|
||||
return 'ノードの準備ができていません。しばらくして再試行してください';
|
||||
case AppLang.ko:
|
||||
return '노드가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요';
|
||||
case AppLang.ru:
|
||||
return 'Узлы не готовы, повторите попытку';
|
||||
case AppLang.es:
|
||||
return 'Nodos no listos, inténtalo de nuevo';
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接失败(通用兜底)。
|
||||
String get connectFailed {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '连接失败,请重试';
|
||||
case AppLang.en:
|
||||
return 'Connection failed, please retry';
|
||||
case AppLang.ja:
|
||||
return '接続に失敗しました。再試行してください';
|
||||
case AppLang.ko:
|
||||
return '연결에 실패했습니다. 다시 시도하세요';
|
||||
case AppLang.ru:
|
||||
return 'Не удалось подключиться, повторите попытку';
|
||||
case AppLang.es:
|
||||
return 'Error de conexión, inténtalo de nuevo';
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据加载失败。
|
||||
String get loadFailedRetry {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '加载失败,请重试';
|
||||
case AppLang.en:
|
||||
return 'Failed to load, retry';
|
||||
case AppLang.ja:
|
||||
return '読み込みに失敗しました。再試行してください';
|
||||
case AppLang.ko:
|
||||
return '불러오기에 실패했습니다. 다시 시도하세요';
|
||||
case AppLang.ru:
|
||||
return 'Не удалось загрузить, повторите';
|
||||
case AppLang.es:
|
||||
return 'Error al cargar, reintentar';
|
||||
}
|
||||
}
|
||||
|
||||
/// 无已登录设备。
|
||||
String get noDevices {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '暂无已登录设备';
|
||||
case AppLang.en:
|
||||
return 'No devices yet';
|
||||
case AppLang.ja:
|
||||
return 'ログイン済みのデバイスはありません';
|
||||
case AppLang.ko:
|
||||
return '로그인된 기기가 없습니다';
|
||||
case AppLang.ru:
|
||||
return 'Пока нет устройств';
|
||||
case AppLang.es:
|
||||
return 'Aún no hay dispositivos';
|
||||
}
|
||||
}
|
||||
|
||||
/// 「改邮箱」按钮短标签。
|
||||
String get changeEmail {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '改邮箱';
|
||||
case AppLang.en:
|
||||
return 'Change';
|
||||
case AppLang.ja:
|
||||
return '変更';
|
||||
case AppLang.ko:
|
||||
return '변경';
|
||||
case AppLang.ru:
|
||||
return 'Изменить';
|
||||
case AppLang.es:
|
||||
return 'Cambiar';
|
||||
}
|
||||
}
|
||||
|
||||
/// 相对时间(设备最后在线):刚刚 / X 分钟前 / X 小时前 / X 天前。
|
||||
String relativeTime(Duration d) {
|
||||
final m = d.inMinutes, h = d.inHours, days = d.inDays;
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
if (m < 1) return '刚刚';
|
||||
if (m < 60) return '$m 分钟前';
|
||||
if (h < 24) return '$h 小时前';
|
||||
return '$days 天前';
|
||||
case AppLang.en:
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return '$m min ago';
|
||||
if (h < 24) return '${h}h ago';
|
||||
return '${days}d ago';
|
||||
case AppLang.ja:
|
||||
if (m < 1) return 'たった今';
|
||||
if (m < 60) return '$m 分前';
|
||||
if (h < 24) return '$h 時間前';
|
||||
return '$days 日前';
|
||||
case AppLang.ko:
|
||||
if (m < 1) return '방금';
|
||||
if (m < 60) return '$m분 전';
|
||||
if (h < 24) return '$h시간 전';
|
||||
return '$days일 전';
|
||||
case AppLang.ru:
|
||||
if (m < 1) return 'только что';
|
||||
if (m < 60) return '$m мин назад';
|
||||
if (h < 24) return '$h ч назад';
|
||||
return '$days дн назад';
|
||||
case AppLang.es:
|
||||
if (m < 1) return 'ahora mismo';
|
||||
if (m < 60) return 'hace $m min';
|
||||
if (h < 24) return 'hace $h h';
|
||||
return 'hace $days d';
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新下载进度:「正在下载 X%」。
|
||||
String updateDownloadingPercent(int pct) {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '正在下载 $pct%';
|
||||
case AppLang.en:
|
||||
return 'Downloading $pct%';
|
||||
case AppLang.ja:
|
||||
return 'ダウンロード中 $pct%';
|
||||
case AppLang.ko:
|
||||
return '다운로드 중 $pct%';
|
||||
case AppLang.ru:
|
||||
return 'Загрузка $pct%';
|
||||
case AppLang.es:
|
||||
return 'Descargando $pct%';
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载完成、正在安装。
|
||||
String get updateInstalling {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '下载完成,正在安装…';
|
||||
case AppLang.en:
|
||||
return 'Downloaded, installing…';
|
||||
case AppLang.ja:
|
||||
return 'ダウンロード完了、インストール中…';
|
||||
case AppLang.ko:
|
||||
return '다운로드 완료, 설치 중…';
|
||||
case AppLang.ru:
|
||||
return 'Загружено, установка…';
|
||||
case AppLang.es:
|
||||
return 'Descargado, instalando…';
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载失败。
|
||||
String get updateDownloadFailed {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '下载失败,请重试或改用浏览器下载。';
|
||||
case AppLang.en:
|
||||
return 'Download failed. Retry or download in your browser.';
|
||||
case AppLang.ja:
|
||||
return 'ダウンロードに失敗しました。再試行するかブラウザでダウンロードしてください。';
|
||||
case AppLang.ko:
|
||||
return '다운로드에 실패했습니다. 다시 시도하거나 브라우저로 다운로드하세요.';
|
||||
case AppLang.ru:
|
||||
return 'Не удалось загрузить. Повторите или скачайте в браузере.';
|
||||
case AppLang.es:
|
||||
return 'Error de descarga. Reintenta o descarga en el navegador.';
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS:已下载,提示手动拖入应用程序。
|
||||
String get updateMacReveal {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '已下载并解压。请在访达中将 Pangolin 拖入「应用程序」文件夹替换旧版,然后重新打开。';
|
||||
case AppLang.en:
|
||||
return 'Downloaded and extracted. In Finder, drag Pangolin into your Applications folder to replace the old version, then reopen.';
|
||||
case AppLang.ja:
|
||||
return 'ダウンロードと展開が完了しました。Finder で Pangolin を「アプリケーション」フォルダにドラッグして置き換え、再度開いてください。';
|
||||
case AppLang.ko:
|
||||
return '다운로드 및 압축 해제 완료. Finder에서 Pangolin을 「응용 프로그램」 폴더로 드래그해 이전 버전을 교체한 뒤 다시 여세요.';
|
||||
case AppLang.ru:
|
||||
return 'Загружено и распаковано. В Finder перетащите Pangolin в папку «Программы», заменив старую версию, затем откройте заново.';
|
||||
case AppLang.es:
|
||||
return 'Descargado y extraído. En Finder, arrastra Pangolin a tu carpeta de Aplicaciones para reemplazar la versión anterior y vuelve a abrir.';
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败对话框:改用浏览器下载。
|
||||
String get updateOpenBrowser {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '浏览器下载';
|
||||
case AppLang.en:
|
||||
return 'Open in browser';
|
||||
case AppLang.ja:
|
||||
return 'ブラウザで開く';
|
||||
case AppLang.ko:
|
||||
return '브라우저에서 열기';
|
||||
case AppLang.ru:
|
||||
return 'Открыть в браузере';
|
||||
case AppLang.es:
|
||||
return 'Abrir en el navegador';
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用「取消」。
|
||||
String get updateCancelBtn {
|
||||
switch (this) {
|
||||
case AppLang.zh:
|
||||
return '取消';
|
||||
case AppLang.en:
|
||||
return 'Cancel';
|
||||
case AppLang.ja:
|
||||
return 'キャンセル';
|
||||
case AppLang.ko:
|
||||
return '취소';
|
||||
case AppLang.ru:
|
||||
return 'Отмена';
|
||||
case AppLang.es:
|
||||
return 'Cancelar';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部界面文案的抽象契约。zh / en 各实现一份。
|
||||
abstract class AppText {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// strings_es.dart — Recursos de texto en español (visualización monolingüe)
|
||||
//
|
||||
// Palabras prohibidas: VPN / traspasar muros / red científica / romper el bloqueo / cruce libre / Go anywhere.
|
||||
// Posicionamiento: aceleración de red / optimización de la experiencia + privacidad.
|
||||
// La numeración de planes sigue design/CLAUDE.md §7.
|
||||
import 'app_text.dart';
|
||||
|
||||
class StringsEs extends AppText {
|
||||
const StringsEs();
|
||||
|
||||
@override
|
||||
AppLang get lang => AppLang.es;
|
||||
|
||||
@override
|
||||
String get brand => 'Pangolin';
|
||||
@override
|
||||
String get online => '● En línea';
|
||||
@override
|
||||
String get offline => '○ Desconectado';
|
||||
@override
|
||||
String get capOff => 'Toca para conectar';
|
||||
@override
|
||||
String get capConnecting => 'Conectando…';
|
||||
@override
|
||||
String get capOn => 'Conectado · Cifrado';
|
||||
@override
|
||||
String get connectNow => 'CONECTAR';
|
||||
@override
|
||||
String get secure => 'SEGURO';
|
||||
|
||||
@override
|
||||
String get tabConnect => 'Conectar';
|
||||
@override
|
||||
String get tabServers => 'Servidores';
|
||||
@override
|
||||
String get tabStats => 'Estadísticas';
|
||||
@override
|
||||
String get tabMe => 'Cuenta';
|
||||
|
||||
@override
|
||||
String get currentNode => 'Nodo actual';
|
||||
@override
|
||||
String get download => 'Bajada';
|
||||
@override
|
||||
String get upload => 'Subida';
|
||||
@override
|
||||
String get latency => 'Ping';
|
||||
|
||||
@override
|
||||
String get quotaToday => 'Restante hoy';
|
||||
@override
|
||||
String get minutes => 'min';
|
||||
@override
|
||||
String get quotaFree => 'Gratis · 10 min/día';
|
||||
@override
|
||||
String get planFreeTag => 'Gratis';
|
||||
@override
|
||||
String get watchAd => 'Mira un anuncio para empezar';
|
||||
@override
|
||||
String get adUnlocked => 'Desbloqueado por hoy';
|
||||
@override
|
||||
String get quotaLeftLabel => 'Restante';
|
||||
@override
|
||||
String get quotaUsedUp => 'Tiempo gratis diario agotado';
|
||||
@override
|
||||
String get watchAdMore => 'Mira un anuncio para más tiempo';
|
||||
@override
|
||||
String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mira un anuncio para añadir tiempo o mejora tu plan.';
|
||||
@override
|
||||
String get adPlaying => 'Reproduciendo anuncio…';
|
||||
@override
|
||||
String adRewarded(int minutes) => '+$minutes min añadidos';
|
||||
@override
|
||||
String get adFailed => 'No se pudo añadir tiempo, inténtalo de nuevo';
|
||||
@override
|
||||
String get quotaDesktopTitle => 'Tiempo gratis diario agotado';
|
||||
@override
|
||||
String get quotaDesktopBody => 'Mira anuncios en la app móvil para añadir tiempo, o mejora tu plan para acceso ilimitado.';
|
||||
@override
|
||||
String get gotIt => 'Entendido';
|
||||
|
||||
@override
|
||||
String get chooseNode => 'Elegir servidor';
|
||||
@override
|
||||
String get searchPh => 'Buscar país / ciudad';
|
||||
@override
|
||||
String get smartSelect => 'Selección inteligente';
|
||||
@override
|
||||
String nodeSwitchTitle(String name) => '¿Cambiar a $name?';
|
||||
@override
|
||||
String get nodeSwitchBody => 'Esto desconecta el enlace actual y reconecta a este nodo.';
|
||||
@override
|
||||
String get nodeSwitchConfirm => 'Cambiar';
|
||||
@override
|
||||
String get nodeUnhealthySwitched => 'Nodo actual inaccesible: cambiado automáticamente';
|
||||
@override
|
||||
String get nodeUnhealthyError => 'El nodo actual está inaccesible: reconecta o cambia de nodo';
|
||||
@override
|
||||
String get nodeReconnecting => 'Red inestable, reconectando…';
|
||||
@override
|
||||
String get smartSub => 'Elige el mejor nodo para tu red';
|
||||
@override
|
||||
String get recommended => 'Recomendado';
|
||||
|
||||
@override
|
||||
String get statsTitle => 'Estadísticas';
|
||||
@override
|
||||
String get trafficMonth => 'Tráfico';
|
||||
@override
|
||||
String get avgPing => 'Ping medio';
|
||||
@override
|
||||
String get durMonth => 'Tiempo';
|
||||
@override
|
||||
String get weekTraffic => 'Esta semana (GB)';
|
||||
@override
|
||||
List<String> get days7 => const ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
|
||||
@override
|
||||
String get byDevice => 'Por dispositivo';
|
||||
@override
|
||||
String get noDeviceUsage => 'Aún no hay uso por dispositivo';
|
||||
@override
|
||||
String get periodMonth => 'Este mes';
|
||||
@override
|
||||
String get periodWeek => 'Esta semana';
|
||||
@override
|
||||
String get periodToday => 'Hoy';
|
||||
@override
|
||||
String get statDown => 'Descarga';
|
||||
@override
|
||||
String get statUp => 'Subida';
|
||||
@override
|
||||
String get statDuration => 'Duración';
|
||||
@override
|
||||
String get chartTwoWeeks => 'Últimas 2 semanas';
|
||||
@override
|
||||
String get allDevices => 'Todos los dispositivos';
|
||||
|
||||
@override
|
||||
String get meTitle => 'Cuenta';
|
||||
@override
|
||||
String get proMember => 'Miembro PRO';
|
||||
@override
|
||||
String get freePlanName => 'Gratis';
|
||||
@override
|
||||
String get expires => 'Vence';
|
||||
@override
|
||||
String get upgradeBtn => 'Renovar / Mejorar';
|
||||
@override
|
||||
String get accInfoTitle => 'Información de la cuenta';
|
||||
@override
|
||||
String get accEmail => 'Correo';
|
||||
@override
|
||||
String get accPassword => 'Contraseña';
|
||||
@override
|
||||
String get accChange => 'Cambiar';
|
||||
@override
|
||||
String get deviceLimitTitle => 'Límite de dispositivos alcanzado';
|
||||
@override
|
||||
String get deviceLimitDesc => 'Tu plan permite hasta %s dispositivos. Elimina uno para continuar.';
|
||||
@override
|
||||
String get deviceLimitRemoveOldest => 'Eliminar el menos usado recientemente';
|
||||
@override
|
||||
String get myDevices => 'Mis dispositivos';
|
||||
@override
|
||||
String get devicesSub => 'Hasta 3 dispositivos en PRO';
|
||||
@override
|
||||
String get thisDevice => 'Este dispositivo';
|
||||
@override
|
||||
String get remove => 'Eliminar';
|
||||
@override
|
||||
String get devOnline => 'En línea';
|
||||
@override
|
||||
String get devOffline => 'Desconectado';
|
||||
@override
|
||||
String get devLastOnline => 'Última conexión';
|
||||
@override
|
||||
String get devNeverLogin => 'Nunca ha iniciado sesión';
|
||||
@override
|
||||
String get devForceLogout => 'Forzar cierre de sesión';
|
||||
@override
|
||||
String get devClearLogin => 'Borrar datos de sesión';
|
||||
@override
|
||||
String get devRename => 'Renombrar';
|
||||
@override
|
||||
String get devRenameHint => 'Nombre del dispositivo';
|
||||
@override
|
||||
String get devSave => 'Guardar';
|
||||
@override
|
||||
String get devForceLogoutConfirm => 'Esto revoca la sesión del dispositivo: será desconectado y deberá iniciar sesión de nuevo. El dispositivo permanece en la lista.';
|
||||
@override
|
||||
String get devClearLoginConfirm => 'Esto elimina el dispositivo de la lista por completo y revoca su sesión y su credencial del plano de datos. El dispositivo deberá iniciar sesión de nuevo para poder usarse.';
|
||||
@override
|
||||
String get devCancel => 'Cancelar';
|
||||
@override
|
||||
String get sessionRevokedTitle => 'Se cerró la sesión de este dispositivo';
|
||||
@override
|
||||
String get sessionRevokedBody => 'Tu cuenta cerró la sesión de este dispositivo desde otro dispositivo. Inicia sesión de nuevo.';
|
||||
@override
|
||||
String get sessionRevokedOk => 'Iniciar sesión de nuevo';
|
||||
@override
|
||||
String get redeemEntry => 'Canjear y comprar';
|
||||
@override
|
||||
String get contactEntry => 'Contáctanos';
|
||||
@override
|
||||
String get signOut => 'Cerrar sesión';
|
||||
@override
|
||||
String get language => 'Idioma';
|
||||
@override
|
||||
String get darkAppearance => 'Apariencia oscura';
|
||||
@override
|
||||
String get stateOn => 'Activado';
|
||||
@override
|
||||
String get followLight => 'Desactivado';
|
||||
@override
|
||||
String get protocol => 'Protocolo';
|
||||
@override
|
||||
String get settingsTitle => 'Ajustes';
|
||||
@override
|
||||
String get autostart => 'Iniciar al arrancar';
|
||||
@override
|
||||
String get autostartSub => 'Ejecutar automáticamente al iniciar el sistema';
|
||||
@override
|
||||
String get autoConnect => 'Conexión automática';
|
||||
@override
|
||||
String get autoConnectSub => 'Conectar automáticamente al abrir';
|
||||
@override
|
||||
String autoConnectingTo(String name) => 'Conectando automáticamente a $name…';
|
||||
@override
|
||||
String get smartRoute => 'Enrutamiento inteligente';
|
||||
@override
|
||||
String get smartRouteSub => 'Acelera en el extranjero, directo en casa';
|
||||
@override
|
||||
String get killSwitch => 'Kill Switch';
|
||||
@override
|
||||
String get killSwitchSub => 'Bloquea el tráfico si se cae el enlace';
|
||||
@override
|
||||
String get checkUpdate => 'Buscar actualizaciones';
|
||||
@override
|
||||
String get webUserCenter => 'Centro de usuario (Web)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => 'Nueva versión v$version disponible';
|
||||
@override
|
||||
String get updateNotesFallback => 'Esta actualización incluye correcciones y mejoras de estabilidad.';
|
||||
@override
|
||||
String get updateLater => 'Más tarde';
|
||||
@override
|
||||
String get updateDownload => 'Descargar actualización';
|
||||
@override
|
||||
String get updateUpToDate => 'Ya tienes la última versión';
|
||||
@override
|
||||
String get updateCheckFailed => 'Falló la búsqueda de actualizaciones, inténtalo más tarde';
|
||||
|
||||
@override
|
||||
String get choosePlan => 'Elegir plan';
|
||||
@override
|
||||
String get freePlan => 'Gratis';
|
||||
@override
|
||||
String get proPlan => 'Pro';
|
||||
@override
|
||||
String get teamPlan => 'Equipo';
|
||||
@override
|
||||
String get perMonth => '/mes';
|
||||
@override
|
||||
String get current => 'Actual';
|
||||
@override
|
||||
String get upgrade => 'Mejorar';
|
||||
@override
|
||||
String get choose => 'Elegir';
|
||||
@override
|
||||
String get mostPopular => 'Popular';
|
||||
@override
|
||||
List<String> get featsFree => const [
|
||||
'Solo 1 nodo básico',
|
||||
'10 min al día',
|
||||
'Mira un anuncio para empezar',
|
||||
'Prueba gratis de 7 días al registrarte',
|
||||
];
|
||||
@override
|
||||
List<String> get featsPro =>
|
||||
const ['Más de 80 ubicaciones', 'Ilimitado · rápido', '5 dispositivos', 'Optimizado para streaming'];
|
||||
@override
|
||||
List<String> get featsTeam =>
|
||||
const ['Todo lo de Pro', '10 plazas', 'Facturación centralizada', 'Soporte prioritario'];
|
||||
|
||||
@override
|
||||
String get redeemTitle => 'Canjear y comprar';
|
||||
@override
|
||||
String get redeemCodeTitle => 'Canjear un código';
|
||||
@override
|
||||
String get redeemPh => 'Introduce el código de activación';
|
||||
@override
|
||||
String get redeemBtn => 'Canjear';
|
||||
@override
|
||||
String get redeemOk => 'Activado · PRO desbloqueado';
|
||||
@override
|
||||
String get buyTitle => 'Dónde comprar';
|
||||
@override
|
||||
String get buySub => 'El pago dentro de la app no está disponible. Consigue un código mediante:';
|
||||
@override
|
||||
String get chStore => 'Tienda autoservicio';
|
||||
@override
|
||||
String get chEmail => 'Soporte por correo';
|
||||
|
||||
@override
|
||||
String get contactTitle => 'Contáctanos';
|
||||
@override
|
||||
String get contactIntro =>
|
||||
'¿Necesitas ayuda? Escríbenos por cualquiera de los canales de abajo: solemos responder en minutos.';
|
||||
@override
|
||||
String get contactEmail => 'Soporte por correo';
|
||||
@override
|
||||
String get contactStore => 'Tienda autoservicio';
|
||||
@override
|
||||
String get contactHoursTitle => 'Horario de soporte';
|
||||
@override
|
||||
String get contactHours => 'Todos los días 9:00 – 24:00 (GMT+8)';
|
||||
|
||||
@override
|
||||
String get authTagline => 'Rápido · Estable · Sin complicaciones';
|
||||
@override
|
||||
String get tabLogin => 'Iniciar sesión';
|
||||
@override
|
||||
String get tabRegister => 'Registrarse';
|
||||
@override
|
||||
String get emailLabel => 'Correo';
|
||||
@override
|
||||
String get emailPh => 'tu@correo.com';
|
||||
@override
|
||||
String get pwLabel => 'Contraseña';
|
||||
@override
|
||||
String get pwPh => 'Introduce la contraseña';
|
||||
@override
|
||||
String get setPwPh => 'Crea una contraseña (para acceso multidispositivo)';
|
||||
@override
|
||||
String get codeLabel => 'Código de verificación';
|
||||
@override
|
||||
String codeSentTo(String email) => 'Código enviado a $email';
|
||||
@override
|
||||
String get sendCode => 'Enviar código';
|
||||
@override
|
||||
String get resend => 'Reenviar';
|
||||
@override
|
||||
String get doLogin => 'Iniciar sesión';
|
||||
@override
|
||||
String get doNext => 'Siguiente';
|
||||
@override
|
||||
String get doCreate => 'Crear cuenta';
|
||||
@override
|
||||
String get forgotPw => '¿Olvidaste la contraseña?';
|
||||
@override
|
||||
String get tos => 'Al continuar aceptas nuestros Términos y Política de privacidad';
|
||||
|
||||
@override
|
||||
String get obSkip => 'Omitir';
|
||||
@override
|
||||
String get obNext => 'Siguiente';
|
||||
@override
|
||||
String get obAllow => 'Permitir y continuar';
|
||||
@override
|
||||
String get obStart => 'Empezar';
|
||||
@override
|
||||
String get ob1Title => 'Conéctate en todo el mundo';
|
||||
@override
|
||||
String get ob1Sub => 'Más de 80 ubicaciones, enrutamiento al más rápido automático, sólido como una roca.';
|
||||
@override
|
||||
String get ob2Title => 'Permite la configuración de red';
|
||||
@override
|
||||
String get ob2Sub => 'El sistema te pedirá añadir un perfil de red para crear el túnel cifrado.';
|
||||
@override
|
||||
String get ob3Title => 'Privado por diseño';
|
||||
@override
|
||||
String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.';
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// strings_ja.dart — 日本語の文言リソース(単一言語表示)
|
||||
//
|
||||
// 禁止ワード: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。
|
||||
// ポジショニング: ネットワーク高速化 / 体験最適化 + プライバシー。
|
||||
// プラン番号は design/CLAUDE.md §7 に準拠。
|
||||
import 'app_text.dart';
|
||||
|
||||
class StringsJa extends AppText {
|
||||
const StringsJa();
|
||||
|
||||
@override
|
||||
AppLang get lang => AppLang.ja;
|
||||
|
||||
@override
|
||||
String get brand => 'Pangolin';
|
||||
@override
|
||||
String get online => '● オンライン';
|
||||
@override
|
||||
String get offline => '○ オフライン';
|
||||
@override
|
||||
String get capOff => 'タップして接続';
|
||||
@override
|
||||
String get capConnecting => '接続中…';
|
||||
@override
|
||||
String get capOn => '接続済み · 暗号化';
|
||||
@override
|
||||
String get connectNow => '接続';
|
||||
@override
|
||||
String get secure => 'セキュア';
|
||||
|
||||
@override
|
||||
String get tabConnect => '接続';
|
||||
@override
|
||||
String get tabServers => 'サーバー';
|
||||
@override
|
||||
String get tabStats => '統計';
|
||||
@override
|
||||
String get tabMe => 'アカウント';
|
||||
|
||||
@override
|
||||
String get currentNode => '現在のノード';
|
||||
@override
|
||||
String get download => '下り';
|
||||
@override
|
||||
String get upload => '上り';
|
||||
@override
|
||||
String get latency => 'Ping';
|
||||
|
||||
@override
|
||||
String get quotaToday => '本日の残り';
|
||||
@override
|
||||
String get minutes => '分';
|
||||
@override
|
||||
String get quotaFree => '無料 · 1日10分';
|
||||
@override
|
||||
String get planFreeTag => '無料';
|
||||
@override
|
||||
String get watchAd => '広告を見て開始';
|
||||
@override
|
||||
String get adUnlocked => '本日は利用可能';
|
||||
@override
|
||||
String get quotaLeftLabel => '残り';
|
||||
@override
|
||||
String get quotaUsedUp => '本日の無料時間を使い切りました';
|
||||
@override
|
||||
String get watchAdMore => '広告を見て時間を追加';
|
||||
@override
|
||||
String get quotaExhaustedNotice => '本日の無料時間を使い切りました。広告を見て時間を追加するか、アップグレードしてください。';
|
||||
@override
|
||||
String get adPlaying => '広告を再生中…';
|
||||
@override
|
||||
String adRewarded(int minutes) => '+$minutes 分を追加しました';
|
||||
@override
|
||||
String get adFailed => '時間の追加に失敗しました。もう一度お試しください';
|
||||
@override
|
||||
String get quotaDesktopTitle => '本日の無料時間を使い切りました';
|
||||
@override
|
||||
String get quotaDesktopBody => 'モバイルアプリで広告を見ると時間を追加できます。無制限に使うにはアップグレードしてください。';
|
||||
@override
|
||||
String get gotIt => '了解';
|
||||
|
||||
@override
|
||||
String get chooseNode => 'サーバーを選択';
|
||||
@override
|
||||
String get searchPh => '国・都市を検索';
|
||||
@override
|
||||
String get smartSelect => 'スマート選択';
|
||||
@override
|
||||
String nodeSwitchTitle(String name) => '$name に切り替えますか?';
|
||||
@override
|
||||
String get nodeSwitchBody => '現在の接続を切断し、このノードに再接続します。';
|
||||
@override
|
||||
String get nodeSwitchConfirm => '切り替える';
|
||||
@override
|
||||
String get nodeUnhealthySwitched => '現在のノードに接続できないため、自動的に切り替えました';
|
||||
@override
|
||||
String get nodeUnhealthyError => '現在のノードに接続できません。再接続するかノードを切り替えてください';
|
||||
@override
|
||||
String get nodeReconnecting => 'ネットワークが不安定です。再接続中…';
|
||||
@override
|
||||
String get smartSub => 'お使いのネットワークに最適なノードを選びます';
|
||||
@override
|
||||
String get recommended => 'おすすめ';
|
||||
|
||||
@override
|
||||
String get statsTitle => '統計';
|
||||
@override
|
||||
String get trafficMonth => '通信量';
|
||||
@override
|
||||
String get avgPing => '平均Ping';
|
||||
@override
|
||||
String get durMonth => '接続時間';
|
||||
@override
|
||||
String get weekTraffic => '今週 (GB)';
|
||||
@override
|
||||
List<String> get days7 => const ['月', '火', '水', '木', '金', '土', '日'];
|
||||
@override
|
||||
String get byDevice => 'デバイス別';
|
||||
@override
|
||||
String get noDeviceUsage => 'デバイスの利用履歴はまだありません';
|
||||
@override
|
||||
String get periodMonth => '今月';
|
||||
@override
|
||||
String get periodWeek => '今週';
|
||||
@override
|
||||
String get periodToday => '今日';
|
||||
@override
|
||||
String get statDown => 'ダウンロード';
|
||||
@override
|
||||
String get statUp => 'アップロード';
|
||||
@override
|
||||
String get statDuration => '接続時間';
|
||||
@override
|
||||
String get chartTwoWeeks => '過去2週間';
|
||||
@override
|
||||
String get allDevices => 'すべてのデバイス';
|
||||
|
||||
@override
|
||||
String get meTitle => 'アカウント';
|
||||
@override
|
||||
String get proMember => 'PRO会員';
|
||||
@override
|
||||
String get freePlanName => '無料';
|
||||
@override
|
||||
String get expires => '有効期限';
|
||||
@override
|
||||
String get upgradeBtn => '更新 / アップグレード';
|
||||
@override
|
||||
String get accInfoTitle => 'アカウント情報';
|
||||
@override
|
||||
String get accEmail => 'メールアドレス';
|
||||
@override
|
||||
String get accPassword => 'パスワード';
|
||||
@override
|
||||
String get accChange => '変更';
|
||||
@override
|
||||
String get deviceLimitTitle => 'デバイス数の上限に達しました';
|
||||
@override
|
||||
String get deviceLimitDesc => '現在のプランでは最大 %s 台まで利用できます。続けるには1台を削除してください。';
|
||||
@override
|
||||
String get deviceLimitRemoveOldest => '最後に使用したのが最も古いデバイスを削除';
|
||||
@override
|
||||
String get myDevices => 'マイデバイス';
|
||||
@override
|
||||
String get devicesSub => 'PROでは最大3台まで';
|
||||
@override
|
||||
String get thisDevice => 'このデバイス';
|
||||
@override
|
||||
String get remove => '削除';
|
||||
@override
|
||||
String get devOnline => 'オンライン';
|
||||
@override
|
||||
String get devOffline => 'オフライン';
|
||||
@override
|
||||
String get devLastOnline => '最終オンライン';
|
||||
@override
|
||||
String get devNeverLogin => 'ログイン履歴なし';
|
||||
@override
|
||||
String get devForceLogout => '強制ログアウト';
|
||||
@override
|
||||
String get devClearLogin => 'ログイン情報を消去';
|
||||
@override
|
||||
String get devRename => '名前を変更';
|
||||
@override
|
||||
String get devRenameHint => 'デバイス名';
|
||||
@override
|
||||
String get devSave => '保存';
|
||||
@override
|
||||
String get devForceLogoutConfirm => 'このデバイスのログインセッションを無効にします。オフラインになり、再度サインインが必要になります。デバイスはリストに残ります。';
|
||||
@override
|
||||
String get devClearLoginConfirm => 'このデバイスをリストから完全に削除し、ログインセッションとデータ通信の認証情報を無効にします。再び利用するには、もう一度サインインが必要です。';
|
||||
@override
|
||||
String get devCancel => 'キャンセル';
|
||||
@override
|
||||
String get sessionRevokedTitle => 'このデバイスはサインアウトされました';
|
||||
@override
|
||||
String get sessionRevokedBody => '別のデバイスから、このデバイスがサインアウトされました。もう一度サインインしてください。';
|
||||
@override
|
||||
String get sessionRevokedOk => '再度サインイン';
|
||||
@override
|
||||
String get redeemEntry => 'コード引き換え・購入';
|
||||
@override
|
||||
String get contactEntry => 'お問い合わせ';
|
||||
@override
|
||||
String get signOut => 'サインアウト';
|
||||
@override
|
||||
String get language => '言語';
|
||||
@override
|
||||
String get darkAppearance => 'ダークモード';
|
||||
@override
|
||||
String get stateOn => 'オン';
|
||||
@override
|
||||
String get followLight => 'オフ';
|
||||
@override
|
||||
String get protocol => 'プロトコル';
|
||||
@override
|
||||
String get settingsTitle => '設定';
|
||||
@override
|
||||
String get autostart => 'スタートアップ時に起動';
|
||||
@override
|
||||
String get autostartSub => 'システム起動時に自動的に実行';
|
||||
@override
|
||||
String get autoConnect => '自動接続';
|
||||
@override
|
||||
String get autoConnectSub => '起動時に自動的に接続';
|
||||
@override
|
||||
String autoConnectingTo(String name) => '$name に自動接続中…';
|
||||
@override
|
||||
String get smartRoute => 'スマートルーティング';
|
||||
@override
|
||||
String get smartRouteSub => '海外は高速化、国内は直接接続';
|
||||
@override
|
||||
String get killSwitch => 'キルスイッチ';
|
||||
@override
|
||||
String get killSwitchSub => '接続が切れたら通信を遮断';
|
||||
@override
|
||||
String get checkUpdate => 'アップデートを確認';
|
||||
@override
|
||||
String get webUserCenter => 'ユーザーセンター (Web)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => '新しいバージョン v$version が利用可能です';
|
||||
@override
|
||||
String get updateNotesFallback => 'このアップデートには不具合の修正と安定性の向上が含まれています。';
|
||||
@override
|
||||
String get updateLater => '後で';
|
||||
@override
|
||||
String get updateDownload => 'アップデートをダウンロード';
|
||||
@override
|
||||
String get updateUpToDate => '最新の状態です';
|
||||
@override
|
||||
String get updateCheckFailed => 'アップデートの確認に失敗しました。後でもう一度お試しください';
|
||||
|
||||
@override
|
||||
String get choosePlan => 'プランを選択';
|
||||
@override
|
||||
String get freePlan => '無料';
|
||||
@override
|
||||
String get proPlan => 'Pro';
|
||||
@override
|
||||
String get teamPlan => 'Team';
|
||||
@override
|
||||
String get perMonth => '/月';
|
||||
@override
|
||||
String get current => '現在';
|
||||
@override
|
||||
String get upgrade => 'アップグレード';
|
||||
@override
|
||||
String get choose => '選択';
|
||||
@override
|
||||
String get mostPopular => '人気';
|
||||
@override
|
||||
List<String> get featsFree => const [
|
||||
'基本ノード1つのみ',
|
||||
'1日10分',
|
||||
'広告を見て開始',
|
||||
'登録で7日間無料トライアル',
|
||||
];
|
||||
@override
|
||||
List<String> get featsPro =>
|
||||
const ['80以上の拠点', '無制限 · 高速', '5台のデバイス', 'ストリーミング最適化'];
|
||||
@override
|
||||
List<String> get featsTeam =>
|
||||
const ['Proのすべての機能', '10ライセンス', '一括請求', '優先サポート'];
|
||||
|
||||
@override
|
||||
String get redeemTitle => 'コード引き換え・購入';
|
||||
@override
|
||||
String get redeemCodeTitle => 'コードを引き換える';
|
||||
@override
|
||||
String get redeemPh => 'アクティベーションコードを入力';
|
||||
@override
|
||||
String get redeemBtn => '引き換える';
|
||||
@override
|
||||
String get redeemOk => '認証完了 · PROが利用可能に';
|
||||
@override
|
||||
String get buyTitle => '購入方法';
|
||||
@override
|
||||
String get buySub => 'アプリ内決済はご利用いただけません。以下からコードを入手してください:';
|
||||
@override
|
||||
String get chStore => 'セルフサービスストア';
|
||||
@override
|
||||
String get chEmail => 'メールサポート';
|
||||
|
||||
@override
|
||||
String get contactTitle => 'お問い合わせ';
|
||||
@override
|
||||
String get contactIntro =>
|
||||
'お困りですか?以下のいずれかの方法でお問い合わせください。通常は数分以内に返信します。';
|
||||
@override
|
||||
String get contactEmail => 'メールサポート';
|
||||
@override
|
||||
String get contactStore => 'セルフサービスストア';
|
||||
@override
|
||||
String get contactHoursTitle => 'サポート時間';
|
||||
@override
|
||||
String get contactHours => '毎日 9:00 – 24:00 (GMT+8)';
|
||||
|
||||
@override
|
||||
String get authTagline => '高速 · 安定 · かんたん';
|
||||
@override
|
||||
String get tabLogin => 'ログイン';
|
||||
@override
|
||||
String get tabRegister => '新規登録';
|
||||
@override
|
||||
String get emailLabel => 'メールアドレス';
|
||||
@override
|
||||
String get emailPh => 'your@email.com';
|
||||
@override
|
||||
String get pwLabel => 'パスワード';
|
||||
@override
|
||||
String get pwPh => 'パスワードを入力';
|
||||
@override
|
||||
String get setPwPh => 'パスワードを設定(複数デバイスでのログイン用)';
|
||||
@override
|
||||
String get codeLabel => '確認コード';
|
||||
@override
|
||||
String codeSentTo(String email) => '$email に確認コードを送信しました';
|
||||
@override
|
||||
String get sendCode => 'コードを送信';
|
||||
@override
|
||||
String get resend => '再送信';
|
||||
@override
|
||||
String get doLogin => 'ログイン';
|
||||
@override
|
||||
String get doNext => '次へ';
|
||||
@override
|
||||
String get doCreate => 'アカウントを作成';
|
||||
@override
|
||||
String get forgotPw => 'パスワードをお忘れですか?';
|
||||
@override
|
||||
String get tos => '続行すると、利用規約とプライバシーポリシーに同意したことになります';
|
||||
|
||||
@override
|
||||
String get obSkip => 'スキップ';
|
||||
@override
|
||||
String get obNext => '次へ';
|
||||
@override
|
||||
String get obAllow => '許可して続行';
|
||||
@override
|
||||
String get obStart => '始める';
|
||||
@override
|
||||
String get ob1Title => '世界中に接続';
|
||||
@override
|
||||
String get ob1Sub => '80以上の拠点、最速ルートを自動選択、抜群の安定性。';
|
||||
@override
|
||||
String get ob2Title => 'ネットワーク設定を許可';
|
||||
@override
|
||||
String get ob2Sub => '暗号化トンネルを構築するため、システムがネットワークプロファイルの追加を求めます。';
|
||||
@override
|
||||
String get ob3Title => '設計段階からプライバシー重視';
|
||||
@override
|
||||
String get ob3Sub => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。';
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// strings_ko.dart — 한국어 문구 리소스 (단일 언어 표시)
|
||||
//
|
||||
// 금지어: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere.
|
||||
// 포지셔닝: 네트워크 가속 / 경험 최적화 + 프라이버시.
|
||||
// 요금제 번호는 design/CLAUDE.md §7 을 따름.
|
||||
import 'app_text.dart';
|
||||
|
||||
class StringsKo extends AppText {
|
||||
const StringsKo();
|
||||
|
||||
@override
|
||||
AppLang get lang => AppLang.ko;
|
||||
|
||||
@override
|
||||
String get brand => 'Pangolin';
|
||||
@override
|
||||
String get online => '● 온라인';
|
||||
@override
|
||||
String get offline => '○ 오프라인';
|
||||
@override
|
||||
String get capOff => '탭하여 연결';
|
||||
@override
|
||||
String get capConnecting => '연결 중…';
|
||||
@override
|
||||
String get capOn => '연결됨 · 암호화';
|
||||
@override
|
||||
String get connectNow => '연결';
|
||||
@override
|
||||
String get secure => '보안';
|
||||
|
||||
@override
|
||||
String get tabConnect => '연결';
|
||||
@override
|
||||
String get tabServers => '서버';
|
||||
@override
|
||||
String get tabStats => '통계';
|
||||
@override
|
||||
String get tabMe => '계정';
|
||||
|
||||
@override
|
||||
String get currentNode => '현재 노드';
|
||||
@override
|
||||
String get download => '다운';
|
||||
@override
|
||||
String get upload => '업';
|
||||
@override
|
||||
String get latency => '지연';
|
||||
|
||||
@override
|
||||
String get quotaToday => '오늘 남은 시간';
|
||||
@override
|
||||
String get minutes => '분';
|
||||
@override
|
||||
String get quotaFree => '무료 · 하루 10분';
|
||||
@override
|
||||
String get planFreeTag => '무료';
|
||||
@override
|
||||
String get watchAd => '광고 시청 후 시작';
|
||||
@override
|
||||
String get adUnlocked => '오늘 사용 가능';
|
||||
@override
|
||||
String get quotaLeftLabel => '남음';
|
||||
@override
|
||||
String get quotaUsedUp => '오늘 무료 시간을 모두 사용함';
|
||||
@override
|
||||
String get watchAdMore => '광고 시청으로 시간 추가';
|
||||
@override
|
||||
String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 광고를 시청해 시간을 추가하거나 업그레이드하세요.';
|
||||
@override
|
||||
String get adPlaying => '광고 재생 중…';
|
||||
@override
|
||||
String adRewarded(int minutes) => '+$minutes분 추가됨';
|
||||
@override
|
||||
String get adFailed => '시간 추가에 실패했습니다. 다시 시도하세요';
|
||||
@override
|
||||
String get quotaDesktopTitle => '오늘 무료 시간을 모두 사용함';
|
||||
@override
|
||||
String get quotaDesktopBody => '모바일 앱에서 광고를 시청해 시간을 추가하거나, 업그레이드하여 무제한으로 이용하세요.';
|
||||
@override
|
||||
String get gotIt => '확인';
|
||||
|
||||
@override
|
||||
String get chooseNode => '서버 선택';
|
||||
@override
|
||||
String get searchPh => '국가 / 도시 검색';
|
||||
@override
|
||||
String get smartSelect => '스마트 선택';
|
||||
@override
|
||||
String nodeSwitchTitle(String name) => '$name(으)로 전환할까요?';
|
||||
@override
|
||||
String get nodeSwitchBody => '현재 연결을 끊고 이 노드로 다시 연결합니다.';
|
||||
@override
|
||||
String get nodeSwitchConfirm => '전환';
|
||||
@override
|
||||
String get nodeUnhealthySwitched => '현재 노드에 연결할 수 없어 자동으로 전환했습니다';
|
||||
@override
|
||||
String get nodeUnhealthyError => '현재 노드에 연결할 수 없습니다 — 재연결하거나 노드를 전환하세요';
|
||||
@override
|
||||
String get nodeReconnecting => '네트워크가 불안정하여 다시 연결 중…';
|
||||
@override
|
||||
String get smartSub => '네트워크에 가장 적합한 노드를 선택합니다';
|
||||
@override
|
||||
String get recommended => '추천';
|
||||
|
||||
@override
|
||||
String get statsTitle => '통계';
|
||||
@override
|
||||
String get trafficMonth => '트래픽';
|
||||
@override
|
||||
String get avgPing => '평균 지연';
|
||||
@override
|
||||
String get durMonth => '시간';
|
||||
@override
|
||||
String get weekTraffic => '이번 주 (GB)';
|
||||
@override
|
||||
List<String> get days7 => const ['월', '화', '수', '목', '금', '토', '일'];
|
||||
@override
|
||||
String get byDevice => '기기별';
|
||||
@override
|
||||
String get noDeviceUsage => '아직 기기 사용 기록이 없습니다';
|
||||
@override
|
||||
String get periodMonth => '이번 달';
|
||||
@override
|
||||
String get periodWeek => '이번 주';
|
||||
@override
|
||||
String get periodToday => '오늘';
|
||||
@override
|
||||
String get statDown => '다운로드';
|
||||
@override
|
||||
String get statUp => '업로드';
|
||||
@override
|
||||
String get statDuration => '사용 시간';
|
||||
@override
|
||||
String get chartTwoWeeks => '최근 2주';
|
||||
@override
|
||||
String get allDevices => '모든 기기';
|
||||
|
||||
@override
|
||||
String get meTitle => '계정';
|
||||
@override
|
||||
String get proMember => 'PRO 회원';
|
||||
@override
|
||||
String get freePlanName => '무료';
|
||||
@override
|
||||
String get expires => '만료일';
|
||||
@override
|
||||
String get upgradeBtn => '갱신 / 업그레이드';
|
||||
@override
|
||||
String get accInfoTitle => '계정 정보';
|
||||
@override
|
||||
String get accEmail => '이메일';
|
||||
@override
|
||||
String get accPassword => '비밀번호';
|
||||
@override
|
||||
String get accChange => '변경';
|
||||
@override
|
||||
String get deviceLimitTitle => '기기 한도에 도달함';
|
||||
@override
|
||||
String get deviceLimitDesc => '현재 요금제는 최대 %s대의 기기를 허용합니다. 계속하려면 한 대를 삭제하세요.';
|
||||
@override
|
||||
String get deviceLimitRemoveOldest => '가장 오래 사용하지 않은 기기 삭제';
|
||||
@override
|
||||
String get myDevices => '내 기기';
|
||||
@override
|
||||
String get devicesSub => 'PRO는 최대 3대';
|
||||
@override
|
||||
String get thisDevice => '이 기기';
|
||||
@override
|
||||
String get remove => '삭제';
|
||||
@override
|
||||
String get devOnline => '온라인';
|
||||
@override
|
||||
String get devOffline => '오프라인';
|
||||
@override
|
||||
String get devLastOnline => '마지막 접속';
|
||||
@override
|
||||
String get devNeverLogin => '로그인한 적 없음';
|
||||
@override
|
||||
String get devForceLogout => '강제 로그아웃';
|
||||
@override
|
||||
String get devClearLogin => '로그인 정보 삭제';
|
||||
@override
|
||||
String get devRename => '이름 변경';
|
||||
@override
|
||||
String get devRenameHint => '기기 이름';
|
||||
@override
|
||||
String get devSave => '저장';
|
||||
@override
|
||||
String get devForceLogoutConfirm => '이 기기의 로그인 세션을 무효화합니다 — 오프라인으로 강제 종료되며 다시 로그인해야 합니다. 기기는 목록에 남아 있습니다.';
|
||||
@override
|
||||
String get devClearLoginConfirm => '이 기기를 목록에서 완전히 삭제하고 로그인 세션과 데이터 전송 자격 증명을 무효화합니다. 다시 사용하려면 재로그인해야 합니다.';
|
||||
@override
|
||||
String get devCancel => '취소';
|
||||
@override
|
||||
String get sessionRevokedTitle => '이 기기에서 로그아웃되었습니다';
|
||||
@override
|
||||
String get sessionRevokedBody => '다른 기기에서 이 기기를 로그아웃했습니다. 다시 로그인하세요.';
|
||||
@override
|
||||
String get sessionRevokedOk => '다시 로그인';
|
||||
@override
|
||||
String get redeemEntry => '코드 등록 및 구매';
|
||||
@override
|
||||
String get contactEntry => '문의하기';
|
||||
@override
|
||||
String get signOut => '로그아웃';
|
||||
@override
|
||||
String get language => '언어';
|
||||
@override
|
||||
String get darkAppearance => '다크 모드';
|
||||
@override
|
||||
String get stateOn => '켜짐';
|
||||
@override
|
||||
String get followLight => '꺼짐';
|
||||
@override
|
||||
String get protocol => '프로토콜';
|
||||
@override
|
||||
String get settingsTitle => '설정';
|
||||
@override
|
||||
String get autostart => '시작 시 실행';
|
||||
@override
|
||||
String get autostartSub => '시스템 시작 시 자동으로 실행';
|
||||
@override
|
||||
String get autoConnect => '자동 연결';
|
||||
@override
|
||||
String get autoConnectSub => '실행 시 자동으로 연결';
|
||||
@override
|
||||
String autoConnectingTo(String name) => '$name(으)로 자동 연결 중…';
|
||||
@override
|
||||
String get smartRoute => '스마트 라우팅';
|
||||
@override
|
||||
String get smartRouteSub => '해외는 가속, 국내는 직접 연결';
|
||||
@override
|
||||
String get killSwitch => 'Kill Switch';
|
||||
@override
|
||||
String get killSwitchSub => '연결이 끊기면 트래픽 차단';
|
||||
@override
|
||||
String get checkUpdate => '업데이트 확인';
|
||||
@override
|
||||
String get webUserCenter => '사용자 센터 (웹)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => '새 버전 v$version 사용 가능';
|
||||
@override
|
||||
String get updateNotesFallback => '이번 업데이트에는 버그 수정과 안정성 개선이 포함되어 있습니다.';
|
||||
@override
|
||||
String get updateLater => '나중에';
|
||||
@override
|
||||
String get updateDownload => '업데이트 다운로드';
|
||||
@override
|
||||
String get updateUpToDate => '최신 버전입니다';
|
||||
@override
|
||||
String get updateCheckFailed => '업데이트 확인에 실패했습니다. 잠시 후 다시 시도하세요';
|
||||
|
||||
@override
|
||||
String get choosePlan => '요금제 선택';
|
||||
@override
|
||||
String get freePlan => '무료';
|
||||
@override
|
||||
String get proPlan => 'Pro';
|
||||
@override
|
||||
String get teamPlan => 'Team';
|
||||
@override
|
||||
String get perMonth => '/월';
|
||||
@override
|
||||
String get current => '현재';
|
||||
@override
|
||||
String get upgrade => '업그레이드';
|
||||
@override
|
||||
String get choose => '선택';
|
||||
@override
|
||||
String get mostPopular => '인기';
|
||||
@override
|
||||
List<String> get featsFree => const [
|
||||
'기본 노드 1개',
|
||||
'하루 10분',
|
||||
'광고 시청 후 시작',
|
||||
'가입 시 7일 무료 체험',
|
||||
];
|
||||
@override
|
||||
List<String> get featsPro =>
|
||||
const ['80개 이상 지역', '무제한 · 빠른 속도', '기기 5대', '스트리밍 최적화'];
|
||||
@override
|
||||
List<String> get featsTeam =>
|
||||
const ['Pro의 모든 기능', '10인 좌석', '통합 결제', '우선 지원'];
|
||||
|
||||
@override
|
||||
String get redeemTitle => '코드 등록 및 구매';
|
||||
@override
|
||||
String get redeemCodeTitle => '코드 등록';
|
||||
@override
|
||||
String get redeemPh => '활성화 코드 입력';
|
||||
@override
|
||||
String get redeemBtn => '등록';
|
||||
@override
|
||||
String get redeemOk => '활성화됨 · PRO 잠금 해제';
|
||||
@override
|
||||
String get buyTitle => '구매처';
|
||||
@override
|
||||
String get buySub => '앱 내 결제는 지원되지 않습니다. 다음 경로로 코드를 받으세요:';
|
||||
@override
|
||||
String get chStore => '셀프 스토어';
|
||||
@override
|
||||
String get chEmail => '이메일 지원';
|
||||
|
||||
@override
|
||||
String get contactTitle => '문의하기';
|
||||
@override
|
||||
String get contactIntro =>
|
||||
'도움이 필요하신가요? 아래 채널로 문의하세요 — 보통 몇 분 안에 답변드립니다.';
|
||||
@override
|
||||
String get contactEmail => '이메일 지원';
|
||||
@override
|
||||
String get contactStore => '셀프 스토어';
|
||||
@override
|
||||
String get contactHoursTitle => '지원 시간';
|
||||
@override
|
||||
String get contactHours => '매일 9:00 – 24:00 (GMT+8)';
|
||||
|
||||
@override
|
||||
String get authTagline => '빠르게 · 안정적으로 · 손쉽게';
|
||||
@override
|
||||
String get tabLogin => '로그인';
|
||||
@override
|
||||
String get tabRegister => '회원가입';
|
||||
@override
|
||||
String get emailLabel => '이메일';
|
||||
@override
|
||||
String get emailPh => 'your@email.com';
|
||||
@override
|
||||
String get pwLabel => '비밀번호';
|
||||
@override
|
||||
String get pwPh => '비밀번호 입력';
|
||||
@override
|
||||
String get setPwPh => '비밀번호 설정 (다중 기기 로그인용)';
|
||||
@override
|
||||
String get codeLabel => '인증 코드';
|
||||
@override
|
||||
String codeSentTo(String email) => '$email(으)로 코드를 보냈습니다';
|
||||
@override
|
||||
String get sendCode => '코드 전송';
|
||||
@override
|
||||
String get resend => '재전송';
|
||||
@override
|
||||
String get doLogin => '로그인';
|
||||
@override
|
||||
String get doNext => '다음';
|
||||
@override
|
||||
String get doCreate => '계정 만들기';
|
||||
@override
|
||||
String get forgotPw => '비밀번호를 잊으셨나요?';
|
||||
@override
|
||||
String get tos => '계속하면 이용약관 및 개인정보 처리방침에 동의하는 것입니다';
|
||||
|
||||
@override
|
||||
String get obSkip => '건너뛰기';
|
||||
@override
|
||||
String get obNext => '다음';
|
||||
@override
|
||||
String get obAllow => '허용하고 계속';
|
||||
@override
|
||||
String get obStart => '시작하기';
|
||||
@override
|
||||
String get ob1Title => '전 세계로 연결';
|
||||
@override
|
||||
String get ob1Sub => '80개 이상의 지역, 자동 최적 경로, 안정적인 연결.';
|
||||
@override
|
||||
String get ob2Title => '네트워크 설정 허용';
|
||||
@override
|
||||
String get ob2Sub => '암호화된 터널을 구성하기 위해 시스템이 네트워크 프로필 추가를 요청합니다.';
|
||||
@override
|
||||
String get ob3Title => '설계부터 프라이버시';
|
||||
@override
|
||||
String get ob3Sub => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.';
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// strings_ru.dart — русские текстовые ресурсы (отображение на одном языке)
|
||||
//
|
||||
// Запрещённые слова: VPN /翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / ВПН.
|
||||
// Позиционирование: ускорение сети / оптимизация опыта + приватность.
|
||||
// Номера тарифов соответствуют design/CLAUDE.md §7.
|
||||
import 'app_text.dart';
|
||||
|
||||
class StringsRu extends AppText {
|
||||
const StringsRu();
|
||||
|
||||
@override
|
||||
AppLang get lang => AppLang.ru;
|
||||
|
||||
@override
|
||||
String get brand => 'Pangolin';
|
||||
@override
|
||||
String get online => '● В сети';
|
||||
@override
|
||||
String get offline => '○ Не в сети';
|
||||
@override
|
||||
String get capOff => 'Нажмите для подключения';
|
||||
@override
|
||||
String get capConnecting => 'Подключение…';
|
||||
@override
|
||||
String get capOn => 'Подключено · Шифрование';
|
||||
@override
|
||||
String get connectNow => 'ПОДКЛЮЧИТЬ';
|
||||
@override
|
||||
String get secure => 'ЗАЩИЩЕНО';
|
||||
|
||||
@override
|
||||
String get tabConnect => 'Подключение';
|
||||
@override
|
||||
String get tabServers => 'Серверы';
|
||||
@override
|
||||
String get tabStats => 'Статистика';
|
||||
@override
|
||||
String get tabMe => 'Аккаунт';
|
||||
|
||||
@override
|
||||
String get currentNode => 'Текущий узел';
|
||||
@override
|
||||
String get download => 'Приём';
|
||||
@override
|
||||
String get upload => 'Отдача';
|
||||
@override
|
||||
String get latency => 'Пинг';
|
||||
|
||||
@override
|
||||
String get quotaToday => 'Осталось сегодня';
|
||||
@override
|
||||
String get minutes => 'мин';
|
||||
@override
|
||||
String get quotaFree => 'Бесплатно · 10 мин/день';
|
||||
@override
|
||||
String get planFreeTag => 'Бесплатно';
|
||||
@override
|
||||
String get watchAd => 'Смотрите рекламу, чтобы начать';
|
||||
@override
|
||||
String get adUnlocked => 'Открыто на сегодня';
|
||||
@override
|
||||
String get quotaLeftLabel => 'Осталось';
|
||||
@override
|
||||
String get quotaUsedUp => 'Дневной бесплатный лимит исчерпан';
|
||||
@override
|
||||
String get watchAdMore => 'Смотрите рекламу, чтобы добавить время';
|
||||
@override
|
||||
String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Посмотрите рекламу, чтобы добавить время, или оформите подписку.';
|
||||
@override
|
||||
String get adPlaying => 'Реклама воспроизводится…';
|
||||
@override
|
||||
String adRewarded(int minutes) => '+$minutes мин добавлено';
|
||||
@override
|
||||
String get adFailed => 'Не удалось добавить время, попробуйте ещё раз';
|
||||
@override
|
||||
String get quotaDesktopTitle => 'Дневной бесплатный лимит исчерпан';
|
||||
@override
|
||||
String get quotaDesktopBody => 'Смотрите рекламу в мобильном приложении, чтобы добавить время, или оформите подписку для безлимитного доступа.';
|
||||
@override
|
||||
String get gotIt => 'Понятно';
|
||||
|
||||
@override
|
||||
String get chooseNode => 'Выбор сервера';
|
||||
@override
|
||||
String get searchPh => 'Поиск страны / города';
|
||||
@override
|
||||
String get smartSelect => 'Умный выбор';
|
||||
@override
|
||||
String nodeSwitchTitle(String name) => 'Переключиться на $name?';
|
||||
@override
|
||||
String get nodeSwitchBody => 'Текущее соединение будет разорвано и восстановлено через этот узел.';
|
||||
@override
|
||||
String get nodeSwitchConfirm => 'Переключить';
|
||||
@override
|
||||
String get nodeUnhealthySwitched => 'Текущий узел недоступен — переключение выполнено автоматически';
|
||||
@override
|
||||
String get nodeUnhealthyError => 'Текущий узел недоступен — переподключитесь или смените узел';
|
||||
@override
|
||||
String get nodeReconnecting => 'Сеть нестабильна, переподключение…';
|
||||
@override
|
||||
String get smartSub => 'Подбирает лучший узел для вашей сети';
|
||||
@override
|
||||
String get recommended => 'Рекомендуется';
|
||||
|
||||
@override
|
||||
String get statsTitle => 'Статистика';
|
||||
@override
|
||||
String get trafficMonth => 'Трафик';
|
||||
@override
|
||||
String get avgPing => 'Средний пинг';
|
||||
@override
|
||||
String get durMonth => 'Время';
|
||||
@override
|
||||
String get weekTraffic => 'На этой неделе (ГБ)';
|
||||
@override
|
||||
List<String> get days7 => const ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
@override
|
||||
String get byDevice => 'По устройствам';
|
||||
@override
|
||||
String get noDeviceUsage => 'Пока нет данных по устройствам';
|
||||
@override
|
||||
String get periodMonth => 'Этот месяц';
|
||||
@override
|
||||
String get periodWeek => 'Эта неделя';
|
||||
@override
|
||||
String get periodToday => 'Сегодня';
|
||||
@override
|
||||
String get statDown => 'Приём';
|
||||
@override
|
||||
String get statUp => 'Отдача';
|
||||
@override
|
||||
String get statDuration => 'Длительность';
|
||||
@override
|
||||
String get chartTwoWeeks => 'Последние 2 недели';
|
||||
@override
|
||||
String get allDevices => 'Все устройства';
|
||||
|
||||
@override
|
||||
String get meTitle => 'Аккаунт';
|
||||
@override
|
||||
String get proMember => 'PRO-подписка';
|
||||
@override
|
||||
String get freePlanName => 'Бесплатный';
|
||||
@override
|
||||
String get expires => 'Действует до';
|
||||
@override
|
||||
String get upgradeBtn => 'Продлить / Улучшить';
|
||||
@override
|
||||
String get accInfoTitle => 'Данные аккаунта';
|
||||
@override
|
||||
String get accEmail => 'Эл. почта';
|
||||
@override
|
||||
String get accPassword => 'Пароль';
|
||||
@override
|
||||
String get accChange => 'Изменить';
|
||||
@override
|
||||
String get deviceLimitTitle => 'Достигнут лимит устройств';
|
||||
@override
|
||||
String get deviceLimitDesc => 'Ваш тариф допускает до %s устройств. Удалите одно, чтобы продолжить.';
|
||||
@override
|
||||
String get deviceLimitRemoveOldest => 'Удалить давно неиспользуемое';
|
||||
@override
|
||||
String get myDevices => 'Мои устройства';
|
||||
@override
|
||||
String get devicesSub => 'До 3 устройств на PRO';
|
||||
@override
|
||||
String get thisDevice => 'Это устройство';
|
||||
@override
|
||||
String get remove => 'Удалить';
|
||||
@override
|
||||
String get devOnline => 'В сети';
|
||||
@override
|
||||
String get devOffline => 'Не в сети';
|
||||
@override
|
||||
String get devLastOnline => 'Был(а) в сети';
|
||||
@override
|
||||
String get devNeverLogin => 'Ни разу не входил(а)';
|
||||
@override
|
||||
String get devForceLogout => 'Принудительный выход';
|
||||
@override
|
||||
String get devClearLogin => 'Очистить данные входа';
|
||||
@override
|
||||
String get devRename => 'Переименовать';
|
||||
@override
|
||||
String get devRenameHint => 'Имя устройства';
|
||||
@override
|
||||
String get devSave => 'Сохранить';
|
||||
@override
|
||||
String get devForceLogoutConfirm => 'Это завершит сеанс входа устройства — оно будет отключено и потребует повторного входа. Устройство останется в списке.';
|
||||
@override
|
||||
String get devClearLoginConfirm => 'Это полностью удалит устройство из списка и отзовёт его сеанс входа и учётные данные передачи данных. Чтобы снова использовать устройство, нужно войти заново.';
|
||||
@override
|
||||
String get devCancel => 'Отмена';
|
||||
@override
|
||||
String get sessionRevokedTitle => 'Это устройство было отключено';
|
||||
@override
|
||||
String get sessionRevokedBody => 'Вход с этого устройства завершён с другого устройства вашего аккаунта. Пожалуйста, войдите снова.';
|
||||
@override
|
||||
String get sessionRevokedOk => 'Войти снова';
|
||||
@override
|
||||
String get redeemEntry => 'Активация и покупка';
|
||||
@override
|
||||
String get contactEntry => 'Связаться с нами';
|
||||
@override
|
||||
String get signOut => 'Выйти';
|
||||
@override
|
||||
String get language => 'Язык';
|
||||
@override
|
||||
String get darkAppearance => 'Тёмная тема';
|
||||
@override
|
||||
String get stateOn => 'Вкл.';
|
||||
@override
|
||||
String get followLight => 'Выкл.';
|
||||
@override
|
||||
String get protocol => 'Протокол';
|
||||
@override
|
||||
String get settingsTitle => 'Настройки';
|
||||
@override
|
||||
String get autostart => 'Запуск при старте системы';
|
||||
@override
|
||||
String get autostartSub => 'Запускать автоматически при включении системы';
|
||||
@override
|
||||
String get autoConnect => 'Автоподключение';
|
||||
@override
|
||||
String get autoConnectSub => 'Подключаться автоматически при запуске';
|
||||
@override
|
||||
String autoConnectingTo(String name) => 'Автоподключение к $name…';
|
||||
@override
|
||||
String get smartRoute => 'Умная маршрутизация';
|
||||
@override
|
||||
String get smartRouteSub => 'Ускорение для зарубежных ресурсов, прямой доступ для локальных';
|
||||
@override
|
||||
String get killSwitch => 'Kill Switch';
|
||||
@override
|
||||
String get killSwitchSub => 'Блокировать трафик при разрыве соединения';
|
||||
@override
|
||||
String get checkUpdate => 'Проверить обновления';
|
||||
@override
|
||||
String get webUserCenter => 'Личный кабинет (веб)';
|
||||
@override
|
||||
String updateAvailableTitle(String version) => 'Доступна новая версия v$version';
|
||||
@override
|
||||
String get updateNotesFallback => 'Это обновление включает исправления и улучшения стабильности.';
|
||||
@override
|
||||
String get updateLater => 'Позже';
|
||||
@override
|
||||
String get updateDownload => 'Скачать обновление';
|
||||
@override
|
||||
String get updateUpToDate => 'У вас установлена последняя версия';
|
||||
@override
|
||||
String get updateCheckFailed => 'Не удалось проверить обновления, попробуйте позже';
|
||||
|
||||
@override
|
||||
String get choosePlan => 'Выбор тарифа';
|
||||
@override
|
||||
String get freePlan => 'Бесплатный';
|
||||
@override
|
||||
String get proPlan => 'Pro';
|
||||
@override
|
||||
String get teamPlan => 'Команда';
|
||||
@override
|
||||
String get perMonth => '/мес';
|
||||
@override
|
||||
String get current => 'Текущий';
|
||||
@override
|
||||
String get upgrade => 'Улучшить';
|
||||
@override
|
||||
String get choose => 'Выбрать';
|
||||
@override
|
||||
String get mostPopular => 'Популярный';
|
||||
@override
|
||||
List<String> get featsFree => const [
|
||||
'Только 1 базовый узел',
|
||||
'10 минут в день',
|
||||
'Смотрите рекламу, чтобы начать',
|
||||
'7 дней бесплатно при регистрации',
|
||||
];
|
||||
@override
|
||||
List<String> get featsPro =>
|
||||
const ['80+ локаций', 'Безлимитно · быстро', '5 устройств', 'Оптимизация для стриминга'];
|
||||
@override
|
||||
List<String> get featsTeam =>
|
||||
const ['Всё из Pro', '10 мест', 'Единый счёт', 'Приоритетная поддержка'];
|
||||
|
||||
@override
|
||||
String get redeemTitle => 'Активация и покупка';
|
||||
@override
|
||||
String get redeemCodeTitle => 'Активировать код';
|
||||
@override
|
||||
String get redeemPh => 'Введите код активации';
|
||||
@override
|
||||
String get redeemBtn => 'Активировать';
|
||||
@override
|
||||
String get redeemOk => 'Активировано · PRO открыт';
|
||||
@override
|
||||
String get buyTitle => 'Где купить';
|
||||
@override
|
||||
String get buySub => 'Оплата внутри приложения недоступна. Получить код можно через:';
|
||||
@override
|
||||
String get chStore => 'Магазин самообслуживания';
|
||||
@override
|
||||
String get chEmail => 'Поддержка по эл. почте';
|
||||
|
||||
@override
|
||||
String get contactTitle => 'Связаться с нами';
|
||||
@override
|
||||
String get contactIntro =>
|
||||
'Нужна помощь? Свяжитесь с нами любым способом ниже — обычно отвечаем за считанные минуты.';
|
||||
@override
|
||||
String get contactEmail => 'Поддержка по эл. почте';
|
||||
@override
|
||||
String get contactStore => 'Магазин самообслуживания';
|
||||
@override
|
||||
String get contactHoursTitle => 'Часы поддержки';
|
||||
@override
|
||||
String get contactHours => 'Ежедневно 9:00 – 24:00 (GMT+8)';
|
||||
|
||||
@override
|
||||
String get authTagline => 'Быстро · Стабильно · Без усилий';
|
||||
@override
|
||||
String get tabLogin => 'Вход';
|
||||
@override
|
||||
String get tabRegister => 'Регистрация';
|
||||
@override
|
||||
String get emailLabel => 'Эл. почта';
|
||||
@override
|
||||
String get emailPh => 'your@email.com';
|
||||
@override
|
||||
String get pwLabel => 'Пароль';
|
||||
@override
|
||||
String get pwPh => 'Введите пароль';
|
||||
@override
|
||||
String get setPwPh => 'Задайте пароль (для входа с нескольких устройств)';
|
||||
@override
|
||||
String get codeLabel => 'Код подтверждения';
|
||||
@override
|
||||
String codeSentTo(String email) => 'Код отправлен на $email';
|
||||
@override
|
||||
String get sendCode => 'Отправить код';
|
||||
@override
|
||||
String get resend => 'Отправить снова';
|
||||
@override
|
||||
String get doLogin => 'Войти';
|
||||
@override
|
||||
String get doNext => 'Далее';
|
||||
@override
|
||||
String get doCreate => 'Создать аккаунт';
|
||||
@override
|
||||
String get forgotPw => 'Забыли пароль?';
|
||||
@override
|
||||
String get tos => 'Продолжая, вы принимаете наши Условия и Политику конфиденциальности';
|
||||
|
||||
@override
|
||||
String get obSkip => 'Пропустить';
|
||||
@override
|
||||
String get obNext => 'Далее';
|
||||
@override
|
||||
String get obAllow => 'Разрешить и продолжить';
|
||||
@override
|
||||
String get obStart => 'Начать';
|
||||
@override
|
||||
String get ob1Title => 'Подключение по всему миру';
|
||||
@override
|
||||
String get ob1Sub => '80+ локаций, автоматический выбор самого быстрого маршрута, надёжная стабильность.';
|
||||
@override
|
||||
String get ob2Title => 'Разрешите настройку сети';
|
||||
@override
|
||||
String get ob2Sub => 'Система запросит добавление сетевого профиля для создания зашифрованного туннеля.';
|
||||
@override
|
||||
String get ob3Title => 'Приватность по умолчанию';
|
||||
@override
|
||||
String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.';
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class Node {
|
||||
String localizedSub(AppLang lang) {
|
||||
switch (tag) {
|
||||
case NodeTag.streaming:
|
||||
return lang == AppLang.zh ? '流媒体优化' : 'Streaming';
|
||||
return lang.tagStreaming;
|
||||
case NodeTag.p2p:
|
||||
return 'P2P';
|
||||
case NodeTag.none:
|
||||
|
||||
@@ -75,7 +75,7 @@ class AccountPage extends ConsumerWidget {
|
||||
_CustomRow(
|
||||
icon: PangolinIcons.globe,
|
||||
title: t.language,
|
||||
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l),
|
||||
trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)),
|
||||
),
|
||||
Divider(height: 1, color: c.border),
|
||||
_CustomRow(
|
||||
@@ -351,31 +351,37 @@ class _LangSwitch extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
Widget seg(AppLang v, String label) {
|
||||
final active = lang == v;
|
||||
return GestureDetector(
|
||||
onTap: () => onChange(v),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? c.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
// 6 种语言用下拉(段控横排会挤)。
|
||||
return PopupMenuButton<AppLang>(
|
||||
initialValue: lang,
|
||||
onSelected: onChange,
|
||||
color: c.surface,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
itemBuilder: (_) => [
|
||||
for (final l in AppLang.values)
|
||||
PopupMenuItem<AppLang>(
|
||||
value: l,
|
||||
height: 42,
|
||||
child: Text(l.nativeLabel,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: l == lang ? c.accent : c.fg1,
|
||||
fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500,
|
||||
fontSize: 13)),
|
||||
),
|
||||
child: Text(label,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
seg(AppLang.zh, '中文'),
|
||||
const SizedBox(width: 2),
|
||||
seg(AppLang.en, 'EN'),
|
||||
]),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
|
||||
decoration:
|
||||
BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(lang.nativeLabel,
|
||||
style: PangolinText.caption
|
||||
.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12.5)),
|
||||
const SizedBox(width: 4),
|
||||
Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ class _NodeGridTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
||||
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
|
||||
final unavail = lang.unavailable;
|
||||
return Opacity(
|
||||
opacity: down ? 0.55 : 1.0,
|
||||
child: Material(
|
||||
|
||||
@@ -20,7 +20,7 @@ import '../widgets/update_dialog.dart';
|
||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||
/// 有更新则弹 [showUpdateDialog]。
|
||||
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
||||
final info = await ref.read(updateCheckerProvider).check();
|
||||
final info = await ref.read(updateProvider.notifier).forceCheck();
|
||||
if (!context.mounted) return;
|
||||
if (info == null) {
|
||||
showPangolinToast(context, t.updateCheckFailed);
|
||||
@@ -72,7 +72,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
// 配置组
|
||||
_Card(children: [
|
||||
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).state = l)),
|
||||
_Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).set(l))),
|
||||
_Row(
|
||||
title: t.darkAppearance,
|
||||
sub: isDark ? t.stateOn : t.followLight,
|
||||
@@ -148,27 +148,37 @@ class _LangSwitch extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
Widget seg(AppLang v, String label) {
|
||||
final on = lang == v;
|
||||
return GestureDetector(
|
||||
onTap: () => onPick(v),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? c.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
||||
// 6 种语言用下拉(段控横排会挤)。当前语言以本地名 + 下拉箭头展示。
|
||||
return PopupMenuButton<AppLang>(
|
||||
initialValue: lang,
|
||||
onSelected: onPick,
|
||||
color: c.surface,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
||||
itemBuilder: (_) => [
|
||||
for (final l in AppLang.values)
|
||||
PopupMenuItem<AppLang>(
|
||||
value: l,
|
||||
height: 42,
|
||||
child: Text(l.nativeLabel,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: l == lang ? c.accent : c.fg1,
|
||||
fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500,
|
||||
fontSize: 13)),
|
||||
),
|
||||
child: Text(label,
|
||||
style: PangolinText.caption.copyWith(
|
||||
color: on ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [seg(AppLang.zh, '中文'), seg(AppLang.en, 'EN')]),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration:
|
||||
BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(lang.nativeLabel,
|
||||
style: PangolinText.caption
|
||||
.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12)),
|
||||
const SizedBox(width: 4),
|
||||
Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,11 @@ const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'https://api.yanmeiai.com',
|
||||
);
|
||||
|
||||
/// 用户中心(网页)基址(单源)。已从独立子域 app.yanmeiai.com 迁到主站子路径
|
||||
/// pangolin.yanmeiai.com/user/(旧子域已停用)。末尾不带斜杠;调用方自行拼 path。
|
||||
/// 本地联调可 --dart-define=PANGOLIN_USERCENTER_URL=http://127.0.0.1:3000 覆盖。
|
||||
const String kWebUserCenterBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_USERCENTER_URL',
|
||||
defaultValue: 'https://pangolin.yanmeiai.com/user',
|
||||
);
|
||||
|
||||
@@ -8,9 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../state/account_providers.dart';
|
||||
|
||||
/// 用户中心(网页版)基址。
|
||||
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
|
||||
import 'api_config.dart';
|
||||
|
||||
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
|
||||
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
|
||||
|
||||
@@ -8,6 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/responsive/form_factor.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'mobile_shell.dart';
|
||||
import 'tablet_shell.dart';
|
||||
@@ -18,6 +21,23 @@ class HomeShell extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
|
||||
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询);
|
||||
// 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免
|
||||
// rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。
|
||||
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
|
||||
final info = next.valueOrNull;
|
||||
if (info == null || !info.hasUpdate) return;
|
||||
final notifier = ref.read(updateProvider.notifier);
|
||||
if (notifier.isDismissed) return;
|
||||
if (!info.forceUpdate) notifier.dismiss();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
showUpdateDialog(context, ref.read(appTextProvider), info);
|
||||
});
|
||||
});
|
||||
ref.watch(updateProvider); // 激活 provider(惰性 build)
|
||||
|
||||
final Widget body = switch (context.formFactor) {
|
||||
FormFactor.desktop => const DesktopShell(),
|
||||
FormFactor.tablet => const TabletShell(),
|
||||
|
||||
@@ -1,21 +1,77 @@
|
||||
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../l10n/strings_en.dart';
|
||||
import '../l10n/strings_es.dart';
|
||||
import '../l10n/strings_ja.dart';
|
||||
import '../l10n/strings_ko.dart';
|
||||
import '../l10n/strings_ru.dart';
|
||||
import '../l10n/strings_zh.dart';
|
||||
import 'account_providers.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 当前语言(单显)。设置/账户页段控切换。
|
||||
final localeProvider = StateProvider<AppLang>((ref) => AppLang.zh);
|
||||
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
|
||||
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
|
||||
AppText appTextFor(AppLang lang) {
|
||||
switch (lang) {
|
||||
case AppLang.zh:
|
||||
return const StringsZh();
|
||||
case AppLang.en:
|
||||
return const StringsEn();
|
||||
case AppLang.ja:
|
||||
return const StringsJa();
|
||||
case AppLang.ko:
|
||||
return const StringsKo();
|
||||
case AppLang.ru:
|
||||
return const StringsRu();
|
||||
case AppLang.es:
|
||||
return const StringsEs();
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到
|
||||
/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化,
|
||||
/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。
|
||||
class LocaleNotifier extends StateNotifier<AppLang> {
|
||||
LocaleNotifier() : super(AppLang.en) {
|
||||
_load();
|
||||
}
|
||||
static const _key = 'pg_lang';
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getString(_key);
|
||||
if (saved != null) {
|
||||
for (final l in AppLang.values) {
|
||||
if (l.name == saved) {
|
||||
state = l;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* 读失败保持默认 en */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set(AppLang lang) async {
|
||||
state = lang;
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setString(_key, lang.name);
|
||||
} catch (_) {
|
||||
/* 持久化失败忽略,本次会话仍生效 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final localeProvider =
|
||||
StateNotifierProvider<LocaleNotifier, AppLang>((ref) => LocaleNotifier());
|
||||
|
||||
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
|
||||
final appTextProvider = Provider<AppText>((ref) {
|
||||
final lang = ref.watch(localeProvider);
|
||||
return lang == AppLang.zh ? const StringsZh() : const StringsEn();
|
||||
});
|
||||
final appTextProvider = Provider<AppText>((ref) => appTextFor(ref.watch(localeProvider)));
|
||||
|
||||
/// 主题模式。默认跟随系统;设置页可显式切深色。
|
||||
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
|
||||
|
||||
@@ -203,7 +203,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
state = const ConnectionState(phase: VpnPhase.connecting);
|
||||
|
||||
final node = _ref.read(effectiveNodeProvider);
|
||||
final zh = _ref.read(localeProvider) == AppLang.zh;
|
||||
final lang = _ref.read(localeProvider);
|
||||
final zh = lang == AppLang.zh; // 仅用于服务端 e.messageZh/En 的二选一(下方)
|
||||
logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} '
|
||||
'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}');
|
||||
|
||||
@@ -212,7 +213,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (mounted) {
|
||||
state = ConnectionState(
|
||||
phase: VpnPhase.off,
|
||||
error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry',
|
||||
error: lang.nodesNotReady,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -244,7 +245,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
if (mounted) {
|
||||
state = ConnectionState(
|
||||
phase: VpnPhase.off,
|
||||
error: zh ? '连接失败,请重试' : 'Connection failed, please retry',
|
||||
error: lang.connectFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。
|
||||
// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。
|
||||
//
|
||||
// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息,
|
||||
// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗
|
||||
// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。
|
||||
// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性
|
||||
// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。
|
||||
// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。
|
||||
// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。
|
||||
//
|
||||
// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与
|
||||
// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。
|
||||
// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -15,6 +16,15 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../services/api_config.dart';
|
||||
|
||||
/// 启动后首次检查的延迟(避开登录/首屏竞争)。
|
||||
const _kInitialDelay = Duration(seconds: 3);
|
||||
|
||||
/// 轮询间隔。
|
||||
const _kPollInterval = Duration(hours: 1);
|
||||
|
||||
/// 单次检查网络超时。
|
||||
const _kCheckTimeout = Duration(seconds: 8);
|
||||
|
||||
/// 一次更新检查的结果。
|
||||
class AppUpdateInfo {
|
||||
const AppUpdateInfo({
|
||||
@@ -34,18 +44,43 @@ class AppUpdateInfo {
|
||||
final bool hasUpdate;
|
||||
}
|
||||
|
||||
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。
|
||||
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
|
||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
Timer? _timer;
|
||||
bool _dismissed = false;
|
||||
|
||||
class UpdateChecker {
|
||||
const UpdateChecker();
|
||||
/// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。
|
||||
bool get isDismissed => _dismissed;
|
||||
|
||||
@override
|
||||
Future<AppUpdateInfo?> build() async {
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
await Future<void>.delayed(_kInitialDelay);
|
||||
final first = await _check();
|
||||
_timer = Timer.periodic(_kPollInterval, (_) async {
|
||||
_dismissed = false; // 新一轮允许再次提示
|
||||
state = AsyncValue.data(await _check());
|
||||
});
|
||||
return first;
|
||||
}
|
||||
|
||||
/// 用户点「稍后」——本次会话内不再自动弹(直到下轮轮询)。
|
||||
void dismiss() => _dismissed = true;
|
||||
|
||||
/// 设置页「检查更新」手动触发:立即查一次并回结果(不受 dismiss 影响)。
|
||||
Future<AppUpdateInfo?> forceCheck() async {
|
||||
_dismissed = false;
|
||||
final info = await _check();
|
||||
state = AsyncValue.data(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
||||
Future<AppUpdateInfo?> check() async {
|
||||
Future<AppUpdateInfo?> _check() async {
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse('$kApiBaseUrl/version'))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
.timeout(_kCheckTimeout);
|
||||
if (resp.statusCode != 200) return null;
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
|
||||
@@ -56,8 +91,8 @@ class UpdateChecker {
|
||||
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
|
||||
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final hasUpdate = _isNewer(latestVersion, info.version);
|
||||
final pkg = await PackageInfo.fromPlatform();
|
||||
final hasUpdate = _isNewer(latestVersion, pkg.version);
|
||||
|
||||
return AppUpdateInfo(
|
||||
latestVersion: latestVersion,
|
||||
@@ -95,6 +130,9 @@ class UpdateChecker {
|
||||
}
|
||||
}
|
||||
|
||||
final updateProvider =
|
||||
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||
|
||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||
|
||||
@@ -98,7 +98,7 @@ class PlansScreen extends ConsumerWidget {
|
||||
Widget body() => plansAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||
error: (_, __) => Center(
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
data: (plans) => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||
children: [
|
||||
@@ -140,13 +140,8 @@ class DevicesScreen extends ConsumerWidget {
|
||||
|
||||
// 相对时间(最后登录);null → 从未登录。
|
||||
String _rel(DateTime? ts) {
|
||||
final zh = t.lang == AppLang.zh;
|
||||
if (ts == null) return t.devNeverLogin;
|
||||
final d = DateTime.now().difference(ts.toLocal());
|
||||
if (d.inMinutes < 1) return zh ? '刚刚' : 'just now';
|
||||
if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago';
|
||||
if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago';
|
||||
return zh ? '${d.inDays} 天前' : '${d.inDays}d ago';
|
||||
return t.lang.relativeTime(DateTime.now().difference(ts.toLocal()));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -158,12 +153,12 @@ class DevicesScreen extends ConsumerWidget {
|
||||
Widget content() => devicesAsync.when(
|
||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||
error: (_, __) => Center(
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))),
|
||||
data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
|
||||
Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||
const SizedBox(height: 12),
|
||||
if (devices.isEmpty)
|
||||
Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet',
|
||||
Text(t.lang.noDevices,
|
||||
style: PangolinText.sm.copyWith(color: c.fg3))
|
||||
else
|
||||
Container(
|
||||
|
||||
@@ -39,7 +39,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
bool _sent = false;
|
||||
bool _loading = false;
|
||||
bool _pwVisible = false;
|
||||
String? _errorZh;
|
||||
String? _error;
|
||||
|
||||
final _email = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
@@ -90,17 +90,17 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
// ── 认证操作(逻辑不变)──────────────────────────────────────────
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await _api.sendCode(_email.text.trim());
|
||||
if (mounted) setState(() { _sent = true; _loading = false; });
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doRegister() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
||||
final tokens = await _api.register(
|
||||
@@ -113,12 +113,12 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doLogin() async {
|
||||
setState(() { _loading = true; _errorZh = null; });
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
||||
final tokens = await _api.login(
|
||||
@@ -132,7 +132,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
|
||||
if (mounted) widget.onDone();
|
||||
} on AuthApiException catch (e) {
|
||||
if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; });
|
||||
if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
_mode = m;
|
||||
_step = 0;
|
||||
_sent = false;
|
||||
_errorZh = null;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────
|
||||
@@ -278,7 +278,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
child: Column(children: [
|
||||
_segTabs(c, t),
|
||||
const SizedBox(height: 22),
|
||||
if (_errorZh != null) ...[
|
||||
if (_error != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
@@ -289,7 +289,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
child: Row(children: [
|
||||
Icon(PangolinIcons.x, size: 16, color: c.danger),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))),
|
||||
Expanded(child: Text(_error!, style: PangolinText.sm.copyWith(color: c.danger))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
@@ -457,7 +457,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _step = 0),
|
||||
child: Text(t.lang == AppLang.zh ? '改邮箱' : 'Change',
|
||||
child: Text(t.lang.changeEmail,
|
||||
style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -44,7 +44,7 @@ class ServerTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
||||
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
|
||||
final unavail = lang.unavailable;
|
||||
return InkWell(
|
||||
onTap: down ? null : onTap,
|
||||
child: Opacity(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// update_dialog.dart — 「发现新版本」更新提示弹窗。
|
||||
//
|
||||
// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链
|
||||
// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭
|
||||
// (无「稍后」按钮、点遮罩/返回也关不掉)。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
// 点「下载更新」走 App 内下载安装(core/update/app_updater.dart,带进度框,不再开
|
||||
// 浏览器);Android 装 APK、Windows 跑安装包、macOS 解压提示拖入、iOS 降级外链。
|
||||
// force_update=true 时不可关闭(无「稍后」按钮、点遮罩/返回也关不掉)。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/update/app_updater.dart';
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/update_provider.dart';
|
||||
@@ -63,15 +65,12 @@ class _UpdateDialog extends StatelessWidget {
|
||||
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final url = platformDownloadUrl(info.downloadUrls);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
onPressed: () {
|
||||
// 先关本弹窗,用 Navigator 自身 context(关后仍挂载)启动 App 内下载
|
||||
// (它自带进度框)。
|
||||
final nav = Navigator.of(context);
|
||||
nav.pop();
|
||||
unawaited(startInAppUpdate(nav.context, t, info));
|
||||
},
|
||||
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ dependencies:
|
||||
flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化
|
||||
shared_preferences: ^2.5.5
|
||||
package_info_plus: ^9.0.1
|
||||
url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页)
|
||||
url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / iOS 更新降级外链)
|
||||
open_filex: ^4.5.0 # App 内更新:下载后拉起系统安装器(Android APK / 打开文件)
|
||||
device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报)
|
||||
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
|
||||
launch_at_startup: ^0.5.1
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
# 不需要重启/重新部署控制面。
|
||||
#
|
||||
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
|
||||
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写;
|
||||
# macos / ios 产物尚未产出,先留空字符串。
|
||||
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写。
|
||||
# macos 自 client-v1.0.59 起由 build-macos 产出 pangolin-macos-x64.zip 并部署到
|
||||
# /downloads,故填稳定直链;ios 走 TestFlight(无直接下载文件),保持留空。
|
||||
version: "1.0.48"
|
||||
build_number: 10048
|
||||
force_update: false
|
||||
@@ -17,6 +18,6 @@ release_notes: ""
|
||||
download_urls:
|
||||
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
|
||||
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
|
||||
macos: ""
|
||||
macos: "https://api.yanmeiai.com/downloads/pangolin-macos-x64.zip"
|
||||
ios: ""
|
||||
changelog: []
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# combine-site.sh — 把用户中心(web/usercenter/out/)并入官网产物,供**单次** CF Pages
|
||||
# 部署到 pangolin-site:官网在 /、用户中心在 /user/(域名迁移:原独立子域
|
||||
# app.yanmeiai.com → pangolin.yanmeiai.com/user/)。
|
||||
#
|
||||
# 关键:两个 app 的 CSP 不同(官网用脚本哈希 + connect-src 'self';用户中心要
|
||||
# 'unsafe-inline' + connect-src https 调 api)。CF Pages 只读产物根 _headers,故把
|
||||
# 用户中心的 _headers 规则前缀重定到 /user/*,追加在官网规则之后 —— CF Pages 对同
|
||||
# 一路径「后写规则覆盖先写」,于是 /user/* 命中用户中心 CSP,其余走官网 CSP。
|
||||
#
|
||||
# 由 compile-site.sh + compile-usercenter.sh 先产出 dist/ 与 out/;从 repo 根调用。
|
||||
set -euo pipefail
|
||||
|
||||
DIST=web/website/dist
|
||||
UC=web/usercenter/out
|
||||
|
||||
[ -d "$DIST" ] || { echo "==> combine-site: $DIST 不存在(先跑 compile-site.sh)" >&2; exit 1; }
|
||||
[ -d "$UC" ] || { echo "==> combine-site: $UC 不存在(先跑 compile-usercenter.sh)" >&2; exit 1; }
|
||||
|
||||
# 1) 用户中心产物挂到 /user/。
|
||||
rm -rf "${DIST}/user"
|
||||
mkdir -p "${DIST}/user"
|
||||
cp -R "${UC}/." "${DIST}/user/"
|
||||
|
||||
# 2) 合并 _headers:用户中心路径规则前缀 /user,追加到官网 _headers 之后。
|
||||
# 删掉并入产物里那份会被 CF 忽略的 /user/_headers,避免误导。
|
||||
rm -f "${DIST}/user/_headers"
|
||||
{
|
||||
echo ""
|
||||
echo "# ==== 用户中心(/user/*)独立 CSP,combine-site.sh 从 usercenter/_headers 重定 ===="
|
||||
# 行首 '/'(路径规则)前缀 /user;'#' 注释与缩进的 header 行不动。
|
||||
sed -E 's#^/#/user/#' "${UC}/_headers"
|
||||
} >> "${DIST}/_headers"
|
||||
|
||||
echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并"
|
||||
ls -la "${DIST}/user" | head
|
||||
@@ -223,8 +223,12 @@ PLIST
|
||||
|
||||
echo "==> compile-macos: inside-out codesign (sysext -> frameworks -> app)"
|
||||
xattr -cr "$APP"
|
||||
cp "$SYSEXT_PROFILE" "${SE}/Contents/embedded.provisionprofile"
|
||||
cp "$APP_PROFILE" "${APP}/Contents/embedded.provisionprofile"
|
||||
# 从 WORK 临时目录复制(不是 PROF_DIR):flutter build macos 期间 xcodebuild 会
|
||||
# 修剪 ~/Library/Developer/Xcode/UserData/Provisioning Profiles,把[3/7]构建前
|
||||
# 装进去的 profile 清掉,构建后再从那目录 cp 会扑空(No such file → 本步失败)。
|
||||
# WORK 是 mktemp 目录、xcodebuild 不碰,里面的原始 .provisionprofile 全程留存。
|
||||
cp "${WORK}/sysext.provisionprofile" "${SE}/Contents/embedded.provisionprofile"
|
||||
cp "${WORK}/app.provisionprofile" "${APP}/Contents/embedded.provisionprofile"
|
||||
|
||||
# sysext first — libbox is linked (not embedded) into the sysext binary
|
||||
# itself (see CLAUDE.md), so signing the sysext bundle re-covers its statically
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# compile-usercenter.sh — build the Next.js 用户中心 (web/usercenter) as a static
|
||||
# export (output: web/usercenter/out/, basePath=/user)。由 combine-site.sh 并入官网
|
||||
# dist/user/,随官网一起部署到 CF Pages pangolin-site(pangolin.yanmeiai.com/user/)。
|
||||
# 域名迁移:原独立子域 app.yanmeiai.com 已停用。
|
||||
#
|
||||
# ⚠️ 必须以「真实 API」模式构建:NEXT_PUBLIC_API_MODE=http。否则 lib/api/client.ts
|
||||
# 默认落到 mock(内存假数据),部署上去登录/订阅全是演示数据。API 域名是公开信息
|
||||
# (非密钥,经 CF Tunnel 暴露的控制面),故此处内置默认值;可用同名 env 覆盖。
|
||||
#
|
||||
# 由 .gitea/workflows/deploy-site.yml 在 ubuntu runner 上调用,从 repo 根运行。
|
||||
set -euo pipefail
|
||||
|
||||
export NEXT_PUBLIC_API_MODE="${NEXT_PUBLIC_API_MODE:-http}"
|
||||
export NEXT_PUBLIC_API_DOMAINS="${NEXT_PUBLIC_API_DOMAINS:-https://api.yanmeiai.com}"
|
||||
echo "==> compile-usercenter: API_MODE=${NEXT_PUBLIC_API_MODE} API_DOMAINS=${NEXT_PUBLIC_API_DOMAINS}"
|
||||
|
||||
cd web/usercenter
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
echo "==> compile-usercenter: done — out/ contents:"
|
||||
ls -lh out/ | head
|
||||
@@ -123,7 +123,7 @@ func main() {
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(apierr.Middleware)
|
||||
// CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。
|
||||
// CORS:Web 用户中心(pangolin.yanmeiai.com/user)跨域调 /v1/*;原生端不受影响。
|
||||
r.Use(httpapi.NewCORS())
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
func NewCORS() func(http.Handler) http.Handler {
|
||||
raw := os.Getenv("CORS_ORIGINS")
|
||||
if raw == "" {
|
||||
raw = "https://app.yanmeiai.com,https://pangolin-usercenter.pages.dev"
|
||||
// 用户中心已迁到主站子路径 pangolin.yanmeiai.com/user/,其 origin 即主站域名
|
||||
// (旧 app.yanmeiai.com 已停用)。pages.dev 为 CF Pages 直连兜底。
|
||||
raw = "https://pangolin.yanmeiai.com,https://pangolin-site.pages.dev"
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, o := range strings.Split(raw, ",") {
|
||||
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
)
|
||||
|
||||
func TestCORS(t *testing.T) {
|
||||
t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com")
|
||||
t.Setenv("CORS_ORIGINS", "https://pangolin.yanmeiai.com")
|
||||
mw := NewCORS()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||
h := mw(next)
|
||||
|
||||
// 允许的 Origin:补 Allow-Origin,普通请求继续。
|
||||
r := httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||
r.Header.Set("Origin", "https://pangolin.yanmeiai.com")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.yanmeiai.com" {
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://pangolin.yanmeiai.com" {
|
||||
t.Fatalf("allowed origin: want header, got %q", got)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
@@ -26,7 +26,7 @@ func TestCORS(t *testing.T) {
|
||||
|
||||
// OPTIONS 预检:204,不落到业务。
|
||||
r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||
r.Header.Set("Origin", "https://pangolin.yanmeiai.com")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNoContent {
|
||||
|
||||
@@ -3,8 +3,8 @@ import './globals.css';
|
||||
import { UIProvider } from '../lib/theme';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '穿山甲 · 用户中心',
|
||||
description: '管理订阅、兑换激活码、查看用量、邀请返利与账户安全设置。',
|
||||
title: 'Pangolin · Account',
|
||||
description: 'Manage your subscription, redeem codes, view usage, referrals and account security.',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -12,13 +12,17 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
// basePath 前缀(与 next.config.mjs 同源)。public/ 的根绝对资源(colors_and_type.css)
|
||||
// 不会被 Next 自动加 basePath,需手动拼,否则挂到 /user/ 下会 404。
|
||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/user';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh" data-theme="light" suppressHydrationWarning>
|
||||
<html lang="en" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
<link rel="stylesheet" href="/colors_and_type.css" />
|
||||
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
|
||||
</head>
|
||||
<body>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
|
||||
@@ -23,28 +23,33 @@ export const input: React.CSSProperties = {
|
||||
};
|
||||
|
||||
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||
// 6 语言用原生下拉(段控横排会挤)。默认 en。
|
||||
const langs: [Lang, string][] = [
|
||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
|
||||
{([['zh', '中文'], ['en', 'EN']] as [Lang, string][]).map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setLang(v)}
|
||||
aria-pressed={lang === v}
|
||||
style={{
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 999,
|
||||
padding: '5px 12px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: lang === v ? 'var(--accent)' : 'transparent',
|
||||
color: lang === v ? 'var(--fg-on-accent)' : 'var(--fg3)',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as Lang)}
|
||||
aria-label="Language"
|
||||
style={{
|
||||
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
|
||||
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 10,
|
||||
padding: '5px 10px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--fg2)',
|
||||
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||
}}
|
||||
>
|
||||
{langs.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</div>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
// errors.ts — 错误码 → 双语文案映射表(阶段 B 落地)
|
||||
// errors.ts — 错误码 → 多语文案映射表(阶段 B 落地)
|
||||
import { ApiError } from './types';
|
||||
import type { Lang } from '../i18n';
|
||||
|
||||
/**
|
||||
* 后端统一返回 {code, message_zh, message_en};前端优先用后端文案,
|
||||
* 兜底用本表(后端文案缺失/网络层错误时)。codes 与 #1 openapi 对齐。
|
||||
* 后端统一返回 {code, message_zh, message_en};前端优先用本表(按 code 命中当前语言),
|
||||
* 再回退后端文案(en 优先),最后 unknown。codes 与 #1 openapi 对齐。
|
||||
*/
|
||||
export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
|
||||
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
|
||||
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down' },
|
||||
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required' },
|
||||
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
|
||||
code_invalid: { zh: '激活码无效', en: 'Invalid activation code' },
|
||||
code_used: { zh: '激活码已被使用', en: 'Activation code already used' },
|
||||
code_expired: { zh: '激活码已过期', en: 'Activation code expired' },
|
||||
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
|
||||
device_not_found: { zh: '设备不存在', en: 'Device not found' },
|
||||
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
|
||||
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' },
|
||||
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
export const ERROR_TEXT: Record<string, { zh: string; en: string; ja: string; ko: string; ru: string; es: string }> = {
|
||||
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password', ja: 'メールアドレスまたはパスワードが違います', ko: '이메일 또는 비밀번호가 올바르지 않습니다', ru: 'Неверная почта или пароль', es: 'Correo o contraseña incorrectos' },
|
||||
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down', ja: 'リクエストが多すぎます。少し時間をおいてください', ko: '요청이 너무 많습니다. 잠시 후 다시 시도하세요', ru: 'Слишком много запросов, помедленнее', es: 'Demasiadas solicitudes, ve más despacio' },
|
||||
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked', ja: '試行回数が多すぎます — アカウントを一時的にロックしました', ko: '시도 횟수가 너무 많습니다 — 계정이 일시적으로 잠겼습니다', ru: 'Слишком много попыток — аккаунт временно заблокирован', es: 'Demasiados intentos: la cuenta está bloqueada temporalmente' },
|
||||
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required', ja: '二要素認証コードが必要です', ko: '2단계 인증 코드가 필요합니다', ru: 'Требуется код двухфакторной аутентификации', es: 'Se requiere el código de verificación en dos pasos' },
|
||||
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again', ja: 'コードが正しくありません。もう一度お試しください', ko: '코드가 올바르지 않습니다. 다시 시도하세요', ru: 'Неверный код, попробуйте снова', es: 'Código no válido, inténtalo de nuevo' },
|
||||
code_invalid: { zh: '激活码无效', en: 'Invalid activation code', ja: 'アクティベーションコードが無効です', ko: '인증 코드가 유효하지 않습니다', ru: 'Недействительный код активации', es: 'Código de activación no válido' },
|
||||
code_used: { zh: '激活码已被使用', en: 'Activation code already used', ja: 'アクティベーションコードは使用済みです', ko: '이미 사용된 인증 코드입니다', ru: 'Код активации уже использован', es: 'El código de activación ya se ha usado' },
|
||||
code_expired: { zh: '激活码已过期', en: 'Activation code expired', ja: 'アクティベーションコードの有効期限が切れています', ko: '인증 코드가 만료되었습니다', ru: 'Срок действия кода активации истёк', es: 'El código de activación ha caducado' },
|
||||
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached', ja: 'デバイス数が上限に達しました', ko: '기기 수가 한도에 도달했습니다', ru: 'Достигнут лимит устройств', es: 'Se alcanzó el límite de dispositivos' },
|
||||
device_not_found: { zh: '设备不存在', en: 'Device not found', ja: 'デバイスが見つかりません', ko: '기기를 찾을 수 없습니다', ru: 'Устройство не найдено', es: 'Dispositivo no encontrado' },
|
||||
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again', ja: 'セッションの有効期限が切れました。再度ログインしてください', ko: '세션이 만료되었습니다. 다시 로그인하세요', ru: 'Сеанс истёк, войдите снова', es: 'La sesión ha caducado, inicia sesión de nuevo' },
|
||||
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app', ja: 'ログインチケットが無効か期限切れです。アプリから開き直してください', ko: '로그인 티켓이 유효하지 않거나 만료되었습니다. 앱에서 다시 여세요', ru: 'Токен входа недействителен или истёк, откройте снова из приложения', es: 'El ticket de inicio de sesión no es válido o ha caducado, vuelve a abrirlo desde la app' },
|
||||
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry', ja: 'ネットワークエラーです。後でもう一度お試しください', ko: '네트워크 오류입니다. 잠시 후 다시 시도하세요', ru: 'Ошибка сети, повторите попытку', es: 'Error de red, vuelve a intentarlo' },
|
||||
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry', ja: '問題が発生しました。後でもう一度お試しください', ko: '문제가 발생했습니다. 잠시 후 다시 시도하세요', ru: 'Что-то пошло не так, повторите попытку', es: 'Algo salió mal, vuelve a intentarlo' },
|
||||
};
|
||||
|
||||
/** 把任意错误转成当前语言文案(后端文案优先,再查表,最后 unknown) */
|
||||
/** 把任意错误转成当前语言文案(按 code 查表→后端文案 en 优先→通用兜底) */
|
||||
export function bilingual(err: unknown, lang: Lang): string {
|
||||
if (err instanceof ApiError) {
|
||||
const fromServer = lang === 'zh' ? err.message_zh : err.message_en;
|
||||
if (fromServer) return fromServer;
|
||||
const m = ERROR_TEXT[err.code];
|
||||
if (m) return m[lang];
|
||||
return m?.[lang] ?? m?.en ?? err.message_en ?? err.message_zh ?? ERROR_TEXT.unknown[lang];
|
||||
}
|
||||
return ERROR_TEXT.unknown[lang];
|
||||
}
|
||||
|
||||
+111
-111
@@ -1,140 +1,140 @@
|
||||
// i18n.ts — 穿山甲 Web 用户中心 · 双语字串(单显,绝不并排)
|
||||
// i18n.ts — 穿山甲 Web 用户中心 · 多语字串(单显,绝不并排)
|
||||
// 直接承袭 design/ui_kits/usercenter/ucparts.jsx 的 UCSTRINGS,并补充设置/设备/TOTP 键。
|
||||
|
||||
export type Lang = 'zh' | 'en';
|
||||
export type Lang = 'zh' | 'en' | 'ja' | 'ko' | 'ru' | 'es';
|
||||
|
||||
type Entry = { zh: string; en: string };
|
||||
type Entry = { zh: string; en: string; ja: string; ko: string; ru: string; es: string };
|
||||
|
||||
export const STRINGS: Record<string, Entry> = {
|
||||
navOverview: { zh: '概览', en: 'Overview' },
|
||||
navSub: { zh: '订阅', en: 'Subscription' },
|
||||
navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy' },
|
||||
navInvite: { zh: '邀请返利', en: 'Referral' },
|
||||
navSettings: { zh: '设置', en: 'Settings' },
|
||||
signOut: { zh: '退出', en: 'Sign out' },
|
||||
navOverview: { zh: '概览', en: 'Overview', ja: '概要', ko: '개요', ru: 'Обзор', es: 'Resumen' },
|
||||
navSub: { zh: '订阅', en: 'Subscription', ja: 'サブスクリプション', ko: '구독', ru: 'Подписка', es: 'Suscripción' },
|
||||
navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy', ja: '引き換え & 購入', ko: '코드 등록 & 구매', ru: 'Активация и покупка', es: 'Canjear y comprar' },
|
||||
navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
|
||||
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account' },
|
||||
loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage' },
|
||||
emailLabel: { zh: '邮箱', en: 'Email' },
|
||||
emailPh: { zh: '你的邮箱地址', en: 'your@email.com' },
|
||||
pwLabel: { zh: '密码', en: 'Password' },
|
||||
pwPh: { zh: '输入密码', en: 'Enter password' },
|
||||
doLogin: { zh: '登录', en: 'Log in' },
|
||||
forgotPw: { zh: '忘记密码?', en: 'Forgot password?' },
|
||||
noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app' },
|
||||
loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
|
||||
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
||||
loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage', ja: 'サブスクの管理、コードの引き換え、使用状況の確認', ko: '구독 관리, 코드 등록, 사용량 확인', ru: 'Управление подпиской, активация кодов, статистика использования', es: 'Gestiona tu suscripción, canjea códigos y consulta el uso' },
|
||||
emailLabel: { zh: '邮箱', en: 'Email', ja: 'メールアドレス', ko: '이메일', ru: 'Эл. почта', es: 'Correo electrónico' },
|
||||
emailPh: { zh: '你的邮箱地址', en: 'your@email.com', ja: 'your@email.com', ko: 'your@email.com', ru: 'your@email.com', es: 'your@email.com' },
|
||||
pwLabel: { zh: '密码', en: 'Password', ja: 'パスワード', ko: '비밀번호', ru: 'Пароль', es: 'Contraseña' },
|
||||
pwPh: { zh: '输入密码', en: 'Enter password', ja: 'パスワードを入力', ko: '비밀번호 입력', ru: 'Введите пароль', es: 'Introduce la contraseña' },
|
||||
doLogin: { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
||||
forgotPw: { zh: '忘记密码?', en: 'Forgot password?', ja: 'パスワードをお忘れですか?', ko: '비밀번호를 잊으셨나요?', ru: 'Забыли пароль?', es: '¿Olvidaste tu contraseña?' },
|
||||
noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app', ja: 'アカウントがない場合はアプリで登録', ko: '계정이 없나요? 앱에서 가입하세요', ru: 'Нет аккаунта? Зарегистрируйтесь в приложении', es: '¿No tienes cuenta? Regístrate en la app' },
|
||||
loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password', ja: 'メールアドレスまたはパスワードが違います', ko: '이메일 또는 비밀번호가 올바르지 않습니다', ru: 'Неверная почта или пароль', es: 'Correo o contraseña incorrectos' },
|
||||
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked', ja: '試行回数が多すぎます — アカウントを一時的にロックしました', ko: '시도 횟수가 너무 많습니다 — 계정이 일시적으로 잠겼습니다', ru: 'Слишком много попыток — аккаунт временно заблокирован', es: 'Demasiados intentos: la cuenta está bloqueada temporalmente' },
|
||||
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s', ja: '{s} 秒後に再試行してください', ko: '{s}초 후에 다시 시도하세요', ru: 'Повторите через {s} с', es: 'Vuelve a intentarlo en {s}s' },
|
||||
|
||||
/* sso(App→网页免登录落地页) */
|
||||
ssoTitle: { zh: '正在登录…', en: 'Signing in…' },
|
||||
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' },
|
||||
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' },
|
||||
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' },
|
||||
ssoTitle: { zh: '正在登录…', en: 'Signing in…', ja: 'ログイン中…', ko: '로그인 중…', ru: 'Вход…', es: 'Iniciando sesión…' },
|
||||
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app', ja: 'アプリからのログインチケットを確認しています', ko: '앱에서 받은 로그인 티켓을 확인하는 중', ru: 'Проверяем токен входа из приложения', es: 'Verificando el ticket de inicio de sesión de la app' },
|
||||
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed', ja: 'ログインのリダイレクトに失敗しました', ko: '로그인 리디렉션에 실패했습니다', ru: 'Не удалось выполнить переход для входа', es: 'Error en la redirección de inicio de sesión' },
|
||||
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually', ja: 'ログインページに移動します。手動でログインしてください', ko: '로그인 페이지로 이동합니다. 직접 로그인해 주세요', ru: 'Перенаправляем на страницу входа, войдите вручную', es: 'Redirigiendo a la página de inicio de sesión, inicia sesión manualmente' },
|
||||
|
||||
/* overview */
|
||||
greeting: { zh: '欢迎回来', en: 'Welcome back' },
|
||||
curPlan: { zh: '当前套餐', en: 'Current plan' },
|
||||
freePlan: { zh: '免费版', en: 'Free' },
|
||||
proMember: { zh: 'PRO 会员', en: 'PRO member' },
|
||||
expires: { zh: '有效期至', en: 'Expires' },
|
||||
renew: { zh: '续费 / 升级', en: 'Renew / Upgrade' },
|
||||
quotaToday: { zh: '今日剩余时长', en: 'Time left today' },
|
||||
dataToday: { zh: '今日已用流量', en: 'Data used today' },
|
||||
devices: { zh: '在线设备', en: 'Devices online' },
|
||||
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day' },
|
||||
usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)' },
|
||||
quickSub: { zh: '快速操作', en: 'Quick actions' },
|
||||
qaSub: { zh: '获取订阅链接', en: 'Get subscription' },
|
||||
qaRedeem: { zh: '兑换激活码', en: 'Redeem a code' },
|
||||
qaApp: { zh: '下载 App', en: 'Download apps' },
|
||||
greeting: { zh: '欢迎回来', en: 'Welcome back', ja: 'おかえりなさい', ko: '다시 오신 것을 환영합니다', ru: 'С возвращением', es: 'Bienvenido de nuevo' },
|
||||
curPlan: { zh: '当前套餐', en: 'Current plan', ja: '現在のプラン', ko: '현재 요금제', ru: 'Текущий тариф', es: 'Plan actual' },
|
||||
freePlan: { zh: '免费版', en: 'Free', ja: '無料版', ko: '무료', ru: 'Бесплатный', es: 'Gratis' },
|
||||
proMember: { zh: 'PRO 会员', en: 'PRO member', ja: 'PRO会員', ko: 'PRO 회원', ru: 'PRO-участник', es: 'Miembro PRO' },
|
||||
expires: { zh: '有效期至', en: 'Expires', ja: '有効期限', ko: '만료일', ru: 'Действует до', es: 'Caduca' },
|
||||
renew: { zh: '续费 / 升级', en: 'Renew / Upgrade', ja: '更新 / アップグレード', ko: '갱신 / 업그레이드', ru: 'Продлить / Улучшить', es: 'Renovar / Mejorar' },
|
||||
quotaToday: { zh: '今日剩余时长', en: 'Time left today', ja: '本日の残り時間', ko: '오늘 남은 시간', ru: 'Осталось времени сегодня', es: 'Tiempo restante hoy' },
|
||||
dataToday: { zh: '今日已用流量', en: 'Data used today', ja: '本日の使用データ量', ko: '오늘 사용한 데이터', ru: 'Трафик за сегодня', es: 'Datos usados hoy' },
|
||||
devices: { zh: '在线设备', en: 'Devices online', ja: 'オンラインのデバイス', ko: '온라인 기기', ru: 'Устройств онлайн', es: 'Dispositivos en línea' },
|
||||
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day', ja: '無料版 · 1日10分', ko: '무료 · 하루 10분', ru: 'Бесплатно · 10 мин/день', es: 'Gratis · 10 min/día' },
|
||||
usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)', ja: '過去7日間 (GB)', ko: '최근 7일 (GB)', ru: 'За 7 дней (ГБ)', es: 'Últimos 7 días (GB)' },
|
||||
quickSub: { zh: '快速操作', en: 'Quick actions', ja: 'クイック操作', ko: '빠른 작업', ru: 'Быстрые действия', es: 'Acciones rápidas' },
|
||||
qaSub: { zh: '获取订阅链接', en: 'Get subscription', ja: 'サブスクリプションを取得', ko: '구독 가져오기', ru: 'Получить подписку', es: 'Obtener suscripción' },
|
||||
qaRedeem: { zh: '兑换激活码', en: 'Redeem a code', ja: 'コードを引き換える', ko: '코드 등록', ru: 'Активировать код', es: 'Canjear un código' },
|
||||
qaApp: { zh: '下载 App', en: 'Download apps', ja: 'アプリをダウンロード', ko: '앱 다운로드', ru: 'Скачать приложения', es: 'Descargar apps' },
|
||||
|
||||
/* subscription */
|
||||
subTitle: { zh: '我的订阅', en: 'My subscription' },
|
||||
subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.' },
|
||||
subCopy: { zh: '复制链接', en: 'Copy link' },
|
||||
subCopied: { zh: '已复制', en: 'Copied' },
|
||||
subReset: { zh: '重置链接', en: 'Reset link' },
|
||||
subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices' },
|
||||
subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices' },
|
||||
scanTitle: { zh: '扫码导入', en: 'Scan to import' },
|
||||
scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client' },
|
||||
importTitle: { zh: '一键导入三方客户端', en: 'One-tap import' },
|
||||
importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.' },
|
||||
fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.' },
|
||||
subTitle: { zh: '我的订阅', en: 'My subscription', ja: 'マイサブスクリプション', ko: '내 구독', ru: 'Моя подписка', es: 'Mi suscripción' },
|
||||
subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.', ja: 'サブスクリプションリンクはあなた専用の認証情報です。他人と共有しないでください。', ko: '구독 링크는 개인 인증 정보입니다. 절대 공유하지 마세요.', ru: 'Ссылка на подписку — ваш личный ключ доступа. Никому не передавайте её.', es: 'Tu enlace de suscripción es una credencial privada. Nunca lo compartas.' },
|
||||
subCopy: { zh: '复制链接', en: 'Copy link', ja: 'リンクをコピー', ko: '링크 복사', ru: 'Копировать ссылку', es: 'Copiar enlace' },
|
||||
subCopied: { zh: '已复制', en: 'Copied', ja: 'コピーしました', ko: '복사됨', ru: 'Скопировано', es: 'Copiado' },
|
||||
subReset: { zh: '重置链接', en: 'Reset link', ja: 'リンクをリセット', ko: '링크 재설정', ru: 'Сбросить ссылку', es: 'Restablecer enlace' },
|
||||
subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices', ja: '古いリンクは即座に無効になり、すべてのデバイスで再インポートが必要です', ko: '기존 링크는 즉시 만료되며 모든 기기에서 다시 가져와야 합니다', ru: 'Старая ссылка перестанет работать; переимпортируйте на всех устройствах', es: 'El enlace anterior deja de funcionar; vuelve a importarlo en todos los dispositivos' },
|
||||
subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices', ja: 'リセットしました。デバイスで再インポートしてください', ko: '재설정됨 — 기기에서 다시 가져오세요', ru: 'Сброшено — переимпортируйте на устройствах', es: 'Restablecido: vuelve a importarlo en tus dispositivos' },
|
||||
scanTitle: { zh: '扫码导入', en: 'Scan to import', ja: 'スキャンしてインポート', ko: '스캔하여 가져오기', ru: 'Сканировать для импорта', es: 'Escanear para importar' },
|
||||
scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client', ja: 'Pangolinアプリまたはサードパーティクライアントでスキャン', ko: 'Pangolin 앱 또는 서드파티 클라이언트로 스캔하세요', ru: 'Сканируйте в приложении Pangolin или стороннем клиенте', es: 'Escanea con la app Pangolin o un cliente de terceros' },
|
||||
importTitle: { zh: '一键导入三方客户端', en: 'One-tap import', ja: 'ワンタップインポート', ko: '원탭 가져오기', ru: 'Импорт в одно касание', es: 'Importar con un toque' },
|
||||
importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.', ja: '対応クライアントがインストール済みなら、タップするだけでサブスクリプションが自動インポートされます。', ko: '해당 클라이언트가 설치되어 있으면 탭 한 번으로 구독이 자동 가져오기 됩니다.', ru: 'Если клиент установлен, нажатие автоматически импортирует подписку.', es: 'Si el cliente está instalado, al tocar se importa la suscripción automáticamente.' },
|
||||
fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.', ja: '1つのリンクで sing-box / Clash.Meta / v2ray 形式に対応します。', ko: '하나의 링크로 sing-box / Clash.Meta / v2ray 형식을 모두 지원합니다.', ru: 'Одна ссылка работает с форматами sing-box / Clash.Meta / v2ray.', es: 'Un mismo enlace sirve para los formatos sing-box / Clash.Meta / v2ray.' },
|
||||
|
||||
/* redeem */
|
||||
redeemTitle: { zh: '兑换激活码', en: 'Redeem a code' },
|
||||
redeemPh: { zh: '输入激活码', en: 'Enter activation code' },
|
||||
redeemBtn: { zh: '激活', en: 'Redeem' },
|
||||
redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied' },
|
||||
buyTitle: { zh: '购买渠道', en: 'Where to buy' },
|
||||
buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:' },
|
||||
chStore: { zh: '自助发卡商店', en: 'Self-serve store' },
|
||||
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code' },
|
||||
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code' },
|
||||
chEmail: { zh: '邮箱客服', en: 'Email support' },
|
||||
redeemTitle: { zh: '兑换激活码', en: 'Redeem a code', ja: 'コードを引き換える', ko: '코드 등록', ru: 'Активировать код', es: 'Canjear un código' },
|
||||
redeemPh: { zh: '输入激活码', en: 'Enter activation code', ja: 'アクティベーションコードを入力', ko: '인증 코드 입력', ru: 'Введите код активации', es: 'Introduce el código de activación' },
|
||||
redeemBtn: { zh: '激活', en: 'Redeem', ja: '引き換える', ko: '등록', ru: 'Активировать', es: 'Canjear' },
|
||||
redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied', ja: 'アクティベート完了 — プランが適用されました', ko: '활성화 완료 — 요금제가 적용되었습니다', ru: 'Активировано — тариф применён', es: 'Activado: plan aplicado' },
|
||||
buyTitle: { zh: '购买渠道', en: 'Where to buy', ja: '購入方法', ko: '구매처', ru: 'Где купить', es: 'Dónde comprar' },
|
||||
buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:', ja: '当サイトでは直接お支払いを受け付けていません。以下のチャネルでコードを購入し、このページで引き換えてください:', ko: '본 사이트에서는 직접 결제를 받지 않습니다. 아래 채널에서 코드를 구매한 뒤 이 페이지에서 등록하세요:', ru: 'Мы не принимаем оплату на этом сайте. Купите код через один из каналов ниже и активируйте его здесь:', es: 'No aceptamos pagos en este sitio. Compra un código por uno de los canales de abajo y canjéalo aquí:' },
|
||||
chStore: { zh: '自助发卡商店', en: 'Self-serve store', ja: 'セルフサービスストア', ko: '셀프 서비스 스토어', ru: 'Магазин самообслуживания', es: 'Tienda de autoservicio' },
|
||||
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code', ja: 'Alipay / WeChat · コード即時発行', ko: 'Alipay / WeChat · 즉시 발급', ru: 'Alipay / WeChat · мгновенная выдача кода', es: 'Alipay / WeChat · código al instante' },
|
||||
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code', ja: 'オンチェーン送金 · 最もプライベート · コード即時発行', ko: '온체인 송금 · 가장 프라이빗 · 즉시 발급', ru: 'Ончейн-перевод · максимальная приватность · мгновенная выдача кода', es: 'En cadena · máxima privacidad · código al instante' },
|
||||
chEmail: { zh: '邮箱客服', en: 'Email support', ja: 'メールサポート', ko: '이메일 고객지원', ru: 'Поддержка по эл. почте', es: 'Soporte por correo' },
|
||||
|
||||
/* invite */
|
||||
inviteTitle: { zh: '邀请返利', en: 'Referral' },
|
||||
inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.' },
|
||||
inviteLink: { zh: '我的邀请链接', en: 'My invite link' },
|
||||
invited: { zh: '已邀请', en: 'Invited' },
|
||||
paidUsers: { zh: '已付费', en: 'Converted' },
|
||||
earned: { zh: '累计返利', en: 'Credit earned' },
|
||||
inviteRecord: { zh: '邀请记录', en: 'History' },
|
||||
recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet' },
|
||||
recRegistered: { zh: '已注册', en: 'Registered' },
|
||||
recPaid: { zh: '已付费', en: 'Paid' },
|
||||
inviteTitle: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
|
||||
inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.', ja: '友達があなたのリンクから登録して支払うと、初回注文の20%がクレジットとして還元されます。', ko: '친구가 내 링크로 가입하고 결제하면 첫 주문 금액의 20%를 크레딧으로 받습니다.', ru: 'Когда друг регистрируется и платит по вашей ссылке, вы получаете 20% его первого заказа в виде бонуса.', es: 'Cuando un amigo se registra y paga con tu enlace, ganas el 20% de su primer pedido como saldo.' },
|
||||
inviteLink: { zh: '我的邀请链接', en: 'My invite link', ja: 'マイ紹介リンク', ko: '내 초대 링크', ru: 'Моя реферальная ссылка', es: 'Mi enlace de invitación' },
|
||||
invited: { zh: '已邀请', en: 'Invited', ja: '招待済み', ko: '초대함', ru: 'Приглашено', es: 'Invitados' },
|
||||
paidUsers: { zh: '已付费', en: 'Converted', ja: '成約', ko: '전환됨', ru: 'Оплатили', es: 'Convertidos' },
|
||||
earned: { zh: '累计返利', en: 'Credit earned', ja: '獲得クレジット', ko: '적립 크레딧', ru: 'Начислено бонусов', es: 'Saldo ganado' },
|
||||
inviteRecord: { zh: '邀请记录', en: 'History', ja: '履歴', ko: '내역', ru: 'История', es: 'Historial' },
|
||||
recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet', ja: 'まだ紹介はありません', ko: '아직 추천 내역이 없습니다', ru: 'Пока нет рефералов', es: 'Aún no hay referidos' },
|
||||
recRegistered: { zh: '已注册', en: 'Registered', ja: '登録済み', ko: '가입함', ru: 'Зарегистрирован', es: 'Registrado' },
|
||||
recPaid: { zh: '已付费', en: 'Paid', ja: '支払い済み', ko: '결제함', ru: 'Оплачено', es: 'Pagado' },
|
||||
|
||||
/* 2FA login step */
|
||||
twoFATitle: { zh: '双重认证', en: 'Two-factor auth' },
|
||||
twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.' },
|
||||
twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in' },
|
||||
twoFABack: { zh: '返回上一步', en: 'Back' },
|
||||
twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
|
||||
twoFATitle: { zh: '双重认证', en: 'Two-factor auth', ja: '二要素認証', ko: '2단계 인증', ru: 'Двухфакторная аутентификация', es: 'Verificación en dos pasos' },
|
||||
twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.', ja: 'このアカウントでは二要素認証が有効です。認証アプリの6桁コードを入力してください。', ko: '이 계정은 2단계 인증이 켜져 있습니다. 인증 앱의 6자리 코드를 입력하세요.', ru: 'Для этого аккаунта включена двухфакторная аутентификация. Введите 6-значный код из приложения-аутентификатора.', es: 'La verificación en dos pasos está activada en esta cuenta. Introduce el código de 6 dígitos de tu app de autenticación.' },
|
||||
twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in', ja: '確認してログイン', ko: '인증 후 로그인', ru: 'Подтвердить и войти', es: 'Verificar e iniciar sesión' },
|
||||
twoFABack: { zh: '返回上一步', en: 'Back', ja: '戻る', ko: '뒤로', ru: 'Назад', es: 'Atrás' },
|
||||
twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again', ja: 'コードが正しくありません。もう一度お試しください', ko: '코드가 올바르지 않습니다. 다시 시도하세요', ru: 'Неверный код, попробуйте снова', es: 'Código no válido, inténtalo de nuevo' },
|
||||
|
||||
/* settings */
|
||||
settingsTitle: { zh: '设置', en: 'Settings' },
|
||||
prefTitle: { zh: '偏好', en: 'Preferences' },
|
||||
prefLang: { zh: '显示语言', en: 'Language' },
|
||||
prefTheme: { zh: '外观主题', en: 'Theme' },
|
||||
themeLight: { zh: '浅色', en: 'Light' },
|
||||
themeDark: { zh: '深色', en: 'Dark' },
|
||||
settingsTitle: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||
prefTitle: { zh: '偏好', en: 'Preferences', ja: '環境設定', ko: '환경설정', ru: 'Параметры', es: 'Preferencias' },
|
||||
prefLang: { zh: '显示语言', en: 'Language', ja: '表示言語', ko: '언어', ru: 'Язык', es: 'Idioma' },
|
||||
prefTheme: { zh: '外观主题', en: 'Theme', ja: 'テーマ', ko: '테마', ru: 'Тема', es: 'Tema' },
|
||||
themeLight: { zh: '浅色', en: 'Light', ja: 'ライト', ko: '라이트', ru: 'Светлая', es: 'Claro' },
|
||||
themeDark: { zh: '深色', en: 'Dark', ja: 'ダーク', ko: '다크', ru: 'Тёмная', es: 'Oscuro' },
|
||||
|
||||
/* devices */
|
||||
devTitle: { zh: '设备管理', en: 'Devices' },
|
||||
devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.' },
|
||||
devCurrent: { zh: '当前设备', en: 'This device' },
|
||||
devLastActive: { zh: '最近活跃', en: 'Last active' },
|
||||
devRemove: { zh: '移除', en: 'Remove' },
|
||||
devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?' },
|
||||
devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.' },
|
||||
devEmpty: { zh: '暂无其他设备', en: 'No devices yet' },
|
||||
cancel: { zh: '取消', en: 'Cancel' },
|
||||
confirm: { zh: '确认移除', en: 'Remove' },
|
||||
devTitle: { zh: '设备管理', en: 'Devices', ja: 'デバイス', ko: '기기', ru: 'Устройства', es: 'Dispositivos' },
|
||||
devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.', ja: 'このアカウントにログイン中のデバイスです。削除するとそのデバイスはログアウトされます。', ko: '이 계정에 로그인된 기기입니다. 제거하면 해당 기기는 로그아웃됩니다.', ru: 'Устройства, вошедшие в ваш аккаунт. Удаление приведёт к выходу из аккаунта на устройстве.', es: 'Dispositivos con sesión iniciada en tu cuenta. Al quitar uno, se cierra su sesión.' },
|
||||
devCurrent: { zh: '当前设备', en: 'This device', ja: 'このデバイス', ko: '현재 기기', ru: 'Это устройство', es: 'Este dispositivo' },
|
||||
devLastActive: { zh: '最近活跃', en: 'Last active', ja: '最終アクティブ', ko: '마지막 활동', ru: 'Последняя активность', es: 'Última actividad' },
|
||||
devRemove: { zh: '移除', en: 'Remove', ja: '削除', ko: '제거', ru: 'Удалить', es: 'Quitar' },
|
||||
devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?', ja: 'このデバイスを削除しますか?', ko: '이 기기를 제거할까요?', ru: 'Удалить это устройство?', es: '¿Quitar este dispositivo?' },
|
||||
devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.', ja: 'このデバイスはログアウトされ、再接続には再認証が必要になります。', ko: '해당 기기는 로그아웃되며 다시 연결하려면 재인증이 필요합니다.', ru: 'Устройство выйдет из аккаунта и должно будет пройти повторную аутентификацию для подключения.', es: 'Se cerrará su sesión y deberá volver a autenticarse para conectarse de nuevo.' },
|
||||
devEmpty: { zh: '暂无其他设备', en: 'No devices yet', ja: '他のデバイスはありません', ko: '다른 기기가 없습니다', ru: 'Пока нет устройств', es: 'Aún no hay dispositivos' },
|
||||
cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル', ko: '취소', ru: 'Отмена', es: 'Cancelar' },
|
||||
confirm: { zh: '确认移除', en: 'Remove', ja: '削除する', ko: '제거', ru: 'Удалить', es: 'Quitar' },
|
||||
|
||||
/* TOTP management */
|
||||
totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)' },
|
||||
totpOff: { zh: '未开启', en: 'Off' },
|
||||
totpOn: { zh: '已开启', en: 'On' },
|
||||
totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.' },
|
||||
totpEnable: { zh: '开启双重认证', en: 'Enable 2FA' },
|
||||
totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA' },
|
||||
totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually' },
|
||||
totpSecretLabel: { zh: '手动密钥', en: 'Manual key' },
|
||||
totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm' },
|
||||
totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable' },
|
||||
totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled' },
|
||||
totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled' },
|
||||
totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.' },
|
||||
copyKey: { zh: '复制密钥', en: 'Copy key' },
|
||||
totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)', ja: '二要素認証 (TOTP)', ko: '2단계 인증 (TOTP)', ru: 'Двухфакторная аутентификация (TOTP)', es: 'Verificación en dos pasos (TOTP)' },
|
||||
totpOff: { zh: '未开启', en: 'Off', ja: 'オフ', ko: '꺼짐', ru: 'Выкл.', es: 'Desactivado' },
|
||||
totpOn: { zh: '已开启', en: 'On', ja: 'オン', ko: '켜짐', ru: 'Вкл.', es: 'Activado' },
|
||||
totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.', ja: '認証アプリのワンタイムコードでログインを保護します。', ko: '인증 앱의 일회용 코드로 로그인을 보호하세요.', ru: 'Защитите вход одноразовыми кодами из приложения-аутентификатора.', es: 'Protege el inicio de sesión con códigos de un solo uso de una app de autenticación.' },
|
||||
totpEnable: { zh: '开启双重认证', en: 'Enable 2FA', ja: '二要素認証を有効にする', ko: '2단계 인증 켜기', ru: 'Включить 2FA', es: 'Activar 2FA' },
|
||||
totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA', ja: '二要素認証を無効にする', ko: '2단계 인증 끄기', ru: 'Отключить 2FA', es: 'Desactivar 2FA' },
|
||||
totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually', ja: '1. 認証アプリでスキャンするか、キーを手動で入力してください', ko: '1. 인증 앱으로 스캔하거나 키를 직접 입력하세요', ru: '1. Отсканируйте в приложении-аутентификаторе или введите ключ вручную', es: '1. Escanea con una app de autenticación o introduce la clave manualmente' },
|
||||
totpSecretLabel: { zh: '手动密钥', en: 'Manual key', ja: '手動入力キー', ko: '수동 키', ru: 'Ключ для ручного ввода', es: 'Clave manual' },
|
||||
totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm', ja: '2. アプリに表示された6桁コードを入力して確認してください', ko: '2. 앱에 표시된 6자리 코드를 입력해 확인하세요', ru: '2. Введите 6-значный код из приложения для подтверждения', es: '2. Introduce el código de 6 dígitos que muestra la app para confirmar' },
|
||||
totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable', ja: '確認して有効にする', ko: '확인 후 켜기', ru: 'Подтвердить и включить', es: 'Confirmar y activar' },
|
||||
totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled', ja: '二要素認証を有効にしました', ko: '2단계 인증이 켜졌습니다', ru: 'Двухфакторная аутентификация включена', es: 'Verificación en dos pasos activada' },
|
||||
totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled', ja: '二要素認証を無効にしました', ko: '2단계 인증이 꺼졌습니다', ru: 'Двухфакторная аутентификация отключена', es: 'Verificación en dos pasos desactivada' },
|
||||
totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.', ja: '二要素認証を無効にするには現在のコードを入力してください。', ko: '2단계 인증을 끄려면 현재 코드를 입력하세요.', ru: 'Введите текущий код, чтобы отключить 2FA.', es: 'Introduce tu código actual para desactivar la 2FA.' },
|
||||
copyKey: { zh: '复制密钥', en: 'Copy key', ja: 'キーをコピー', ko: '키 복사', ru: 'Копировать ключ', es: 'Copiar clave' },
|
||||
|
||||
/* generic errors */
|
||||
errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
loading: { zh: '加载中…', en: 'Loading…' },
|
||||
errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry', ja: 'ネットワークエラーです。後でもう一度お試しください', ko: '네트워크 오류입니다. 잠시 후 다시 시도하세요', ru: 'Ошибка сети, повторите попытку', es: 'Error de red, vuelve a intentarlo' },
|
||||
errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry', ja: '問題が発生しました。後でもう一度お試しください', ko: '문제가 발생했습니다. 잠시 후 다시 시도하세요', ru: 'Что-то пошло не так, повторите попытку', es: 'Algo salió mal, vuelve a intentarlo' },
|
||||
loading: { zh: '加载中…', en: 'Loading…', ja: '読み込み中…', ko: '불러오는 중…', ru: 'Загрузка…', es: 'Cargando…' },
|
||||
};
|
||||
|
||||
export type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
@@ -142,7 +142,7 @@ export type TFn = (key: string, vars?: Record<string, string | number>) => strin
|
||||
export function makeT(lang: Lang): TFn {
|
||||
return (key, vars) => {
|
||||
const e = STRINGS[key];
|
||||
let s = e ? e[lang] || key : key;
|
||||
let s = e ? e[lang] || e.en || key : key;
|
||||
if (vars) for (const k of Object.keys(vars)) s = s.replace(`{${k}}`, String(vars[k]));
|
||||
return s;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const LANG_KEY = 'pg_uc_lang';
|
||||
const THEME_KEY = 'pg_uc_theme';
|
||||
|
||||
export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>('zh');
|
||||
const [lang, setLangState] = useState<Lang>('en'); // 默认英文(国际化默认语种)
|
||||
const [theme, setThemeState] = useState<Theme>('light');
|
||||
|
||||
// 挂载后读取持久化(避免 hydration 不一致)
|
||||
@@ -26,7 +26,7 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
|
||||
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (l === 'zh' || l === 'en') setLangState(l);
|
||||
if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l);
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
setThemeState(initial);
|
||||
} catch {
|
||||
@@ -36,7 +36,7 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh' : 'en');
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
}, [theme, lang]);
|
||||
|
||||
const setLang = useCallback((l: Lang) => {
|
||||
|
||||
@@ -5,6 +5,10 @@ const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
trailingSlash: true,
|
||||
// 用户中心从独立子域 app.yanmeiai.com 迁到主站子路径:与官网合并部署到同一
|
||||
// CF Pages(pangolin-site),挂在 pangolin.yanmeiai.com/user/。basePath 让所有
|
||||
// 路由/资源前缀带 /user;可用 NEXT_PUBLIC_BASE_PATH 覆盖(如本地 dev 置空)。
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '/user',
|
||||
// 静态导出无图片优化服务,关闭以保证产物无服务端依赖。
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
@@ -6,12 +6,12 @@ import { SITE } from '../config/site';
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。
|
||||
// Android + Windows 已由客户端 CI 产出真实产物并部署到 /downloads。
|
||||
// href 缺省 = 本轮未接入下载(iOS 走 TestFlight / Linux),按钮渲染为禁用态占位。
|
||||
// Android + Windows + macOS 已由客户端 CI 产出真实产物并部署到 /downloads。
|
||||
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+', href: SITE.downloads.macos },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
|
||||
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||
];
|
||||
|
||||
@@ -44,6 +44,13 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
['#docs', t.docs],
|
||||
];
|
||||
|
||||
// 6 语言:默认 en=/,其余 /<lang>/。段控横排会挤,用原生下拉。
|
||||
const langs = [
|
||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||
];
|
||||
const langHref = (l) => (l === 'en' ? '/' : `/${l}/`);
|
||||
|
||||
return (
|
||||
<header class={`hdr${scrolled ? ' scrolled' : ''}`}>
|
||||
<div class="wrap row">
|
||||
@@ -57,10 +64,16 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<div class="langseg">
|
||||
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
|
||||
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
|
||||
</div>
|
||||
<select
|
||||
class="langsel"
|
||||
value={lang}
|
||||
onChange={(e) => { window.location.href = langHref(e.target.value); }}
|
||||
aria-label="Language"
|
||||
>
|
||||
{langs.map(([code, label]) => (
|
||||
<option key={code} value={code}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
|
||||
@@ -21,16 +21,18 @@ export const SITE = {
|
||||
store: { label: 'shop.pangolin.vpn' },
|
||||
|
||||
/** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
|
||||
usercenter: 'https://app.yanmeiai.com',
|
||||
usercenter: 'https://pangolin.yanmeiai.com/user/',
|
||||
|
||||
/**
|
||||
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
||||
* /downloads/<file>(origin = 127.0.0.1:8080),文件由
|
||||
* scripts/ci/deploy-client.sh 每次构建 scp 覆盖到 pangolin1:/var/lib/pangolin/downloads/,
|
||||
* 每平台固定文件名、只保留最新一份。iOS / macOS / Linux 本轮未接入下载按钮。
|
||||
* 每平台固定文件名、只保留最新一份。macOS 自 client-v1.0.59 起接入;
|
||||
* iOS 走 TestFlight(无直接下载文件)、Linux 本轮未接入下载按钮。
|
||||
*/
|
||||
downloads: {
|
||||
android: 'https://api.yanmeiai.com/downloads/pangolin-android.apk',
|
||||
windows: 'https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe',
|
||||
macos: 'https://api.yanmeiai.com/downloads/pangolin-macos-x64.zip',
|
||||
},
|
||||
} as const;
|
||||
|
||||
+191
-170
@@ -1,201 +1,202 @@
|
||||
// strings.ts — 官网 i18n 字典(中 / EN)
|
||||
// strings.ts — 官网 i18n 字典(中 / EN / 日 / 韩 / 俄 / 西 六语)
|
||||
// 文案逐字沿用 design/ui_kits/website/site.js 的 I18N(已脱敏,符合 design/CLAUDE.md 铁律 13)。
|
||||
// 与原型不同的是:这里是「构建期」按路由整页单显(zh = /,en = /en/),不在运行时 JS 切换文本。
|
||||
// zh/en 原样保留;ja/ko/ru/es 依据 English 自然本地化(营销官网口吻,非机翻)。
|
||||
// 与原型不同的是:这里是「构建期」按路由整页单显,不在运行时 JS 切换文本。
|
||||
|
||||
export type Lang = 'zh' | 'en';
|
||||
export type Lang = 'zh' | 'en' | 'ja' | 'ko' | 'ru' | 'es';
|
||||
|
||||
// 每条 = [中文, English]
|
||||
export const STRINGS: Record<string, [string, string]> = {
|
||||
'ann.txt': ['新用户注册即享 7 天免费试用', 'New users get a 7-day free trial'],
|
||||
'ann.cta': ['立即注册 →', 'Sign up →'],
|
||||
// 每条 = { zh, en, ja, ko, ru, es }
|
||||
export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'ann.txt': { zh: '新用户注册即享 7 天免费试用', en: 'New users get a 7-day free trial', ja: '新規登録で7日間の無料トライアル', ko: '신규 가입 시 7일 무료 체험', ru: 'Новым пользователям — 7 дней бесплатно', es: 'Los nuevos usuarios reciben 7 días de prueba gratis' },
|
||||
'ann.cta': { zh: '立即注册 →', en: 'Sign up →', ja: '今すぐ登録 →', ko: '지금 가입 →', ru: 'Зарегистрироваться →', es: 'Registrarse →' },
|
||||
|
||||
'su.ph': ['输入邮箱,免费注册', 'Enter email to sign up free'],
|
||||
'su.btn': ['免费注册', 'Sign up free'],
|
||||
'su.ok': ['验证码已发送!下载 App 并用同一邮箱继续完成注册。', 'Code sent! Download the app and continue with the same email.'],
|
||||
'su.hint': ['免费注册 · 无需付款 · 7 天免费试用', 'Free to join · no payment · 7-day free trial'],
|
||||
'su.dl': ['或直接下载 App', 'or just download the app'],
|
||||
'su.ph': { zh: '输入邮箱,免费注册', en: 'Enter email to sign up free', ja: 'メールアドレスで無料登録', ko: '이메일로 무료 가입', ru: 'Введите e-mail для бесплатной регистрации', es: 'Introduce tu correo para registrarte gratis' },
|
||||
'su.btn': { zh: '免费注册', en: 'Sign up free', ja: '無料で登録', ko: '무료 가입', ru: 'Зарегистрироваться бесплатно', es: 'Registrarse gratis' },
|
||||
'su.ok': { zh: '验证码已发送!下载 App 并用同一邮箱继续完成注册。', en: 'Code sent! Download the app and continue with the same email.', ja: '認証コードを送信しました!アプリをダウンロードし、同じメールアドレスで登録を続けてください。', ko: '인증 코드를 보냈습니다! 앱을 내려받아 같은 이메일로 가입을 이어가세요.', ru: 'Код отправлен! Скачайте приложение и продолжите с той же почтой.', es: '¡Código enviado! Descarga la app y continúa con el mismo correo.' },
|
||||
'su.hint': { zh: '免费注册 · 无需付款 · 7 天免费试用', en: 'Free to join · no payment · 7-day free trial', ja: '登録無料 · 支払い不要 · 7日間無料トライアル', ko: '가입 무료 · 결제 불필요 · 7일 무료 체험', ru: 'Регистрация бесплатна · без оплаты · 7 дней бесплатно', es: 'Registro gratis · sin pago · 7 días de prueba' },
|
||||
'su.dl': { zh: '或直接下载 App', en: 'or just download the app', ja: 'アプリを直接ダウンロード', ko: '또는 앱 바로 내려받기', ru: 'или просто скачайте приложение', es: 'o simplemente descarga la app' },
|
||||
|
||||
'why.eyebrow': ['免费注册', 'Sign up'],
|
||||
'why.h': ['注册一次,处处可用', 'One account, everywhere'],
|
||||
'why.sub': ['一个邮箱就能开始。注册免费,不需要任何付款信息。', 'All you need is an email. Free to join — no payment details, ever.'],
|
||||
'why.1t': ['7 天免费试用', '7-day free trial'],
|
||||
'why.1d': ['新用户注册即享 7 天免费使用,无需任何付款信息。', 'New accounts get 7 days of free, unrestricted use — no payment details.'],
|
||||
'why.2t': ['多端同步', 'Multi-device sync'],
|
||||
'why.2d': ['手机、平板、电脑共用一个账户,套餐与设置随身走。', 'Phone, tablet and desktop share one account — your plan follows you.'],
|
||||
'why.3t': ['无需付款信息', 'No payment details'],
|
||||
'why.3d': ['注册只要邮箱。想升级时,再通过外部渠道兑换激活码。', 'Just an email to join. Upgrade later with an activation code.'],
|
||||
'why.s1': ['填写邮箱', 'Enter email'],
|
||||
'why.s2': ['输入验证码', 'Verify code'],
|
||||
'why.s3': ['开始畅连', 'Start connecting'],
|
||||
'why.cta': ['立即免费注册', 'Sign up free'],
|
||||
'why.eyebrow': { zh: '免费注册', en: 'Sign up', ja: '無料登録', ko: '가입하기', ru: 'Регистрация', es: 'Registrarse' },
|
||||
'why.h': { zh: '注册一次,处处可用', en: 'One account, everywhere', ja: '一つのアカウントで、どこでも', ko: '하나의 계정으로 어디서나', ru: 'Один аккаунт — везде', es: 'Una cuenta, en todas partes' },
|
||||
'why.sub': { zh: '一个邮箱就能开始。注册免费,不需要任何付款信息。', en: 'All you need is an email. Free to join — no payment details, ever.', ja: '必要なのはメールアドレスだけ。登録は無料、支払い情報は一切不要です。', ko: '필요한 건 이메일 하나뿐. 가입은 무료이며 결제 정보도 필요 없습니다.', ru: 'Нужна лишь почта. Регистрация бесплатна — платёжные данные не требуются.', es: 'Solo necesitas un correo. Registro gratis, sin datos de pago nunca.' },
|
||||
'why.1t': { zh: '7 天免费试用', en: '7-day free trial', ja: '7日間無料トライアル', ko: '7일 무료 체험', ru: '7 дней бесплатно', es: '7 días de prueba gratis' },
|
||||
'why.1d': { zh: '新用户注册即享 7 天免费使用,无需任何付款信息。', en: 'New accounts get 7 days of free, unrestricted use — no payment details.', ja: '新規アカウントは7日間、制限なしで無料利用。支払い情報は不要です。', ko: '신규 계정은 7일간 제한 없이 무료로 이용, 결제 정보도 필요 없습니다.', ru: 'Новые аккаунты — 7 дней свободного использования без ограничений и без платёжных данных.', es: 'Las cuentas nuevas disfrutan 7 días de uso libre y sin límites, sin datos de pago.' },
|
||||
'why.2t': { zh: '多端同步', en: 'Multi-device sync', ja: 'マルチデバイス同期', ko: '여러 기기 동기화', ru: 'Синхронизация устройств', es: 'Sincronización multidispositivo' },
|
||||
'why.2d': { zh: '手机、平板、电脑共用一个账户,套餐与设置随身走。', en: 'Phone, tablet and desktop share one account — your plan follows you.', ja: 'スマホ・タブレット・PCで一つのアカウントを共有。プランも設定も一緒に持ち運べます。', ko: '휴대폰, 태블릿, PC가 하나의 계정을 공유해 요금제와 설정이 따라다닙니다.', ru: 'Телефон, планшет и компьютер — один аккаунт, а тариф всегда с вами.', es: 'Teléfono, tableta y ordenador comparten una cuenta; tu plan te acompaña.' },
|
||||
'why.3t': { zh: '无需付款信息', en: 'No payment details', ja: '支払い情報は不要', ko: '결제 정보 불필요', ru: 'Без платёжных данных', es: 'Sin datos de pago' },
|
||||
'why.3d': { zh: '注册只要邮箱。想升级时,再通过外部渠道兑换激活码。', en: 'Just an email to join. Upgrade later with an activation code.', ja: '登録はメールだけ。アップグレードは後からアクティベーションコードで。', ko: '가입은 이메일만. 업그레이드는 나중에 활성화 코드로.', ru: 'Для входа нужна только почта. Обновиться можно позже кодом активации.', es: 'Solo un correo para unirte. Mejora luego con un código de activación.' },
|
||||
'why.s1': { zh: '填写邮箱', en: 'Enter email', ja: 'メールを入力', ko: '이메일 입력', ru: 'Введите почту', es: 'Introduce el correo' },
|
||||
'why.s2': { zh: '输入验证码', en: 'Verify code', ja: '認証コードを入力', ko: '인증 코드 입력', ru: 'Введите код', es: 'Verifica el código' },
|
||||
'why.s3': { zh: '开始畅连', en: 'Start connecting', ja: '接続を開始', ko: '연결 시작', ru: 'Начните подключение', es: 'Empieza a conectar' },
|
||||
'why.cta': { zh: '立即免费注册', en: 'Sign up free', ja: '今すぐ無料登録', ko: '지금 무료 가입', ru: 'Зарегистрироваться бесплатно', es: 'Registrarse gratis' },
|
||||
|
||||
'cta.btn2': ['免费注册', 'Sign up free'],
|
||||
'cta.btn2': { zh: '免费注册', en: 'Sign up free', ja: '無料で登録', ko: '무료 가입', ru: 'Зарегистрироваться бесплатно', es: 'Registrarse gratis' },
|
||||
|
||||
'nav.product': ['产品', 'Product'],
|
||||
'nav.pricing': ['定价', 'Pricing'],
|
||||
'nav.download': ['下载', 'Download'],
|
||||
'nav.docs': ['文档', 'Docs'],
|
||||
'nav.blog': ['Blog', 'Blog'],
|
||||
'nav.login': ['登录', 'Log in'],
|
||||
'nav.get': ['立即下载', 'Get the app'],
|
||||
'nav.product': { zh: '产品', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' },
|
||||
'nav.pricing': { zh: '定价', en: 'Pricing', ja: '料金', ko: '요금제', ru: 'Цены', es: 'Precios' },
|
||||
'nav.download': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' },
|
||||
'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||
'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
||||
'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
||||
'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' },
|
||||
|
||||
'hero.eyebrow': ['极速 · 稳定 · 省心', 'Fast · Stable · Effortless'],
|
||||
'hero.h1': ['极速畅连,\n网络如丝顺滑', 'Faster, smoother,\neverywhere'],
|
||||
'hero.lede': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'],
|
||||
'hero.cta1': ['免费下载', 'Download free'],
|
||||
'hero.cta2': ['查看定价', 'See pricing'],
|
||||
'hero.t1': ['全球加速线路', 'Global routes'],
|
||||
'hero.t2': ['严格无日志', 'Strict no-logs'],
|
||||
'hero.t3': ['端到端加密', 'End-to-end encrypted'],
|
||||
'orb.cap': ['已连接', 'CONNECTED'],
|
||||
'orb.down': ['下载', 'Down'],
|
||||
'orb.up': ['上传', 'Up'],
|
||||
'orb.node': ['香港 · 流媒体', 'Hong Kong'],
|
||||
'orb.nodesub': ['延迟 18ms · 已连接', '18ms · Connected'],
|
||||
'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' },
|
||||
'hero.h1': { zh: '极速畅连,\n网络如丝顺滑', en: 'Faster, smoother,\neverywhere', ja: 'もっと速く、もっと滑らかに、\nどこでも', ko: '더 빠르고 더 매끄럽게,\n어디서나', ru: 'Быстрее, плавнее,\nвезде', es: 'Más rápido, más fluido,\nen todas partes' },
|
||||
'hero.lede': { zh: '轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', en: 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.', ja: '軽快で使いやすい、クロスプラットフォームのネットワークアクセラレーター。ワンタップ接続、スマートルーティング、安定した通信。', ko: '가볍고 친근한 크로스플랫폼 네트워크 가속 앱. 원 탭 연결, 스마트 라우팅, 흔들림 없는 안정성.', ru: 'Лёгкий и удобный кроссплатформенный ускоритель сети. Одно касание, умная маршрутизация, надёжное соединение.', es: 'Un acelerador de red multiplataforma, ligero y fácil de usar. Un toque, enrutamiento inteligente, total estabilidad.' },
|
||||
'hero.cta1': { zh: '免费下载', en: 'Download free', ja: '無料ダウンロード', ko: '무료 다운로드', ru: 'Скачать бесплатно', es: 'Descargar gratis' },
|
||||
'hero.cta2': { zh: '查看定价', en: 'See pricing', ja: '料金を見る', ko: '요금 보기', ru: 'Посмотреть цены', es: 'Ver precios' },
|
||||
'hero.t1': { zh: '全球加速线路', en: 'Global routes', ja: 'グローバル回線', ko: '글로벌 라우트', ru: 'Глобальные маршруты', es: 'Rutas globales' },
|
||||
'hero.t2': { zh: '严格无日志', en: 'Strict no-logs', ja: '厳格なノーログ', ko: '엄격한 노로그', ru: 'Строго без логов', es: 'Sin registros, estricto' },
|
||||
'hero.t3': { zh: '端到端加密', en: 'End-to-end encrypted', ja: 'エンドツーエンド暗号化', ko: '엔드투엔드 암호화', ru: 'Сквозное шифрование', es: 'Cifrado de extremo a extremo' },
|
||||
'orb.cap': { zh: '已连接', en: 'CONNECTED', ja: '接続済み', ko: '연결됨', ru: 'ПОДКЛЮЧЕНО', es: 'CONECTADO' },
|
||||
'orb.down': { zh: '下载', en: 'Down', ja: '下り', ko: '다운', ru: 'Приём', es: 'Bajada' },
|
||||
'orb.up': { zh: '上传', en: 'Up', ja: '上り', ko: '업', ru: 'Отдача', es: 'Subida' },
|
||||
'orb.node': { zh: '香港 · 流媒体', en: 'Hong Kong', ja: '香港', ko: '홍콩', ru: 'Гонконг', es: 'Hong Kong' },
|
||||
'orb.nodesub': { zh: '延迟 18ms · 已连接', en: '18ms · Connected', ja: '18ms · 接続済み', ko: '18ms · 연결됨', ru: '18 мс · Подключено', es: '18 ms · Conectado' },
|
||||
|
||||
'strip.txt': ['已支持', 'Available on'],
|
||||
'strip.txt': { zh: '已支持', en: 'Available on', ja: '対応プラットフォーム', ko: '지원 플랫폼', ru: 'Доступно на', es: 'Disponible en' },
|
||||
|
||||
'feat.eyebrow': ['产品', 'Product'],
|
||||
'feat.h': ['为日常而生的连接体验', 'Built for everyday connection'],
|
||||
'feat.sub': ['没有复杂配置,没有技术门槛。打开就用,把稳定与隐私留给我们。', 'No complex setup, no jargon. Just open and go — we handle speed and privacy.'],
|
||||
'feat.1t': ['一键连接', 'One-tap connect'],
|
||||
'feat.1d': ['一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。', 'One button picks the fastest route. No manual setup, instant connect.'],
|
||||
'feat.2t': ['智能分流', 'Smart routing'],
|
||||
'feat.2d': ['按规则智能分流,该加速的加速,本地服务直连,互不打扰。', 'Smart rule-based routing — accelerate what needs it, keep local traffic direct.'],
|
||||
'feat.3t': ['全球加速线路', 'Global routes'],
|
||||
'feat.3d': ['覆盖多地优质线路,视频与游戏优化随心选。', 'Premium routes across regions, tuned for video & gaming.'],
|
||||
'feat.4t': ['严格无日志', 'Strict no-logs'],
|
||||
'feat.4d': ['我们不记录你的浏览数据。隐私是底线,不是卖点。', 'We never log your browsing. Privacy is the baseline, not a feature.'],
|
||||
'feat.5t': ['Kill Switch', 'Kill Switch'],
|
||||
'feat.5d': ['一旦连接中断,立即阻断网络,杜绝真实 IP 泄露。', 'If the tunnel drops, traffic is blocked instantly — no IP leaks.'],
|
||||
'feat.6t': ['多端同步', 'Multi-device'],
|
||||
'feat.6d': ['一个账户,手机、平板、电脑同时在线,最多 5 台设备。', 'One account across phone, tablet and desktop — up to 5 devices.'],
|
||||
'feat.eyebrow': { zh: '产品', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' },
|
||||
'feat.h': { zh: '为日常而生的连接体验', en: 'Built for everyday connection', ja: '毎日の接続のために', ko: '일상을 위한 연결 경험', ru: 'Создан для повседневных подключений', es: 'Diseñado para la conexión de cada día' },
|
||||
'feat.sub': { zh: '没有复杂配置,没有技术门槛。打开就用,把稳定与隐私留给我们。', en: 'No complex setup, no jargon. Just open and go — we handle speed and privacy.', ja: '複雑な設定も専門用語も不要。開いて使うだけ。速度とプライバシーはお任せください。', ko: '복잡한 설정도, 어려운 용어도 없습니다. 열면 바로 사용, 속도와 개인정보는 저희가 책임집니다.', ru: 'Никаких сложных настроек и жаргона. Просто откройте и пользуйтесь — скорость и приватность на нас.', es: 'Sin configuraciones complejas ni tecnicismos. Ábrelo y listo: la velocidad y la privacidad corren por nuestra cuenta.' },
|
||||
'feat.1t': { zh: '一键连接', en: 'One-tap connect', ja: 'ワンタップ接続', ko: '원 탭 연결', ru: 'Подключение одним касанием', es: 'Conexión con un toque' },
|
||||
'feat.1d': { zh: '一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。', en: 'One button picks the fastest route. No manual setup, instant connect.', ja: 'ボタン一つで最速回線を自動選択。手動設定なしで瞬時に接続。', ko: '버튼 하나로 가장 빠른 라우트를 자동 선택. 수동 설정 없이 바로 연결.', ru: 'Одна кнопка выбирает самый быстрый маршрут. Без ручных настроек — мгновенное подключение.', es: 'Un botón elige la ruta más rápida. Sin ajustes manuales, conexión instantánea.' },
|
||||
'feat.2t': { zh: '智能分流', en: 'Smart routing', ja: 'スマートルーティング', ko: '스마트 라우팅', ru: 'Умная маршрутизация', es: 'Enrutamiento inteligente' },
|
||||
'feat.2d': { zh: '按规则智能分流,该加速的加速,本地服务直连,互不打扰。', en: 'Smart rule-based routing — accelerate what needs it, keep local traffic direct.', ja: 'ルールベースのスマートルーティングで、必要な通信は高速化、ローカル通信は直結。', ko: '규칙 기반 스마트 라우팅으로 필요한 트래픽은 가속하고 로컬 트래픽은 직접 연결.', ru: 'Умная маршрутизация по правилам: ускоряем нужное, локальный трафик идёт напрямую.', es: 'Enrutamiento inteligente por reglas: acelera lo que lo necesita y deja el tráfico local directo.' },
|
||||
'feat.3t': { zh: '全球加速线路', en: 'Global routes', ja: 'グローバル回線', ko: '글로벌 라우트', ru: 'Глобальные маршруты', es: 'Rutas globales' },
|
||||
'feat.3d': { zh: '覆盖多地优质线路,视频与游戏优化随心选。', en: 'Premium routes across regions, tuned for video & gaming.', ja: '各地の高品質回線を網羅、動画・ゲーム向けに最適化。', ko: '여러 지역의 프리미엄 라우트, 영상과 게임에 맞춰 최적화.', ru: 'Премиум-маршруты по регионам, настроенные для видео и игр.', es: 'Rutas premium en varias regiones, optimizadas para vídeo y juegos.' },
|
||||
'feat.4t': { zh: '严格无日志', en: 'Strict no-logs', ja: '厳格なノーログ', ko: '엄격한 노로그', ru: 'Строго без логов', es: 'Sin registros, estricto' },
|
||||
'feat.4d': { zh: '我们不记录你的浏览数据。隐私是底线,不是卖点。', en: 'We never log your browsing. Privacy is the baseline, not a feature.', ja: '閲覧データは一切記録しません。プライバシーは売り文句ではなく、当然の前提です。', ko: '고객의 브라우징 데이터를 절대 기록하지 않습니다. 개인정보 보호는 기본이지 특별 기능이 아닙니다.', ru: 'Мы не ведём журнал вашего трафика. Приватность — это база, а не опция.', es: 'Nunca registramos tu navegación. La privacidad es la base, no un extra.' },
|
||||
'feat.5t': { zh: 'Kill Switch', en: 'Kill Switch', ja: 'キルスイッチ', ko: '킬 스위치', ru: 'Kill Switch', es: 'Kill Switch' },
|
||||
'feat.5d': { zh: '一旦连接中断,立即阻断网络,杜绝真实 IP 泄露。', en: 'If the tunnel drops, traffic is blocked instantly — no IP leaks.', ja: '接続が切れた瞬間に通信を遮断し、実IPの漏洩を防ぎます。', ko: '터널이 끊기면 즉시 트래픽을 차단해 실제 IP 노출을 막습니다.', ru: 'Если соединение обрывается, трафик мгновенно блокируется — без утечек IP.', es: 'Si la conexión se cae, el tráfico se bloquea al instante: sin filtraciones de IP.' },
|
||||
'feat.6t': { zh: '多端同步', en: 'Multi-device', ja: 'マルチデバイス', ko: '멀티 디바이스', ru: 'Много устройств', es: 'Multidispositivo' },
|
||||
'feat.6d': { zh: '一个账户,手机、平板、电脑同时在线,最多 5 台设备。', en: 'One account across phone, tablet and desktop — up to 5 devices.', ja: '一つのアカウントでスマホ・タブレット・PCを同時に、最大5台まで。', ko: '하나의 계정으로 휴대폰, 태블릿, PC를 동시에, 최대 5대까지.', ru: 'Один аккаунт на телефоне, планшете и компьютере — до 5 устройств.', es: 'Una cuenta en teléfono, tableta y ordenador: hasta 5 dispositivos.' },
|
||||
|
||||
'price.eyebrow': ['定价', 'Pricing'],
|
||||
'price.h': ['简单透明的价格', 'Simple, honest pricing'],
|
||||
'price.sub': ['随时升级或取消。所有套餐均含核心加密与无日志承诺。', 'Upgrade or cancel anytime. Every plan includes core encryption and our no-logs promise.'],
|
||||
'price.monthly': ['按月', 'Monthly'],
|
||||
'price.yearly': ['按年', 'Yearly'],
|
||||
'price.save': ['省 20%', 'Save 20%'],
|
||||
'price.permo': ['/月', '/mo'],
|
||||
'price.free': ['免费版', 'Free'],
|
||||
'price.freedesc': ['适合轻度使用与尝鲜', 'For light, casual use'],
|
||||
'price.pro': ['专业版', 'Pro'],
|
||||
'price.prodesc': ['个人用户的最佳选择', 'Best for individuals'],
|
||||
'price.team': ['团队版', 'Team'],
|
||||
'price.teamdesc': ['小团队集中管理', 'For small teams'],
|
||||
'price.popular': ['最受欢迎', 'Most popular'],
|
||||
'price.cta_free': ['免费下载', 'Download free'],
|
||||
'price.cta_pro': ['获取激活码', 'Get a code'],
|
||||
'price.cta_team': ['获取激活码', 'Get a code'],
|
||||
'pf.free1': ['每日 10 分钟时长', '10 min per day'],
|
||||
'pf.free2': ['仅 1 个基础节点', '1 basic node only'],
|
||||
'pf.free3': ['使用前观看广告', 'Watch an ad to start'],
|
||||
'pf.free4': ['核心加密 · 无日志', 'Core encryption · no-logs'],
|
||||
'pf.pro1': ['全球加速线路', 'Global routes'],
|
||||
'pf.pro2': ['无限流量 · 极速', 'Unlimited · top speed'],
|
||||
'pf.pro3': ['5 台设备同时在线', '5 devices at once'],
|
||||
'pf.pro4': ['流媒体 & P2P 优化', 'Streaming & P2P routes'],
|
||||
'pf.pro5': ['Kill Switch · 智能分流', 'Kill Switch · smart routing'],
|
||||
'pf.team1': ['专业版全部功能', 'Everything in Pro'],
|
||||
'pf.team2': ['10 个成员席位', '10 seats'],
|
||||
'pf.team3': ['集中计费与管理', 'Central billing & admin'],
|
||||
'pf.team4': ['优先客服', 'Priority support'],
|
||||
'price.eyebrow': { zh: '定价', en: 'Pricing', ja: '料金', ko: '요금제', ru: 'Цены', es: 'Precios' },
|
||||
'price.h': { zh: '简单透明的价格', en: 'Simple, honest pricing', ja: 'シンプルで正直な料金', ko: '단순하고 정직한 가격', ru: 'Простые и честные цены', es: 'Precios simples y honestos' },
|
||||
'price.sub': { zh: '随时升级或取消。所有套餐均含核心加密与无日志承诺。', en: 'Upgrade or cancel anytime. Every plan includes core encryption and our no-logs promise.', ja: 'いつでもアップグレード・解約可能。すべてのプランにコア暗号化とノーログの約束が含まれます。', ko: '언제든 업그레이드하거나 해지하세요. 모든 요금제에 핵심 암호화와 노로그 약속이 포함됩니다.', ru: 'Меняйте тариф или отменяйте в любой момент. В каждый план входит базовое шифрование и обещание не вести логи.', es: 'Mejora o cancela cuando quieras. Cada plan incluye cifrado esencial y nuestra promesa sin registros.' },
|
||||
'price.monthly': { zh: '按月', en: 'Monthly', ja: '月払い', ko: '월간', ru: 'Помесячно', es: 'Mensual' },
|
||||
'price.yearly': { zh: '按年', en: 'Yearly', ja: '年払い', ko: '연간', ru: 'Ежегодно', es: 'Anual' },
|
||||
'price.save': { zh: '省 20%', en: 'Save 20%', ja: '20%お得', ko: '20% 절약', ru: 'Экономия 20%', es: 'Ahorra 20%' },
|
||||
'price.permo': { zh: '/月', en: '/mo', ja: '/月', ko: '/월', ru: '/мес', es: '/mes' },
|
||||
'price.free': { zh: '免费版', en: 'Free', ja: '無料版', ko: '무료', ru: 'Бесплатный', es: 'Gratis' },
|
||||
'price.freedesc': { zh: '适合轻度使用与尝鲜', en: 'For light, casual use', ja: 'ライトなお試し利用に', ko: '가볍게 써보는 사용자에게', ru: 'Для лёгкого, эпизодического использования', es: 'Para un uso ligero y ocasional' },
|
||||
'price.pro': { zh: '专业版', en: 'Pro', ja: 'プロ版', ko: '프로', ru: 'Pro', es: 'Pro' },
|
||||
'price.prodesc': { zh: '个人用户的最佳选择', en: 'Best for individuals', ja: '個人ユーザーに最適', ko: '개인 사용자에게 최적', ru: 'Оптимально для одного пользователя', es: 'Ideal para uso individual' },
|
||||
'price.team': { zh: '团队版', en: 'Team', ja: 'チーム版', ko: '팀', ru: 'Команда', es: 'Equipo' },
|
||||
'price.teamdesc': { zh: '小团队集中管理', en: 'For small teams', ja: '小規模チームの一括管理に', ko: '소규모 팀 통합 관리', ru: 'Для небольших команд', es: 'Para equipos pequeños' },
|
||||
'price.popular': { zh: '最受欢迎', en: 'Most popular', ja: '一番人気', ko: '가장 인기', ru: 'Самый популярный', es: 'El más popular' },
|
||||
'price.cta_free': { zh: '免费下载', en: 'Download free', ja: '無料ダウンロード', ko: '무료 다운로드', ru: 'Скачать бесплатно', es: 'Descargar gratis' },
|
||||
'price.cta_pro': { zh: '获取激活码', en: 'Get a code', ja: 'コードを入手', ko: '코드 받기', ru: 'Получить код', es: 'Obtener un código' },
|
||||
'price.cta_team': { zh: '获取激活码', en: 'Get a code', ja: 'コードを入手', ko: '코드 받기', ru: 'Получить код', es: 'Obtener un código' },
|
||||
'pf.free1': { zh: '每日 10 分钟时长', en: '10 min per day', ja: '1日10分', ko: '하루 10분', ru: '10 минут в день', es: '10 min al día' },
|
||||
'pf.free2': { zh: '仅 1 个基础节点', en: '1 basic node only', ja: '基本ノード1つのみ', ko: '기본 노드 1개만', ru: 'Только 1 базовый узел', es: 'Solo 1 nodo básico' },
|
||||
'pf.free3': { zh: '使用前观看广告', en: 'Watch an ad to start', ja: '利用前に広告を視聴', ko: '사용 전 광고 시청', ru: 'Просмотр рекламы перед запуском', es: 'Ver un anuncio para empezar' },
|
||||
'pf.free4': { zh: '核心加密 · 无日志', en: 'Core encryption · no-logs', ja: 'コア暗号化 · ノーログ', ko: '핵심 암호화 · 노로그', ru: 'Базовое шифрование · без логов', es: 'Cifrado esencial · sin registros' },
|
||||
'pf.pro1': { zh: '全球加速线路', en: 'Global routes', ja: 'グローバル回線', ko: '글로벌 라우트', ru: 'Глобальные маршруты', es: 'Rutas globales' },
|
||||
'pf.pro2': { zh: '无限流量 · 极速', en: 'Unlimited · top speed', ja: '無制限 · 最高速度', ko: '무제한 · 최고 속도', ru: 'Без лимитов · максимальная скорость', es: 'Ilimitado · máxima velocidad' },
|
||||
'pf.pro3': { zh: '5 台设备同时在线', en: '5 devices at once', ja: '5台同時接続', ko: '동시 5대', ru: '5 устройств одновременно', es: '5 dispositivos a la vez' },
|
||||
'pf.pro4': { zh: '流媒体 & P2P 优化', en: 'Streaming & P2P routes', ja: 'ストリーミング & P2P最適化', ko: '스트리밍 & P2P 라우트', ru: 'Маршруты для стриминга и P2P', es: 'Rutas para streaming y P2P' },
|
||||
'pf.pro5': { zh: 'Kill Switch · 智能分流', en: 'Kill Switch · smart routing', ja: 'キルスイッチ · スマートルーティング', ko: '킬 스위치 · 스마트 라우팅', ru: 'Kill Switch · умная маршрутизация', es: 'Kill Switch · enrutamiento inteligente' },
|
||||
'pf.team1': { zh: '专业版全部功能', en: 'Everything in Pro', ja: 'プロ版の全機能', ko: '프로의 모든 기능', ru: 'Всё из тарифа Pro', es: 'Todo lo de Pro' },
|
||||
'pf.team2': { zh: '10 个成员席位', en: '10 seats', ja: '10メンバー席', ko: '멤버 10석', ru: '10 мест для участников', es: '10 plazas' },
|
||||
'pf.team3': { zh: '集中计费与管理', en: 'Central billing & admin', ja: '一括請求と管理', ko: '통합 결제 및 관리', ru: 'Единый биллинг и администрирование', es: 'Facturación y administración centralizadas' },
|
||||
'pf.team4': { zh: '优先客服', en: 'Priority support', ja: '優先サポート', ko: '우선 지원', ru: 'Приоритетная поддержка', es: 'Soporte prioritario' },
|
||||
|
||||
'pay.title': ['为什么网页上没有支付按钮?', 'Why no checkout on this page?'],
|
||||
'pay.body': ['出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你可以通过以下任一渠道获取激活码,在客户端内兑换即可开通。', 'For risk and privacy reasons, Pangolin never takes payment in the web or app. Get an activation code through any channel below, then redeem it in the client.'],
|
||||
'pay.store': ['自助发卡商店', 'Self-serve store'],
|
||||
'pay.usdt': ['USDT (TRC20) · 最隐私', 'USDT (TRC20) · most private'],
|
||||
'pay.title': { zh: '为什么网页上没有支付按钮?', en: 'Why no checkout on this page?', ja: 'なぜこのページに決済ボタンがないのですか?', ko: '이 페이지에 결제 버튼이 없는 이유는?', ru: 'Почему на этой странице нет оплаты?', es: '¿Por qué no hay pago en esta página?' },
|
||||
'pay.body': { zh: '出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你可以通过以下任一渠道获取激活码,在客户端内兑换即可开通。', en: 'For risk and privacy reasons, Pangolin never takes payment in the web or app. Get an activation code through any channel below, then redeem it in the client.', ja: 'リスク管理とプライバシーの観点から、Pangolinはウェブやアプリ内で直接決済を受け付けません。以下のいずれかの窓口でアクティベーションコードを入手し、クライアント内で引き換えてください。', ko: '리스크 관리와 개인정보 보호를 위해 Pangolin은 웹이나 앱에서 직접 결제를 받지 않습니다. 아래 채널에서 활성화 코드를 받아 클라이언트에서 등록하세요.', ru: 'В целях безопасности и приватности Pangolin не принимает оплату в вебе или приложении. Получите код активации по любому из каналов ниже и активируйте его в клиенте.', es: 'Por motivos de seguridad y privacidad, Pangolin nunca cobra en la web ni en la app. Consigue un código de activación por cualquiera de estos canales y canjéalo en el cliente.' },
|
||||
'pay.store': { zh: '自助发卡商店', en: 'Self-serve store', ja: 'セルフサービスストア', ko: '셀프 발급 스토어', ru: 'Магазин самообслуживания', es: 'Tienda de autoservicio' },
|
||||
'pay.usdt': { zh: 'USDT (TRC20) · 最隐私', en: 'USDT (TRC20) · most private', ja: 'USDT (TRC20) · 最もプライベート', ko: 'USDT (TRC20) · 가장 프라이빗', ru: 'USDT (TRC20) · максимум приватности', es: 'USDT (TRC20) · lo más privado' },
|
||||
|
||||
'cmp.h': ['功能对比', 'Compare plans'],
|
||||
'cmp.feature': ['功能', 'Feature'],
|
||||
'cmp.locations': ['可用线路', 'Routes'],
|
||||
'cmp.data': ['流量', 'Data'],
|
||||
'cmp.time': ['每日时长', 'Daily time'],
|
||||
'cmp.time_free': ['10 分钟/天(试用期不限)', '10 min/day (trial: unlimited)'],
|
||||
'cmp.devices': ['设备数', 'Devices'],
|
||||
'cmp.speed': ['极速线路', 'Top-speed routes'],
|
||||
'cmp.stream': ['视频优化', 'Video routes'],
|
||||
'cmp.kill': ['Kill Switch', 'Kill Switch'],
|
||||
'cmp.support': ['客服', 'Support'],
|
||||
'cmp.unlimited': ['无限', 'Unlimited'],
|
||||
'cmp.loc_all': ['全球', 'Global'],
|
||||
'cmp.daily': ['不限', 'Unlimited'],
|
||||
'cmp.basic': ['标准', 'Standard'],
|
||||
'cmp.priority': ['优先', 'Priority'],
|
||||
'cmp.h': { zh: '功能对比', en: 'Compare plans', ja: 'プラン比較', ko: '요금제 비교', ru: 'Сравнение тарифов', es: 'Comparar planes' },
|
||||
'cmp.feature': { zh: '功能', en: 'Feature', ja: '機能', ko: '기능', ru: 'Функция', es: 'Función' },
|
||||
'cmp.locations': { zh: '可用线路', en: 'Routes', ja: '利用可能な回線', ko: '이용 가능한 라우트', ru: 'Маршруты', es: 'Rutas' },
|
||||
'cmp.data': { zh: '流量', en: 'Data', ja: 'データ量', ko: '데이터', ru: 'Трафик', es: 'Datos' },
|
||||
'cmp.time': { zh: '每日时长', en: 'Daily time', ja: '1日の利用時間', ko: '하루 사용 시간', ru: 'Время в день', es: 'Tiempo diario' },
|
||||
'cmp.time_free': { zh: '10 分钟/天(试用期不限)', en: '10 min/day (trial: unlimited)', ja: '10分/日(トライアル中は無制限)', ko: '10분/일 (체험 중 무제한)', ru: '10 мин/день (в пробный период — без лимита)', es: '10 min/día (prueba: ilimitado)' },
|
||||
'cmp.devices': { zh: '设备数', en: 'Devices', ja: 'デバイス数', ko: '기기 수', ru: 'Устройства', es: 'Dispositivos' },
|
||||
'cmp.speed': { zh: '极速线路', en: 'Top-speed routes', ja: '最高速回線', ko: '최고 속도 라우트', ru: 'Скоростные маршруты', es: 'Rutas de máxima velocidad' },
|
||||
'cmp.stream': { zh: '视频优化', en: 'Video routes', ja: '動画向け回線', ko: '영상 라우트', ru: 'Маршруты для видео', es: 'Rutas para vídeo' },
|
||||
'cmp.kill': { zh: 'Kill Switch', en: 'Kill Switch', ja: 'キルスイッチ', ko: '킬 스위치', ru: 'Kill Switch', es: 'Kill Switch' },
|
||||
'cmp.support': { zh: '客服', en: 'Support', ja: 'サポート', ko: '고객 지원', ru: 'Поддержка', es: 'Soporte' },
|
||||
'cmp.unlimited': { zh: '无限', en: 'Unlimited', ja: '無制限', ko: '무제한', ru: 'Без лимита', es: 'Ilimitado' },
|
||||
'cmp.loc_all': { zh: '全球', en: 'Global', ja: 'グローバル', ko: '글로벌', ru: 'Глобально', es: 'Global' },
|
||||
'cmp.daily': { zh: '不限', en: 'Unlimited', ja: '無制限', ko: '무제한', ru: 'Без лимита', es: 'Ilimitado' },
|
||||
'cmp.basic': { zh: '标准', en: 'Standard', ja: '標準', ko: '표준', ru: 'Стандарт', es: 'Estándar' },
|
||||
'cmp.priority': { zh: '优先', en: 'Priority', ja: '優先', ko: '우선', ru: 'Приоритет', es: 'Prioritario' },
|
||||
|
||||
'dl.eyebrow': ['下载', 'Download'],
|
||||
'dl.h': ['全平台,随处可用', 'Every platform, everywhere'],
|
||||
'dl.sub': ['一个账户,所有设备同步。下载即用,无需配置。', 'One account syncs every device. Download and go.'],
|
||||
'dl.get': ['下载', 'Download'],
|
||||
'dl.soon': ['敬请期待', 'Coming soon'],
|
||||
'dl.eyebrow': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' },
|
||||
'dl.h': { zh: '全平台,随处可用', en: 'Every platform, everywhere', ja: 'すべてのプラットフォーム、どこでも', ko: '모든 플랫폼, 어디서나', ru: 'Все платформы, везде', es: 'Todas las plataformas, en todas partes' },
|
||||
'dl.sub': { zh: '一个账户,所有设备同步。下载即用,无需配置。', en: 'One account syncs every device. Download and go.', ja: '一つのアカウントで全デバイスを同期。ダウンロードしてすぐ使えます。', ko: '하나의 계정으로 모든 기기를 동기화. 내려받아 바로 사용.', ru: 'Один аккаунт синхронизирует все устройства. Скачал — и в путь.', es: 'Una cuenta sincroniza todos tus dispositivos. Descarga y listo.' },
|
||||
'dl.get': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' },
|
||||
'dl.soon': { zh: '敬请期待', en: 'Coming soon', ja: '近日公開', ko: '출시 예정', ru: 'Скоро', es: 'Próximamente' },
|
||||
|
||||
'docs.eyebrow': ['文档', 'Docs'],
|
||||
'docs.h': ['需要帮助?都在这里', 'Need help? It’s all here'],
|
||||
'docs.sub': ['从快速上手到协议细节,清晰直接。', 'From quickstart to protocol details — clear and direct.'],
|
||||
'docs.1t': ['快速开始', 'Quickstart'],
|
||||
'docs.1d': ['三分钟完成注册、下载与首次连接。', 'Sign up, download and connect in three minutes.'],
|
||||
'docs.2t': ['常见问题', 'FAQ'],
|
||||
'docs.2d': ['连接、计费、设备与兑换码的常见疑问。', 'Connection, billing, devices and redeem codes.'],
|
||||
'docs.3t': ['协议与安全', 'Protocol & security'],
|
||||
'docs.3d': ['WireGuard、加密方式与无日志架构说明。', 'WireGuard, encryption and our no-logs architecture.'],
|
||||
'docs.4t': ['隐私政策', 'Privacy policy'],
|
||||
'docs.4d': ['我们收集什么、不收集什么,一目了然。', 'Exactly what we collect — and what we never do.'],
|
||||
'docs.read': ['阅读', 'Read'],
|
||||
'docs.eyebrow': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||
'docs.h': { zh: '需要帮助?都在这里', en: 'Need help? It’s all here', ja: 'お困りですか?すべてここに', ko: '도움이 필요하세요? 모두 여기에', ru: 'Нужна помощь? Всё здесь', es: '¿Necesitas ayuda? Todo está aquí' },
|
||||
'docs.sub': { zh: '从快速上手到协议细节,清晰直接。', en: 'From quickstart to protocol details — clear and direct.', ja: 'クイックスタートからプロトコルの詳細まで、明快に。', ko: '빠른 시작부터 프로토콜 세부까지, 명확하고 간결하게.', ru: 'От быстрого старта до деталей протокола — ясно и по делу.', es: 'Desde el inicio rápido hasta los detalles del protocolo, claro y directo.' },
|
||||
'docs.1t': { zh: '快速开始', en: 'Quickstart', ja: 'クイックスタート', ko: '빠른 시작', ru: 'Быстрый старт', es: 'Inicio rápido' },
|
||||
'docs.1d': { zh: '三分钟完成注册、下载与首次连接。', en: 'Sign up, download and connect in three minutes.', ja: '3分で登録・ダウンロード・初回接続まで完了。', ko: '3분 만에 가입, 다운로드, 첫 연결까지.', ru: 'Регистрация, загрузка и первое подключение за три минуты.', es: 'Regístrate, descarga y conéctate en tres minutos.' },
|
||||
'docs.2t': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' },
|
||||
'docs.2d': { zh: '连接、计费、设备与兑换码的常见疑问。', en: 'Connection, billing, devices and redeem codes.', ja: '接続・請求・デバイス・引き換えコードのよくある疑問。', ko: '연결, 결제, 기기, 등록 코드에 대한 궁금증.', ru: 'Подключение, оплата, устройства и коды активации.', es: 'Conexión, facturación, dispositivos y códigos de canje.' },
|
||||
'docs.3t': { zh: '协议与安全', en: 'Protocol & security', ja: 'プロトコルとセキュリティ', ko: '프로토콜 & 보안', ru: 'Протокол и безопасность', es: 'Protocolo y seguridad' },
|
||||
'docs.3d': { zh: 'WireGuard、加密方式与无日志架构说明。', en: 'WireGuard, encryption and our no-logs architecture.', ja: 'WireGuard、暗号化方式、ノーログ設計の解説。', ko: 'WireGuard, 암호화 방식, 노로그 아키텍처 설명.', ru: 'WireGuard, шифрование и наша архитектура без логов.', es: 'WireGuard, cifrado y nuestra arquitectura sin registros.' },
|
||||
'docs.4t': { zh: '隐私政策', en: 'Privacy policy', ja: 'プライバシーポリシー', ko: '개인정보 처리방침', ru: 'Политика конфиденциальности', es: 'Política de privacidad' },
|
||||
'docs.4d': { zh: '我们收集什么、不收集什么,一目了然。', en: 'Exactly what we collect — and what we never do.', ja: '収集するもの・しないものを明確に。', ko: '무엇을 수집하고 무엇을 수집하지 않는지 한눈에.', ru: 'Что мы собираем — и чего не собираем никогда.', es: 'Exactamente qué recopilamos y qué nunca hacemos.' },
|
||||
'docs.read': { zh: '阅读', en: 'Read', ja: '読む', ko: '읽기', ru: 'Читать', es: 'Leer' },
|
||||
|
||||
'blog.eyebrow': ['Blog', 'Blog'],
|
||||
'blog.h': ['来自团队的最新动态', 'Latest from the team'],
|
||||
'blog.sub': ['产品更新、安全科普与节点公告。', 'Product updates, security explainers and node news.'],
|
||||
'blog.1tag': ['产品更新', 'Product'],
|
||||
'blog.1t': ['v2.4:更快的智能分流与全新统计页', 'v2.4: faster smart routing & a new stats tab'],
|
||||
'blog.1d': ['本次更新重写了分流引擎,连接建立速度提升约 40%。', 'We rewrote the routing engine — connections are ~40% faster.'],
|
||||
'blog.2tag': ['安全', 'Security'],
|
||||
'blog.2t': ['无日志到底意味着什么?', 'What “no-logs” really means'],
|
||||
'blog.2d': ['我们逐条拆解收集与不收集的数据,以及背后的架构。', 'A line-by-line look at what we keep — and what we never touch.'],
|
||||
'blog.3tag': ['公告', 'News'],
|
||||
'blog.3t': ['新增首尔与法兰克福高速节点', 'New high-speed nodes: Seoul & Frankfurt'],
|
||||
'blog.3d': ['两条新线路已上线,为东北亚与欧洲用户带来更低延迟。', 'Two new routes are live, cutting latency for NE Asia and Europe.'],
|
||||
'blog.readmore': ['阅读全文', 'Read more'],
|
||||
'blog.eyebrow': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
||||
'blog.h': { zh: '来自团队的最新动态', en: 'Latest from the team', ja: 'チームからの最新情報', ko: '팀의 최신 소식', ru: 'Свежие новости команды', es: 'Lo último del equipo' },
|
||||
'blog.sub': { zh: '产品更新、安全科普与节点公告。', en: 'Product updates, security explainers and node news.', ja: '製品アップデート、セキュリティ解説、ノード情報。', ko: '제품 업데이트, 보안 해설, 노드 소식.', ru: 'Обновления продукта, разборы по безопасности и новости об узлах.', es: 'Novedades del producto, explicaciones de seguridad y avisos de nodos.' },
|
||||
'blog.1tag': { zh: '产品更新', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' },
|
||||
'blog.1t': { zh: 'v2.4:更快的智能分流与全新统计页', en: 'v2.4: faster smart routing & a new stats tab', ja: 'v2.4:より速いスマートルーティングと新しい統計タブ', ko: 'v2.4: 더 빨라진 스마트 라우팅과 새 통계 탭', ru: 'v2.4: быстрее умная маршрутизация и новая вкладка статистики', es: 'v2.4: enrutamiento inteligente más rápido y nueva pestaña de estadísticas' },
|
||||
'blog.1d': { zh: '本次更新重写了分流引擎,连接建立速度提升约 40%。', en: 'We rewrote the routing engine — connections are ~40% faster.', ja: 'ルーティングエンジンを刷新し、接続の確立が約40%高速化しました。', ko: '라우팅 엔진을 새로 작성해 연결 속도가 약 40% 빨라졌습니다.', ru: 'Мы переписали движок маршрутизации — соединения устанавливаются на ~40% быстрее.', es: 'Reescribimos el motor de enrutamiento: las conexiones son ~40% más rápidas.' },
|
||||
'blog.2tag': { zh: '安全', en: 'Security', ja: 'セキュリティ', ko: '보안', ru: 'Безопасность', es: 'Seguridad' },
|
||||
'blog.2t': { zh: '无日志到底意味着什么?', en: 'What “no-logs” really means', ja: '「ノーログ」とは本当はどういう意味か', ko: '“노로그”가 정말 의미하는 것', ru: 'Что на самом деле значит «без логов»', es: 'Qué significa realmente “sin registros”' },
|
||||
'blog.2d': { zh: '我们逐条拆解收集与不收集的数据,以及背后的架构。', en: 'A line-by-line look at what we keep — and what we never touch.', ja: '保存するデータとしないデータを一つずつ、その背後の設計とともに解説。', ko: '저장하는 데이터와 절대 건드리지 않는 데이터를 하나하나 살펴봅니다.', ru: 'Построчный разбор того, что мы храним — и чего не касаемся никогда.', es: 'Un repaso punto por punto de lo que guardamos y lo que nunca tocamos.' },
|
||||
'blog.3tag': { zh: '公告', en: 'News', ja: 'お知らせ', ko: '소식', ru: 'Новости', es: 'Novedades' },
|
||||
'blog.3t': { zh: '新增首尔与法兰克福高速节点', en: 'New high-speed nodes: Seoul & Frankfurt', ja: '高速ノードを新設:ソウルとフランクフルト', ko: '새 고속 노드: 서울과 프랑크푸르트', ru: 'Новые скоростные узлы: Сеул и Франкфурт', es: 'Nuevos nodos de alta velocidad: Seúl y Fráncfort' },
|
||||
'blog.3d': { zh: '两条新线路已上线,为东北亚与欧洲用户带来更低延迟。', en: 'Two new routes are live, cutting latency for NE Asia and Europe.', ja: '2つの新回線が稼働開始、東北アジアと欧州のユーザーに低遅延を。', ko: '두 개의 새 라우트가 가동되어 동북아와 유럽 사용자의 지연이 줄었습니다.', ru: 'Запущены два новых маршрута, снижающих задержку для Северо-Восточной Азии и Европы.', es: 'Dos rutas nuevas ya activas reducen la latencia para el noreste de Asia y Europa.' },
|
||||
'blog.readmore': { zh: '阅读全文', en: 'Read more', ja: '続きを読む', ko: '더 읽기', ru: 'Читать далее', es: 'Leer más' },
|
||||
|
||||
'cta.h': ['准备好体验极速畅连了吗?', 'Ready for a faster connection?'],
|
||||
'cta.p': ['免费下载,数分钟内开始你的第一次极速连接。', 'Download free and get connected in minutes.'],
|
||||
'cta.btn': ['免费下载', 'Download free'],
|
||||
'cta.h': { zh: '准备好体验极速畅连了吗?', en: 'Ready for a faster connection?', ja: 'より速い接続を体験する準備はできましたか?', ko: '더 빠른 연결을 경험할 준비가 되셨나요?', ru: 'Готовы к более быстрому соединению?', es: '¿Listo para una conexión más rápida?' },
|
||||
'cta.p': { zh: '免费下载,数分钟内开始你的第一次极速连接。', en: 'Download free and get connected in minutes.', ja: '無料でダウンロードして、数分で最初の高速接続を。', ko: '무료로 내려받아 몇 분 안에 연결하세요.', ru: 'Скачайте бесплатно и подключитесь за считанные минуты.', es: 'Descarga gratis y conéctate en minutos.' },
|
||||
'cta.btn': { zh: '免费下载', en: 'Download free', ja: '無料ダウンロード', ko: '무료 다운로드', ru: 'Скачать бесплатно', es: 'Descargar gratis' },
|
||||
|
||||
'ft.tag': ['极简、轻量、亲和的跨平台网络加速应用。守护你的每一次连接。', 'A minimal, friendly cross-platform network accelerator. Protecting every connection.'],
|
||||
'ft.product': ['产品', 'Product'],
|
||||
'ft.resources': ['资源', 'Resources'],
|
||||
'ft.contact': ['联系与渠道', 'Contact'],
|
||||
'ft.features': ['功能', 'Features'],
|
||||
'ft.pricing': ['定价', 'Pricing'],
|
||||
'ft.download': ['下载', 'Download'],
|
||||
'ft.docs': ['文档', 'Docs'],
|
||||
'ft.blog': ['Blog', 'Blog'],
|
||||
'ft.faq': ['常见问题', 'FAQ'],
|
||||
'ft.privacy': ['隐私政策', 'Privacy'],
|
||||
'ft.status': ['服务状态', 'Status'],
|
||||
'ft.hours': ['服务时间 每日 9:00–24:00 (GMT+8)', 'Hours: Daily 9:00–24:00 (GMT+8)'],
|
||||
'ft.copy': ['© 2026 穿山甲 · Pangolin', '© 2026 Pangolin'],
|
||||
'ft.madenote': ['App 内不支持直接支付 · 资金流全走外部渠道', 'No in-app payment · all purchases via external channels'],
|
||||
'ft.tag': { zh: '极简、轻量、亲和的跨平台网络加速应用。守护你的每一次连接。', en: 'A minimal, friendly cross-platform network accelerator. Protecting every connection.', ja: 'ミニマルで軽快、使いやすいクロスプラットフォームのネットワークアクセラレーター。すべての接続を守ります。', ko: '미니멀하고 가벼우며 친근한 크로스플랫폼 네트워크 가속 앱. 모든 연결을 지킵니다.', ru: 'Минималистичный, лёгкий и удобный кроссплатформенный ускоритель сети. Защищает каждое соединение.', es: 'Un acelerador de red multiplataforma minimalista, ligero y amable. Protegiendo cada conexión.' },
|
||||
'ft.product': { zh: '产品', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' },
|
||||
'ft.resources': { zh: '资源', en: 'Resources', ja: 'リソース', ko: '리소스', ru: 'Ресурсы', es: 'Recursos' },
|
||||
'ft.contact': { zh: '联系与渠道', en: 'Contact', ja: 'お問い合わせ', ko: '문의 및 채널', ru: 'Контакты', es: 'Contacto' },
|
||||
'ft.features': { zh: '功能', en: 'Features', ja: '機能', ko: '기능', ru: 'Возможности', es: 'Funciones' },
|
||||
'ft.pricing': { zh: '定价', en: 'Pricing', ja: '料金', ko: '요금제', ru: 'Цены', es: 'Precios' },
|
||||
'ft.download': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' },
|
||||
'ft.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||
'ft.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
||||
'ft.faq': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' },
|
||||
'ft.privacy': { zh: '隐私政策', en: 'Privacy', ja: 'プライバシー', ko: '개인정보', ru: 'Конфиденциальность', es: 'Privacidad' },
|
||||
'ft.status': { zh: '服务状态', en: 'Status', ja: 'サービス状況', ko: '서비스 상태', ru: 'Статус', es: 'Estado' },
|
||||
'ft.hours': { zh: '服务时间 每日 9:00–24:00 (GMT+8)', en: 'Hours: Daily 9:00–24:00 (GMT+8)', ja: '営業時間 毎日 9:00–24:00 (GMT+8)', ko: '운영 시간 매일 9:00–24:00 (GMT+8)', ru: 'Часы работы: ежедневно 9:00–24:00 (GMT+8)', es: 'Horario: todos los días 9:00–24:00 (GMT+8)' },
|
||||
'ft.copy': { zh: '© 2026 穿山甲 · Pangolin', en: '© 2026 Pangolin', ja: '© 2026 Pangolin', ko: '© 2026 Pangolin', ru: '© 2026 Pangolin', es: '© 2026 Pangolin' },
|
||||
'ft.madenote': { zh: 'App 内不支持直接支付 · 资金流全走外部渠道', en: 'No in-app payment · all purchases via external channels', ja: 'アプリ内決済なし · すべての購入は外部窓口で', ko: '앱 내 결제 없음 · 모든 구매는 외부 채널로', ru: 'Без оплаты в приложении · все покупки через внешние каналы', es: 'Sin pago en la app · todas las compras por canales externos' },
|
||||
|
||||
// 页面 <title> / meta(zh / en)
|
||||
'meta.title': ['穿山甲 · Pangolin — 极速 · 稳定 · 省心', 'Pangolin — Fast · Stable · Effortless'],
|
||||
'meta.desc': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'],
|
||||
// 页面 <title> / meta(六语)
|
||||
'meta.title': { zh: '穿山甲 · Pangolin — 极速 · 稳定 · 省心', en: 'Pangolin — Fast · Stable · Effortless', ja: 'Pangolin — 高速 · 安定 · 快適', ko: 'Pangolin — 빠름 · 안정 · 간편', ru: 'Pangolin — Быстро · Стабильно · Без забот', es: 'Pangolin — Rápido · Estable · Sin complicaciones' },
|
||||
'meta.desc': { zh: '轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', en: 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.', ja: '軽快で使いやすい、クロスプラットフォームのネットワークアクセラレーター。ワンタップ接続、スマートルーティング、安定した通信。', ko: '가볍고 친근한 크로스플랫폼 네트워크 가속 앱. 원 탭 연결, 스마트 라우팅, 흔들림 없는 안정성.', ru: 'Лёгкий и удобный кроссплатформенный ускоритель сети. Одно касание, умная маршрутизация, надёжное соединение.', es: 'Un acelerador de red multiplataforma, ligero y fácil de usar. Un toque, enrutamiento inteligente, total estabilidad.' },
|
||||
};
|
||||
|
||||
export type T = (key: string) => string;
|
||||
|
||||
/** 返回当前语言的取词函数;缺键回退到中文,再回退到键名本身(便于发现遗漏)。 */
|
||||
/** 返回当前语言的取词函数;缺键回退英文,再回退键名本身(便于发现遗漏)。 */
|
||||
export function createT(lang: Lang): T {
|
||||
const idx = lang === 'zh' ? 0 : 1;
|
||||
return (key: string) => {
|
||||
const pair = STRINGS[key];
|
||||
if (!pair) return key;
|
||||
return pair[idx] ?? pair[0] ?? key;
|
||||
const m = STRINGS[key];
|
||||
if (!m) return key;
|
||||
return m[lang] ?? m.en ?? key;
|
||||
};
|
||||
}
|
||||
|
||||
/** 价格表(月 / 年·按月),按语言分币种:zh=人民币、en=美元。年付约 8 折。
|
||||
* 对齐 design/CLAUDE.md §7;收款走客户端内兑换激活码,网页价仅为展示。 */
|
||||
/** 价格表(月 / 年·按月),按语言分币种:zh=人民币、其余=美元。年付约 8 折。
|
||||
* 对齐 design/CLAUDE.md §7;收款走客户端内兑换激活码,网页价仅为展示。
|
||||
* ja/ko/ru/es 暂复用 en 的美元价,币种本地化为后续单独工作。 */
|
||||
export const PRICES: Record<Lang, Record<'free' | 'pro' | 'team', [string, string]>> = {
|
||||
zh: {
|
||||
free: ['¥0', '¥0'],
|
||||
@@ -207,4 +208,24 @@ export const PRICES: Record<Lang, Record<'free' | 'pro' | 'team', [string, strin
|
||||
pro: ['$3.99', '$3.19'],
|
||||
team: ['$13.99', '$11.19'],
|
||||
},
|
||||
ja: {
|
||||
free: ['$0', '$0'],
|
||||
pro: ['$3.99', '$3.19'],
|
||||
team: ['$13.99', '$11.19'],
|
||||
},
|
||||
ko: {
|
||||
free: ['$0', '$0'],
|
||||
pro: ['$3.99', '$3.19'],
|
||||
team: ['$13.99', '$11.19'],
|
||||
},
|
||||
ru: {
|
||||
free: ['$0', '$0'],
|
||||
pro: ['$3.99', '$3.19'],
|
||||
team: ['$13.99', '$11.19'],
|
||||
},
|
||||
es: {
|
||||
free: ['$0', '$0'],
|
||||
pro: ['$3.99', '$3.19'],
|
||||
team: ['$13.99', '$11.19'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,6 +43,12 @@ interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const t = createT(lang);
|
||||
|
||||
// 语言 → <html lang> / og:locale / 路由前缀(默认 en = /,其余 /<lang>/)。
|
||||
const HTML_LANG: Record<Lang, string> = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', es: 'es' };
|
||||
const OG_LOCALE: Record<Lang, string> = { zh: 'zh_CN', en: 'en_US', ja: 'ja_JP', ko: 'ko_KR', ru: 'ru_RU', es: 'es_ES' };
|
||||
const ALL_LANGS: Lang[] = ['en', 'zh', 'ja', 'ko', 'ru', 'es'];
|
||||
const langPath = (l: Lang) => (l === 'en' ? '/' : `/${l}/`);
|
||||
|
||||
const headerT = {
|
||||
product: t('nav.product'),
|
||||
pricing: t('nav.pricing'),
|
||||
@@ -55,22 +61,23 @@ const headerT = {
|
||||
};
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={lang === 'zh' ? 'zh-CN' : 'en'}>
|
||||
<html lang={HTML_LANG[lang]}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{t('meta.title')}</title>
|
||||
<meta name="description" content={t('meta.desc')} />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<link rel="canonical" href={new URL(lang === 'zh' ? '/' : '/en/', Astro.site).href} />
|
||||
<link rel="alternate" hreflang="zh-CN" href={new URL('/', Astro.site).href} />
|
||||
<link rel="alternate" hreflang="en" href={new URL('/en/', Astro.site).href} />
|
||||
<link rel="canonical" href={new URL(langPath(lang), Astro.site).href} />
|
||||
{ALL_LANGS.map((l) => (
|
||||
<link rel="alternate" hreflang={HTML_LANG[l]} href={new URL(langPath(l), Astro.site).href} />
|
||||
))}
|
||||
<link rel="alternate" hreflang="x-default" href={new URL('/', Astro.site).href} />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={t('meta.title')} />
|
||||
<meta property="og:description" content={t('meta.desc')} />
|
||||
<meta property="og:locale" content={lang === 'zh' ? 'zh_CN' : 'en_US'} />
|
||||
<meta property="og:locale" content={OG_LOCALE[lang]} />
|
||||
</head>
|
||||
<body>
|
||||
<AnnouncementBar client:idle text={t('ann.txt')} cta={t('ann.cta')} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
// en 路由(/en/)
|
||||
// es 路由(/es/)
|
||||
import Site from '../../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="en" />
|
||||
<Site lang="es" />
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
// zh 路由(/)
|
||||
// 默认语种 = 英文(/ → en)。其它语言:/zh/ /ja/ /ko/ /ru/ /es/。
|
||||
import Site from '../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="zh" />
|
||||
<Site lang="en" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ja 路由(/ja/)
|
||||
import Site from '../../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="ja" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ko 路由(/ko/)
|
||||
import Site from '../../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="ko" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// ru 路由(/ru/)
|
||||
import Site from '../../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="ru" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// zh 路由(/zh/)。默认语种已改英文(/ → en),中文移到此。
|
||||
import Site from '../../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="zh" />
|
||||
@@ -40,6 +40,25 @@
|
||||
}
|
||||
.langseg a.on { background: var(--accent); color: var(--fg-on-accent); }
|
||||
|
||||
/* 6 语用原生下拉(横排段控会挤)。圆角矩形 + 柔和边框(原纯 --border 太生硬)。 */
|
||||
.langsel {
|
||||
border: 1px solid color-mix(in oklab, var(--border), transparent 55%);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--fg2);
|
||||
background: var(--bg-subtle);
|
||||
box-shadow: 0 1px 2px rgba(45, 30, 20, 0.05);
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
.langsel:hover {
|
||||
border-color: color-mix(in oklab, var(--accent), transparent 40%);
|
||||
box-shadow: 0 1px 4px rgba(45, 30, 20, 0.08);
|
||||
}
|
||||
|
||||
/* 视觉隐藏(无障碍用,当前未强依赖) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user