Compare commits

...

9 Commits

Author SHA1 Message Date
wangjia 11ca7531a3 ci: flutter build 前显式 pub get + 重试(防 pub.flutter-io.cn 被 GFW 抖断)
Deploy Client / build-android (push) Successful in 1m46s
Deploy Client / build-windows (push) Successful in 1m55s
Deploy Client / build-macos (push) Successful in 3m40s
Deploy Client / build-ios (push) Successful in 3m28s
Deploy Client / release-deploy (push) Successful in 2m14s
iOS build 曾因 flutter build 内隐式 pub get 拉 google_fonts 时 pub.flutter-io.cn
socket error(exit 69)而失败。_env.sh 加 flutter_pub_get_retry(5 次重试),四个
compile 脚本在 flutter build 前显式预取,成功后 build 命中缓存不再拉网,减少偶发
网络失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 20:25:01 +08:00
wangjia e6b4ec0459 fix(ci/client): mac 端 job runs-on mac→nas(匹配实际 runner label)
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-android (push) Successful in 2m4s
Deploy Client / build-macos (push) Successful in 3m38s
Deploy Client / build-ios (push) Failing after 1m57s
Deploy Client / release-deploy (push) Successful in 2m0s
mac runner(mac-pangolin-2)启动脚本注册 label 为 nas:host;此前靠手动加的 mac
label 让 runs-on:mac 命中,但 runner 重启(经启动脚本)会重注册成 nas、丢掉 mac
label → build-android/macos/ios/release-deploy 无 runner 可接、永久排队。改成
runs-on:nas 匹配实际 label,抗重启漂移(nas runner 即这台 mac,唯一)。windows
job 仍 runs-on:windows(windows runner)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 20:07:16 +08:00
wangjia 0b86138b55 feat(client/update): 更新提示改 jiu 式顶部横条(非强制不再弹窗)
Deploy Client / build-windows (push) Successful in 1m47s
Deploy Client / build-android (push) Has been cancelled
Deploy Client / build-macos (push) Has been cancelled
Deploy Client / build-ios (push) Has been cancelled
Deploy Client / release-deploy (push) Has been cancelled
按需求去掉居中弹窗,改成不打断的顶部 banner(对照 jiu 黄条):
- 非强制更新 → home_shell 顶部 UpdateBanner「发现新版本 vX + 立即更新 + ×」;
  「立即更新」App 内下载安装,「×」忽略此版本(banner 隐藏,更新版本会重现)
- 强制更新(force_update)→ 仍走不可关闭弹窗 showUpdateDialog
- 忽略状态改 dismissedUpdateVersionProvider(按版本号,响应式);去掉 notifier
  的 _dismissed
- 设置页「检查更新」有更新时:清忽略版本→banner 显示 + toast,不再弹窗
- 新增 6 语「立即更新」文案。flutter analyze 仅 2 个既有 withOpacity info

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 19:19:05 +08:00
wangjia aaffe85a4f fix(ci/windows): 同步 Flutter app 版本到 tag(原卡在 pubspec 1.0.48)
compile-windows 此前只 sed Inno 安装器 MyAppVersion,没改 Flutter pubspec 版本,
导致 Windows 包内 package_info 永远是 committed 的 1.0.48 → 设置页显示旧版 +
自动更新永远判"有新版"。补上 build number 计算 + sed 复制后的 pubspec.yaml
(GNU sed,windows runner),与 android/macos 对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 19:05:31 +08:00
wangjia 4940278ea5 feat(migrate): 用户中心迁到 pangolin.yanmeiai.com/user/ + 域名配置化
Deploy Server / deploy-server (push) Successful in 2m55s
Deploy Site / deploy-site (push) Successful in 2m41s
Deploy Client / build-windows (push) Successful in 1m47s
Deploy Client / build-android (push) Successful in 7m39s
Deploy Client / build-macos (push) Successful in 3m40s
Deploy Client / build-ios (push) Successful in 4m48s
Deploy Client / release-deploy (push) Successful in 2m25s
原独立子域 app.yanmeiai.com → 主站子路径 /user/(用户选停用旧域名):
- 域名配置化(去硬编码):客户端 kWebUserCenterBaseUrl 收进 api_config.dart
  (dart-define 可覆盖);官网 site.ts、服务端 CORS_ORIGINS 本就是配置
