Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4940278ea5 | |||
| 6a46e3109a | |||
| c723bdd53e | |||
| 617b43083d | |||
| f9a87a9993 |
@@ -24,6 +24,16 @@ jobs:
|
|||||||
SITE_URL: https://pangolin.yanmeiai.com
|
SITE_URL: https://pangolin.yanmeiai.com
|
||||||
run: bash scripts/ci/compile-site.sh
|
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
|
- name: Deploy → Cloudflare Pages
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<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
|
<application
|
||||||
android:label="穿山甲"
|
android:label="穿山甲"
|
||||||
android:name="${applicationName}"
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,282 @@ 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 全部界面文案的抽象契约。zh / en 各实现一份。
|
/// 全部界面文案的抽象契约。zh / en 各实现一份。
|
||||||
abstract class AppText {
|
abstract class AppText {
|
||||||
const AppText();
|
const AppText();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class Node {
|
|||||||
String localizedSub(AppLang lang) {
|
String localizedSub(AppLang lang) {
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case NodeTag.streaming:
|
case NodeTag.streaming:
|
||||||
return lang == AppLang.zh ? '流媒体优化' : 'Streaming';
|
return lang.tagStreaming;
|
||||||
case NodeTag.p2p:
|
case NodeTag.p2p:
|
||||||
return 'P2P';
|
return 'P2P';
|
||||||
case NodeTag.none:
|
case NodeTag.none:
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class AccountPage extends ConsumerWidget {
|
|||||||
_CustomRow(
|
_CustomRow(
|
||||||
icon: PangolinIcons.globe,
|
icon: PangolinIcons.globe,
|
||||||
title: t.language,
|
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),
|
Divider(height: 1, color: c.border),
|
||||||
_CustomRow(
|
_CustomRow(
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class _NodeGridTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
||||||
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
|
final unavail = lang.unavailable;
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: down ? 0.55 : 1.0,
|
opacity: down ? 0.55 : 1.0,
|
||||||
child: Material(
|
child: Material(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import '../widgets/update_dialog.dart';
|
|||||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||||
/// 有更新则弹 [showUpdateDialog]。
|
/// 有更新则弹 [showUpdateDialog]。
|
||||||
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
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 (!context.mounted) return;
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
showPangolinToast(context, t.updateCheckFailed);
|
showPangolinToast(context, t.updateCheckFailed);
|
||||||
@@ -72,7 +72,7 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 配置组
|
// 配置组
|
||||||
_Card(children: [
|
_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(
|
_Row(
|
||||||
title: t.darkAppearance,
|
title: t.darkAppearance,
|
||||||
sub: isDark ? t.stateOn : t.followLight,
|
sub: isDark ? t.stateOn : t.followLight,
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ const String kApiBaseUrl = String.fromEnvironment(
|
|||||||
'PANGOLIN_API_URL',
|
'PANGOLIN_API_URL',
|
||||||
defaultValue: 'https://api.yanmeiai.com',
|
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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../state/account_providers.dart';
|
import '../state/account_providers.dart';
|
||||||
|
import 'api_config.dart';
|
||||||
/// 用户中心(网页版)基址。
|
|
||||||
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
|
|
||||||
|
|
||||||
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
|
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
|
||||||
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
|
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 '../core/responsive/form_factor.dart';
|
||||||
import '../pangolin_theme.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 'desktop_shell.dart';
|
||||||
import 'mobile_shell.dart';
|
import 'mobile_shell.dart';
|
||||||
import 'tablet_shell.dart';
|
import 'tablet_shell.dart';
|
||||||
@@ -18,6 +21,23 @@ class HomeShell extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final c = context.pangolin;
|
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) {
|
final Widget body = switch (context.formFactor) {
|
||||||
FormFactor.desktop => const DesktopShell(),
|
FormFactor.desktop => const DesktopShell(),
|
||||||
FormFactor.tablet => const TabletShell(),
|
FormFactor.tablet => const TabletShell(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
|
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
import '../l10n/strings_en.dart';
|
import '../l10n/strings_en.dart';
|
||||||
@@ -12,12 +13,10 @@ import '../l10n/strings_zh.dart';
|
|||||||
import 'account_providers.dart';
|
import 'account_providers.dart';
|
||||||
import 'auth_provider.dart';
|
import 'auth_provider.dart';
|
||||||
|
|
||||||
/// 当前语言(单显)。设置/账户页切换。默认英文(国际化默认语种)。
|
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
|
||||||
final localeProvider = StateProvider<AppLang>((ref) => AppLang.en);
|
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
|
||||||
|
AppText appTextFor(AppLang lang) {
|
||||||
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
|
switch (lang) {
|
||||||
final appTextProvider = Provider<AppText>((ref) {
|
|
||||||
switch (ref.watch(localeProvider)) {
|
|
||||||
case AppLang.zh:
|
case AppLang.zh:
|
||||||
return const StringsZh();
|
return const StringsZh();
|
||||||
case AppLang.en:
|
case AppLang.en:
|
||||||
@@ -31,7 +30,48 @@ final appTextProvider = Provider<AppText>((ref) {
|
|||||||
case AppLang.es:
|
case AppLang.es:
|
||||||
return const StringsEs();
|
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);
|
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
state = const ConnectionState(phase: VpnPhase.connecting);
|
state = const ConnectionState(phase: VpnPhase.connecting);
|
||||||
|
|
||||||
final node = _ref.read(effectiveNodeProvider);
|
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"} '
|
logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} '
|
||||||
'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}');
|
'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}');
|
||||||
|
|
||||||
@@ -212,7 +213,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
state = ConnectionState(
|
state = ConnectionState(
|
||||||
phase: VpnPhase.off,
|
phase: VpnPhase.off,
|
||||||
error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry',
|
error: lang.nodesNotReady,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -244,7 +245,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
state = ConnectionState(
|
state = ConnectionState(
|
||||||
phase: VpnPhase.off,
|
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 拉取最新版本信息,
|
// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性
|
||||||
// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗
|
// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。
|
||||||
// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。
|
// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。
|
||||||
|
// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。
|
||||||
//
|
//
|
||||||
// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与
|
// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。
|
||||||
// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -15,6 +16,15 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||||||
|
|
||||||
import '../services/api_config.dart';
|
import '../services/api_config.dart';
|
||||||
|
|
||||||
|
/// 启动后首次检查的延迟(避开登录/首屏竞争)。
|
||||||
|
const _kInitialDelay = Duration(seconds: 3);
|
||||||
|
|
||||||
|
/// 轮询间隔。
|
||||||
|
const _kPollInterval = Duration(hours: 1);
|
||||||
|
|
||||||
|
/// 单次检查网络超时。
|
||||||
|
const _kCheckTimeout = Duration(seconds: 8);
|
||||||
|
|
||||||
/// 一次更新检查的结果。
|
/// 一次更新检查的结果。
|
||||||
class AppUpdateInfo {
|
class AppUpdateInfo {
|
||||||
const AppUpdateInfo({
|
const AppUpdateInfo({
|
||||||
@@ -34,18 +44,43 @@ class AppUpdateInfo {
|
|||||||
final bool hasUpdate;
|
final bool hasUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。
|
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||||
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
|
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(静默)。
|
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
|
||||||
Future<AppUpdateInfo?> check() async {
|
Future<AppUpdateInfo?> _check() async {
|
||||||
try {
|
try {
|
||||||
final resp = await http
|
final resp = await http
|
||||||
.get(Uri.parse('$kApiBaseUrl/version'))
|
.get(Uri.parse('$kApiBaseUrl/version'))
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(_kCheckTimeout);
|
||||||
if (resp.statusCode != 200) return null;
|
if (resp.statusCode != 200) return null;
|
||||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
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 rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
|
||||||
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
|
||||||
|
|
||||||
final info = await PackageInfo.fromPlatform();
|
final pkg = await PackageInfo.fromPlatform();
|
||||||
final hasUpdate = _isNewer(latestVersion, info.version);
|
final hasUpdate = _isNewer(latestVersion, pkg.version);
|
||||||
|
|
||||||
return AppUpdateInfo(
|
return AppUpdateInfo(
|
||||||
latestVersion: latestVersion,
|
latestVersion: latestVersion,
|
||||||
@@ -95,6 +130,9 @@ class UpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final updateProvider =
|
||||||
|
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||||
|
|
||||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class PlansScreen extends ConsumerWidget {
|
|||||||
Widget body() => plansAsync.when(
|
Widget body() => plansAsync.when(
|
||||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||||
error: (_, __) => Center(
|
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(
|
data: (plans) => ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
||||||
children: [
|
children: [
|
||||||
@@ -140,13 +140,8 @@ class DevicesScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
// 相对时间(最后登录);null → 从未登录。
|
// 相对时间(最后登录);null → 从未登录。
|
||||||
String _rel(DateTime? ts) {
|
String _rel(DateTime? ts) {
|
||||||
final zh = t.lang == AppLang.zh;
|
|
||||||
if (ts == null) return t.devNeverLogin;
|
if (ts == null) return t.devNeverLogin;
|
||||||
final d = DateTime.now().difference(ts.toLocal());
|
return t.lang.relativeTime(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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -158,12 +153,12 @@ class DevicesScreen extends ConsumerWidget {
|
|||||||
Widget content() => devicesAsync.when(
|
Widget content() => devicesAsync.when(
|
||||||
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
||||||
error: (_, __) => Center(
|
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: [
|
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)),
|
Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (devices.isEmpty)
|
if (devices.isEmpty)
|
||||||
Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet',
|
Text(t.lang.noDevices,
|
||||||
style: PangolinText.sm.copyWith(color: c.fg3))
|
style: PangolinText.sm.copyWith(color: c.fg3))
|
||||||
else
|
else
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
bool _sent = false;
|
bool _sent = false;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _pwVisible = false;
|
bool _pwVisible = false;
|
||||||
String? _errorZh;
|
String? _error;
|
||||||
|
|
||||||
final _email = TextEditingController();
|
final _email = TextEditingController();
|
||||||
final _code = TextEditingController();
|
final _code = TextEditingController();
|
||||||
@@ -90,17 +90,17 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
// ── 认证操作(逻辑不变)──────────────────────────────────────────
|
// ── 认证操作(逻辑不变)──────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _sendCode() async {
|
Future<void> _sendCode() async {
|
||||||
setState(() { _loading = true; _errorZh = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
try {
|
try {
|
||||||
await _api.sendCode(_email.text.trim());
|
await _api.sendCode(_email.text.trim());
|
||||||
if (mounted) setState(() { _sent = true; _loading = false; });
|
if (mounted) setState(() { _sent = true; _loading = false; });
|
||||||
} on AuthApiException catch (e) {
|
} 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 {
|
Future<void> _doRegister() async {
|
||||||
setState(() { _loading = true; _errorZh = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
try {
|
try {
|
||||||
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
||||||
final tokens = await _api.register(
|
final tokens = await _api.register(
|
||||||
@@ -113,12 +113,12 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||||
if (mounted) widget.onDone();
|
if (mounted) widget.onDone();
|
||||||
} on AuthApiException catch (e) {
|
} 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 {
|
Future<void> _doLogin() async {
|
||||||
setState(() { _loading = true; _errorZh = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
try {
|
try {
|
||||||
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
final device = (await ref.read(deviceIdentityProvider).meta()).toJson();
|
||||||
final tokens = await _api.login(
|
final tokens = await _api.login(
|
||||||
@@ -132,7 +132,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
|
ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit;
|
||||||
if (mounted) widget.onDone();
|
if (mounted) widget.onDone();
|
||||||
} on AuthApiException catch (e) {
|
} 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;
|
_mode = m;
|
||||||
_step = 0;
|
_step = 0;
|
||||||
_sent = false;
|
_sent = false;
|
||||||
_errorZh = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── UI ──────────────────────────────────────────────────────
|
// ── UI ──────────────────────────────────────────────────────
|
||||||
@@ -278,7 +278,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
_segTabs(c, t),
|
_segTabs(c, t),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
if (_errorZh != null) ...[
|
if (_error != null) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -289,7 +289,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Icon(PangolinIcons.x, size: 16, color: c.danger),
|
Icon(PangolinIcons.x, size: 16, color: c.danger),
|
||||||
const SizedBox(width: 8),
|
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),
|
const SizedBox(height: 14),
|
||||||
@@ -457,7 +457,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
|||||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))),
|
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => setState(() => _step = 0),
|
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)),
|
style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class ServerTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。
|
||||||
final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable';
|
final unavail = lang.unavailable;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: down ? null : onTap,
|
onTap: down ? null : onTap,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// update_dialog.dart — 「发现新版本」更新提示弹窗。
|
// update_dialog.dart — 「发现新版本」更新提示弹窗。
|
||||||
//
|
//
|
||||||
// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链
|
// 点「下载更新」走 App 内下载安装(core/update/app_updater.dart,带进度框,不再开
|
||||||
// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭
|
// 浏览器);Android 装 APK、Windows 跑安装包、macOS 解压提示拖入、iOS 降级外链。
|
||||||
// (无「稍后」按钮、点遮罩/返回也关不掉)。
|
// force_update=true 时不可关闭(无「稍后」按钮、点遮罩/返回也关不掉)。
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/update/app_updater.dart';
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
import '../state/update_provider.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)),
|
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () {
|
||||||
final url = platformDownloadUrl(info.downloadUrls);
|
// 先关本弹窗,用 Navigator 自身 context(关后仍挂载)启动 App 内下载
|
||||||
if (url != null && url.isNotEmpty) {
|
// (它自带进度框)。
|
||||||
final uri = Uri.parse(url);
|
final nav = Navigator.of(context);
|
||||||
if (await canLaunchUrl(uri)) {
|
nav.pop();
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
unawaited(startInAppUpdate(nav.context, t, info));
|
||||||
}
|
|
||||||
}
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
},
|
},
|
||||||
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
|
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 持久化
|
flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
package_info_plus: ^9.0.1
|
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 # 设备名/平台(「我的设备」上报)
|
device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报)
|
||||||
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
|
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
|
||||||
launch_at_startup: ^0.5.1
|
launch_at_startup: ^0.5.1
|
||||||
|
|||||||
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
|
||||||
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.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
r.Use(apierr.Middleware)
|
r.Use(apierr.Middleware)
|
||||||
// CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。
|
// CORS:Web 用户中心(pangolin.yanmeiai.com/user)跨域调 /v1/*;原生端不受影响。
|
||||||
r.Use(httpapi.NewCORS())
|
r.Use(httpapi.NewCORS())
|
||||||
|
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
func NewCORS() func(http.Handler) http.Handler {
|
func NewCORS() func(http.Handler) http.Handler {
|
||||||
raw := os.Getenv("CORS_ORIGINS")
|
raw := os.Getenv("CORS_ORIGINS")
|
||||||
if raw == "" {
|
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{}
|
allowed := map[string]bool{}
|
||||||
for _, o := range strings.Split(raw, ",") {
|
for _, o := range strings.Split(raw, ",") {
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCORS(t *testing.T) {
|
func TestCORS(t *testing.T) {
|
||||||
t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com")
|
t.Setenv("CORS_ORIGINS", "https://pangolin.yanmeiai.com")
|
||||||
mw := NewCORS()
|
mw := NewCORS()
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
h := mw(next)
|
h := mw(next)
|
||||||
|
|
||||||
// 允许的 Origin:补 Allow-Origin,普通请求继续。
|
// 允许的 Origin:补 Allow-Origin,普通请求继续。
|
||||||
r := httptest.NewRequest("POST", "/v1/auth/login", nil)
|
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()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
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)
|
t.Fatalf("allowed origin: want header, got %q", got)
|
||||||
}
|
}
|
||||||
if w.Code != 200 {
|
if w.Code != 200 {
|
||||||
@@ -26,7 +26,7 @@ func TestCORS(t *testing.T) {
|
|||||||
|
|
||||||
// OPTIONS 预检:204,不落到业务。
|
// OPTIONS 预检:204,不落到业务。
|
||||||
r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil)
|
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()
|
w = httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
if w.Code != http.StatusNoContent {
|
if w.Code != http.StatusNoContent {
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="light" suppressHydrationWarning>
|
<html lang="en" data-theme="light" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
{/* 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<UIProvider>{children}</UIProvider>
|
<UIProvider>{children}</UIProvider>
|
||||||
|
|||||||
@@ -34,15 +34,17 @@ export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => v
|
|||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
onChange={(e) => setLang(e.target.value as Lang)}
|
||||||
aria-label="Language"
|
aria-label="Language"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--border)',
|
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
|
||||||
|
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: 999,
|
borderRadius: 10,
|
||||||
padding: '5px 9px',
|
padding: '5px 10px',
|
||||||
fontFamily: 'var(--font-sans)',
|
fontFamily: 'var(--font-sans)',
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: 'var(--bg-subtle)',
|
background: 'var(--bg-subtle)',
|
||||||
color: 'var(--fg2)',
|
color: 'var(--fg2)',
|
||||||
|
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{langs.map(([v, l]) => (
|
{langs.map(([v, l]) => (
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ const nextConfig = {
|
|||||||
output: 'export',
|
output: 'export',
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
trailingSlash: 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 },
|
images: { unoptimized: true },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const SITE = {
|
|||||||
store: { label: 'shop.pangolin.vpn' },
|
store: { label: 'shop.pangolin.vpn' },
|
||||||
|
|
||||||
/** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
|
/** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
|
||||||
usercenter: 'https://app.yanmeiai.com',
|
usercenter: 'https://pangolin.yanmeiai.com/user/',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
||||||
|
|||||||
@@ -40,19 +40,24 @@
|
|||||||
}
|
}
|
||||||
.langseg a.on { background: var(--accent); color: var(--fg-on-accent); }
|
.langseg a.on { background: var(--accent); color: var(--fg-on-accent); }
|
||||||
|
|
||||||
/* 6 语用原生下拉(横排段控会挤);沿用 langseg 的药丸视觉。 */
|
/* 6 语用原生下拉(横排段控会挤)。圆角矩形 + 柔和边框(原纯 --border 太生硬)。 */
|
||||||
.langsel {
|
.langsel {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid color-mix(in oklab, var(--border), transparent 55%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-md);
|
||||||
padding: 5px 9px;
|
padding: 5px 10px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg2);
|
color: var(--fg2);
|
||||||
background: var(--bg-subtle);
|
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 {
|
.visually-hidden {
|
||||||
|
|||||||
Reference in New Issue
Block a user