- 用户中心 next.config basePath=/user;layout.tsx 的 /colors_and_type.css 手动
  拼 basePath(public 根绝对资源不自动加前缀,否则 404)
- CI 合并部署:compile-site + compile-usercenter + combine-site(用户中心并入
  dist/user/ + _headers 按 /user/* 分域:官网严格 CSP,用户中心 unsafe-inline+
  connect-src https)→ 单次部署 pangolin-site;删独立 pangolin-usercenter 部署
- 服务端 CORS 默认 origin app.yanmeiai.com → pangolin.yanmeiai.com(+ 测试)
- 客户端 web_launch 走新址 → 随 client-v1.0.62;go test CORS 过、flutter analyze 净

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 17:33:22 +08:00
wangjia 6a46e3109a style(web): 语言下拉框圆角矩形 + 柔和半透明边框(原白边太生硬)
官网 .langsel 与用户中心 LangSeg select:border 由纯 --border 改 color-mix 半透明、
圆角 full→md 圆角矩形、加微投影;hover 柔化。纯视觉,不影响逻辑。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 17:22:03 +08:00
wangjia c723bdd53e feat(client/update): 启动自动检查 + App 内下载安装(不走浏览器)
Deploy Client / build-windows (push) Successful in 2m2s
Deploy Client / build-android (push) Successful in 2m13s
Deploy Client / build-macos (push) Successful in 3m45s
Deploy Client / build-ios (push) Successful in 3m58s
Deploy Client / release-deploy (push) Successful in 2m1s
参照 jiu 补齐,但比 jiu 多做了 Android 原生安装(jiu 的 App 内下载仅 Win/mac):
- 启动自动检查:update_provider 改 AsyncNotifier,build() 延迟 3s 首查 + 每小时
  轮询;home_shell watch 拉起、listen 弹更新框;_dismissed 防反复弹(轮询重置);
  强制更新走不可关闭弹窗。设置页「检查更新」改 forceCheck()。
- App 内下载(core/update/app_updater.dart,http 流式带进度框,不再 launchUrl):
  · Android:下 APK → open_filex 拉起系统安装器(+ REQUEST_INSTALL_PACKAGES)
  · Windows:下 exe → Process.start(detached) → exit(0) 覆盖安装
  · macOS :下 zip → open 解压 + 访达显示,提示手动拖入(带系统扩展不自动 swap)
  · iOS   :降级外链(TestFlight/App Store)
  失败降级浏览器下载。新增 6 语更新进度/失败/提示文案。
- 依赖 open_filex;flutter analyze 仅 2 个既有 withOpacity info。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 17:09:29 +08:00
wangjia 617b43083d feat(client/i18n): B1 硬编码文案 6 语化 + B2 语言持久化
B2 持久化:localeProvider 由 StateProvider 改 StateNotifier(LocaleNotifier),
默认英文,选择存 shared_preferences(key pg_lang),重启保留;新增 appTextFor(lang)
helper 供非 Consumer 场景复用。两处切换 .state= 改 .set()。

B1 硬编码 UI 文案 6 语化(AppLangMisc 扩展,exhaustive switch 不会漏):
- 不可用 / 流媒体优化 / 节点未就绪 / 连接失败 / 加载失败 / 暂无设备 / 改邮箱
- 相对时间(刚刚/X分钟前/…)relativeTime(Duration)
- 修 auth_screen 错误消息 bug:原 _errorZh 永远显示中文(连英文用户都中招)→
  按当前语言取 messageZh/En(非中文显英文)

仍回退英文(服务端数据,需服务端多语字段):节点/套餐名(nameZh/nameEn)、
API 错误消息(messageZh/En)。flutter analyze 仅 2 个既有 withOpacity info。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 14:32:13 +08:00
wangjia f9a87a9993 ci(site): deploy-site 增补用户中心构建+部署(补 CI 缺口)
Deploy Site / deploy-site (push) Successful in 3m42s
用户中心此前无 CI 部署(手动 wrangler),漂移风险。现纳入 site-v* 发版:
- scripts/ci/compile-usercenter.sh:next build → out/,⚠️内置 NEXT_PUBLIC_API_MODE=http
  + NEXT_PUBLIC_API_DOMAINS=https://api.yanmeiai.com(不设默认 mock=假数据,是个坑)
- scripts/ci/deploy-usercenter.sh:wrangler pages deploy → pangolin-usercenter
  (app.yanmeiai.com)
- deploy-site.yml 追加两步,官网+用户中心同一 tag 发,CF token/account 走全局 secret

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 13:37:31 +08:00
35 changed files with 914 additions and 94 deletions
+4 -4
View File
@@ -34,7 +34,7 @@ concurrency:
jobs:
build-android:
runs-on: mac
runs-on: nas
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -98,7 +98,7 @@ jobs:
# simply picks up whatever artifacts DO exist — an absent "macos"
# artifact is not an error there.
build-macos:
runs-on: mac
runs-on: nas
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -129,7 +129,7 @@ jobs:
path: dist/
build-ios:
runs-on: mac
runs-on: nas
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
@@ -160,7 +160,7 @@ jobs:
# (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。
release-deploy:
needs: [build-android]
runs-on: mac
runs-on: nas
steps:
- name: Checkout
uses: actions/checkout@v4
+10
View File
@@ -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}"
+236
View File
@@ -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),
),
],
),
),
),
);
}
}
+294
View File
@@ -34,6 +34,300 @@ extension AppLangLabel on AppLang {
}
}
/// 少量「拿不到 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';
}
}
/// 更新 banner:「立即更新」。
String get updateNow {
switch (this) {
case AppLang.zh:
return '立即更新';
case AppLang.en:
return 'Update now';
case AppLang.ja:
return '今すぐ更新';
case AppLang.ko:
return '지금 업데이트';
case AppLang.ru:
return 'Обновить';
case AppLang.es:
return 'Actualizar';
}
}
}
/// 全部界面文案的抽象契约。zh / en 各实现一份。
abstract class AppText {
const AppText();
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+7 -6
View File
@@ -15,12 +15,10 @@ import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/update_dialog.dart';
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
/// 有更新则弹 [showUpdateDialog]
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)
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);
@@ -30,7 +28,10 @@ Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) asy
showPangolinToast(context, t.updateUpToDate);
return;
}
await showUpdateDialog(context, t, info);
// 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。
// 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。
ref.read(dismissedUpdateVersionProvider.notifier).state = null;
showPangolinToast(context, t.updateAvailableTitle(info.latestVersion));
}
class SettingsPage extends ConsumerWidget {
@@ -72,7 +73,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,
+8
View File
@@ -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',
);
+1 -3
View File
@@ -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 {
+30 -1
View File
@@ -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,11 +21,37 @@ class HomeShell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。
// 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
final info = next.valueOrNull;
if (info != null && info.hasUpdate && info.forceUpdate) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
showUpdateDialog(context, ref.read(appTextProvider), info);
}
});
}
});
final info = ref.watch(updateProvider).valueOrNull;
final dismissedVer = ref.watch(dismissedUpdateVersionProvider);
final showBanner = info != null &&
info.hasUpdate &&
!info.forceUpdate &&
dismissedVer != info.latestVersion;
final Widget body = switch (context.formFactor) {
FormFactor.desktop => const DesktopShell(),
FormFactor.tablet => const TabletShell(),
FormFactor.mobile => const MobileShell(),
};
return Scaffold(backgroundColor: c.bg, body: body);
return Scaffold(
backgroundColor: c.bg,
body: showBanner
? Column(children: [UpdateBanner(info: info), Expanded(child: body)])
: body,
);
}
}
+47 -7
View File
@@ -1,6 +1,7 @@
// 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';
@@ -12,12 +13,10 @@ import '../l10n/strings_zh.dart';
import 'account_providers.dart';
import 'auth_provider.dart';
/// 当前语言(单显)。设置/账户页切换。默认英文(国际化默认语种)。
final localeProvider = StateProvider<AppLang>((ref) => AppLang.en);
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
final appTextProvider = Provider<AppText>((ref) {
switch (ref.watch(localeProvider)) {
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
AppText appTextFor(AppLang lang) {
switch (lang) {
case AppLang.zh:
return const StringsZh();
case AppLang.en:
@@ -31,7 +30,48 @@ final appTextProvider = Provider<AppText>((ref) {
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) => appTextFor(ref.watch(localeProvider)));
/// 主题模式。默认跟随系统;设置页可显式切深色。
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
+4 -3
View File
@@ -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,
);
}
}
+48 -14
View File
@@ -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,34 @@ class AppUpdateInfo {
final bool hasUpdate;
}
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
Timer? _timer;
class UpdateChecker {
const UpdateChecker();
@override
Future<AppUpdateInfo?> build() async {
ref.onDispose(() => _timer?.cancel());
await Future<void>.delayed(_kInitialDelay);
final first = await _check();
_timer = Timer.periodic(_kPollInterval, (_) async {
state = AsyncValue.data(await _check());
});
return first;
}
/// 设置页「检查更新」手动触发:立即查一次并回结果。
Future<AppUpdateInfo?> forceCheck() async {
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 +82,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 +121,14 @@ class UpdateChecker {
}
}
final updateProvider =
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
/// 供 shell 顶部 banner 的显隐判断。
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
/// 按当前平台从服务端 download_urls 里取对应下载直链。
String? platformDownloadUrl(Map<String, String> downloadUrls) {
if (Platform.isMacOS) return downloadUrls['macos'];
+4 -9
View File
@@ -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(
+11 -11
View File
@@ -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)),
),
]),
+1 -1
View File
@@ -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(
+72 -14
View File
@@ -1,13 +1,17 @@
// 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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/update/app_updater.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/app_providers.dart';
import '../state/update_provider.dart';
import 'pangolin_icons.dart';
@@ -63,15 +67,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)),
),
@@ -79,3 +80,60 @@ class _UpdateDialog extends StatelessWidget {
);
}
}
/// jiu 式顶部更新横条(非强制更新用;强制更新仍走不可关闭弹窗 showUpdateDialog)。
/// 「立即更新」App 内下载安装;「×」忽略此版本(banner 隐藏,更新版本会重现)。
class UpdateBanner extends ConsumerWidget {
const UpdateBanner({super.key, required this.info});
final AppUpdateInfo info;
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final t = ref.watch(appTextProvider);
return Material(
color: c.accentSubtle,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 6, 8),
child: Row(children: [
Icon(PangolinIcons.zap, size: 17, color: c.accent),
const SizedBox(width: 10),
Expanded(
child: Text(
t.updateAvailableTitle(info.latestVersion),
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
TextButton(
onPressed: () => unawaited(startInAppUpdate(context, t, info)),
style: TextButton.styleFrom(
backgroundColor: c.accent,
foregroundColor: c.fgOnAccent,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PangolinRadius.full)),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(t.lang.updateNow,
style: PangolinText.caption
.copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)),
),
IconButton(
onPressed: () =>
ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion,
icon: Icon(Icons.close, size: 18, color: c.fg3),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
tooltip: t.updateLater,
),
]),
),
),
);
}
}
+2 -1
View File
@@ -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
+15
View File
@@ -29,3 +29,18 @@ ver_from_tag() {
ref="${ref#"${prefix}"-v}"
printf '%s' "$ref"
}
# flutter_pub_get_retry — pub.flutter-io.cn 常被 GFW 抖断(socket error / exit 69),
# 让 flutter build 内隐式的 pub get 偶发失败(见 iOS build 曾因 google_fonts 拉包
# 断线而挂)。构建前显式预取 + 重试;成功后 build 命中缓存不再拉网。在 flutter
# 项目根目录调用。
flutter_pub_get_retry() {
local i
for i in 1 2 3 4 5; do
if flutter pub get; then return 0; fi
echo "==> flutter pub get 失败(第 ${i}/5 次,pub.flutter-io.cn 抖?),8s 后重试..." >&2
sleep 8
done
echo "==> flutter pub get 5 次仍失败,放弃" >&2
return 1
}
+36
View File
@@ -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
+1
View File
@@ -131,6 +131,7 @@ EOF
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
cd client
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build apk --release --split-per-abi \
"--dart-define=PANGOLIN_API_URL=${API_URL}"
cd ..
+1
View File
@@ -173,6 +173,7 @@ EOF
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
cd "${REPO_ROOT}/client"
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build ipa --release \
--build-name="${VER}" \
--build-number="${BUILD}" \
+1
View File
@@ -182,6 +182,7 @@ echo "==> compile-macos: signing identity '${IDENTITY}'"
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
cd "${REPO_ROOT}/client"
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
cd "$REPO_ROOT"
+23
View File
@@ -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
+16 -1
View File
@@ -51,7 +51,15 @@ VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
rm -f "$ver_file"
echo "==> compile-windows: tag=${TAG} version=${VER}"
# build number 同 android/macos 规则(MAJOR*10000+MINOR*100+PATCH)。
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-windows: tag=${TAG} version=${VER} build=${BUILD}"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
@@ -79,6 +87,12 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
"${BUILD_CLIENT}/.dart_tool" \
"${BUILD_CLIENT}/build"
# 同步 Flutter app 版本到 tag(GNU sed,windows runner)。否则 package_info 卡在
# pubspec committed 值(1.0.48),设置页显示旧版 + 自动更新永远判"有新版"。
# 此前只改了 Inno 安装器 MyAppVersion(下方),App 内部版本没跟上——本次修复。
sed -i "s/^version:.*/version: ${VER}+${BUILD}/" "${BUILD_CLIENT}/pubspec.yaml"
echo "==> compile-windows: pubspec version -> ${VER}+${BUILD}"
# ── [3/4] build ──────────────────────────────────────────────────────────────
# NOTE(controller): `flutter create` on an existing project only fills in
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
@@ -89,6 +103,7 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
# on first real CI run that none of those get clobbered.
pushd "$BUILD_CLIENT" > /dev/null
flutter create --platforms=windows . --project-name pangolin_vpn
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
popd > /dev/null
+1 -1
View File
@@ -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) {
+3 -1
View File
@@ -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, ",") {
+4 -4
View File
@@ -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 {
+5 -1
View File
@@ -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="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>
+5 -3
View File
@@ -34,15 +34,17 @@ export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => v
onChange={(e) => setLang(e.target.value as Lang)}
aria-label="Language"
style={{
border: '1px solid var(--border)',
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
cursor: 'pointer',
borderRadius: 999,
padding: '5px 9px',
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]) => (
+4
View File
@@ -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 },
};
+1 -1
View File
@@ -21,7 +21,7 @@ export const SITE = {
store: { label: 'shop.pangolin.vpn' },
/** Web 用户中心(web/usercenterNext.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
usercenter: 'https://app.yanmeiai.com',
usercenter: 'https://pangolin.yanmeiai.com/user/',
/**
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
+10 -5
View File
@@ -40,19 +40,24 @@
}
.langseg a.on { background: var(--accent); color: var(--fg-on-accent); }
/* 6 语用原生下拉(横排段控会挤);沿用 langseg 的药丸视觉。 */
/* 6 语用原生下拉(横排段控会挤)。圆角矩形 + 柔和边框(原纯 --border 太生硬)。 */
.langsel {
border: 1px solid var(--border);
border: 1px solid color-mix(in oklab, var(--border), transparent 55%);
cursor: pointer;
border-radius: var(--radius-full);
padding: 5px 9px;
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);
}
.langsel:hover { border-color: var(--accent); }
/* 视觉隐藏(无障碍用,当前未强依赖) */
.visually-hidden {