Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b584188ce2 | |||
| 60cd28e085 | |||
| 11ca7531a3 | |||
| e6b4ec0459 | |||
| 0b86138b55 | |||
| aaffe85a4f | |||
| 4940278ea5 | |||
| 6a46e3109a |
@@ -34,7 +34,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android:
|
build-android:
|
||||||
runs-on: mac
|
runs-on: nas
|
||||||
env:
|
env:
|
||||||
GOPROXY: https://goproxy.cn,direct
|
GOPROXY: https://goproxy.cn,direct
|
||||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
# simply picks up whatever artifacts DO exist — an absent "macos"
|
# simply picks up whatever artifacts DO exist — an absent "macos"
|
||||||
# artifact is not an error there.
|
# artifact is not an error there.
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: mac
|
runs-on: nas
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
path: dist/
|
path: dist/
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: mac
|
runs-on: nas
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||||
@@ -160,7 +160,7 @@ jobs:
|
|||||||
# (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。
|
# (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。
|
||||||
release-deploy:
|
release-deploy:
|
||||||
needs: [build-android]
|
needs: [build-android]
|
||||||
runs-on: mac
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -24,19 +24,18 @@ 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 }}
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
run: bash scripts/ci/deploy-site.sh
|
run: bash scripts/ci/deploy-site.sh
|
||||||
|
|
||||||
# 用户中心(web/usercenter → CF Pages pangolin-usercenter / app.yanmeiai.com)。
|
|
||||||
# 与官网同一发版(site-v*),避免两处各自漂移。⚠️ 必须 http 模式构建(见脚本)。
|
|
||||||
- name: Compile (用户中心 Next.js)
|
|
||||||
run: bash scripts/ci/compile-usercenter.sh
|
|
||||||
|
|
||||||
- name: Deploy usercenter → Cloudflare Pages
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
run: bash scripts/ci/deploy-usercenter.sh
|
|
||||||
|
|||||||
@@ -308,6 +308,24 @@ extension AppLangMisc on AppLang {
|
|||||||
return 'Cancelar';
|
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 各实现一份。
|
/// 全部界面文案的抽象契约。zh / en 各实现一份。
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ import '../state/settings_provider.dart';
|
|||||||
import '../state/update_provider.dart';
|
import '../state/update_provider.dart';
|
||||||
import '../widgets/pangolin_icons.dart';
|
import '../widgets/pangolin_icons.dart';
|
||||||
import '../widgets/pangolin_toast.dart';
|
import '../widgets/pangolin_toast.dart';
|
||||||
import '../widgets/update_dialog.dart';
|
|
||||||
|
|
||||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||||
/// 有更新则弹 [showUpdateDialog]。
|
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。
|
||||||
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(updateProvider.notifier).forceCheck();
|
final info = await ref.read(updateProvider.notifier).forceCheck();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -30,7 +28,10 @@ Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) asy
|
|||||||
showPangolinToast(context, t.updateUpToDate);
|
showPangolinToast(context, t.updateUpToDate);
|
||||||
return;
|
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 {
|
class SettingsPage extends ConsumerWidget {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -22,27 +22,36 @@ class HomeShell extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
|
|
||||||
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询);
|
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。
|
||||||
// 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免
|
// 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。
|
||||||
// rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。
|
|
||||||
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
|
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
|
||||||
final info = next.valueOrNull;
|
final info = next.valueOrNull;
|
||||||
if (info == null || !info.hasUpdate) return;
|
if (info != null && info.hasUpdate && info.forceUpdate) {
|
||||||
final notifier = ref.read(updateProvider.notifier);
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (notifier.isDismissed) return;
|
if (context.mounted) {
|
||||||
if (!info.forceUpdate) notifier.dismiss();
|
showUpdateDialog(context, ref.read(appTextProvider), info);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
}
|
||||||
if (!context.mounted) return;
|
});
|
||||||
showUpdateDialog(context, ref.read(appTextProvider), info);
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
ref.watch(updateProvider); // 激活 provider(惰性 build)
|
|
||||||
|
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) {
|
final Widget body = switch (context.formFactor) {
|
||||||
FormFactor.desktop => const DesktopShell(),
|
FormFactor.desktop => const DesktopShell(),
|
||||||
FormFactor.tablet => const TabletShell(),
|
FormFactor.tablet => const TabletShell(),
|
||||||
FormFactor.mobile => const MobileShell(),
|
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,10 +47,6 @@ class AppUpdateInfo {
|
|||||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _dismissed = false;
|
|
||||||
|
|
||||||
/// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。
|
|
||||||
bool get isDismissed => _dismissed;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppUpdateInfo?> build() async {
|
Future<AppUpdateInfo?> build() async {
|
||||||
@@ -58,18 +54,13 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
|||||||
await Future<void>.delayed(_kInitialDelay);
|
await Future<void>.delayed(_kInitialDelay);
|
||||||
final first = await _check();
|
final first = await _check();
|
||||||
_timer = Timer.periodic(_kPollInterval, (_) async {
|
_timer = Timer.periodic(_kPollInterval, (_) async {
|
||||||
_dismissed = false; // 新一轮允许再次提示
|
|
||||||
state = AsyncValue.data(await _check());
|
state = AsyncValue.data(await _check());
|
||||||
});
|
});
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户点「稍后」——本次会话内不再自动弹(直到下轮轮询)。
|
/// 设置页「检查更新」手动触发:立即查一次并回结果。
|
||||||
void dismiss() => _dismissed = true;
|
|
||||||
|
|
||||||
/// 设置页「检查更新」手动触发:立即查一次并回结果(不受 dismiss 影响)。
|
|
||||||
Future<AppUpdateInfo?> forceCheck() async {
|
Future<AppUpdateInfo?> forceCheck() async {
|
||||||
_dismissed = false;
|
|
||||||
final info = await _check();
|
final info = await _check();
|
||||||
state = AsyncValue.data(info);
|
state = AsyncValue.data(info);
|
||||||
return info;
|
return info;
|
||||||
@@ -133,6 +124,11 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
|||||||
final updateProvider =
|
final updateProvider =
|
||||||
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||||
|
|
||||||
|
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
|
||||||
|
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
|
||||||
|
/// 供 shell 顶部 banner 的显隐判断。
|
||||||
|
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
/// 按当前平台从服务端 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'];
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../core/update/app_updater.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/app_providers.dart';
|
||||||
import '../state/update_provider.dart';
|
import '../state/update_provider.dart';
|
||||||
import 'pangolin_icons.dart';
|
import 'pangolin_icons.dart';
|
||||||
|
|
||||||
@@ -78,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,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,3 +29,18 @@ ver_from_tag() {
|
|||||||
ref="${ref#"${prefix}"-v}"
|
ref="${ref#"${prefix}"-v}"
|
||||||
printf '%s' "$ref"
|
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
|
||||||
|
}
|
||||||
|
|||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/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 的 CSP。⚠️ CF Pages _headers 是【叠加】的:/* 与 /user/* 都匹配
|
||||||
|
# /user/,两条 CSP 都会下发,浏览器对多条 CSP 取【交集(最严)】→ 官网严格 CSP
|
||||||
|
# 的「style-src 无 unsafe-inline / connect-src 'self'」赢,用户中心的内联样式被拦、
|
||||||
|
# 连 API 被拦 → 页面裸奔且登录失败。故在 /user/* 里用 `! Content-Security-Policy`
|
||||||
|
# 先【删掉】/* 继承来的官网 CSP,再设用户中心自己的(须排在 /* 之后:先加后删)。
|
||||||
|
# 其余安全头(HSTS/X-Frame/…)/* 与用户中心一致,沿用 /* 即可,不重复。
|
||||||
|
rm -f "${DIST}/user/_headers"
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# ==== 用户中心(/user/*):删掉官网 /* 继承的 CSP,换用用户中心自己的 ===="
|
||||||
|
echo "/user/*"
|
||||||
|
echo " ! Content-Security-Policy"
|
||||||
|
grep -iE '^[[:space:]]*Content-Security-Policy:' "${UC}/_headers"
|
||||||
|
} >> "${DIST}/_headers"
|
||||||
|
|
||||||
|
echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并"
|
||||||
|
ls -la "${DIST}/user" | head
|
||||||
@@ -131,6 +131,7 @@ EOF
|
|||||||
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
|
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
|
||||||
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
|
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
|
||||||
cd client
|
cd client
|
||||||
|
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||||
flutter build apk --release --split-per-abi \
|
flutter build apk --release --split-per-abi \
|
||||||
"--dart-define=PANGOLIN_API_URL=${API_URL}"
|
"--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||||
cd ..
|
cd ..
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ EOF
|
|||||||
|
|
||||||
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
|
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
|
||||||
cd "${REPO_ROOT}/client"
|
cd "${REPO_ROOT}/client"
|
||||||
|
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||||
flutter build ipa --release \
|
flutter build ipa --release \
|
||||||
--build-name="${VER}" \
|
--build-name="${VER}" \
|
||||||
--build-number="${BUILD}" \
|
--build-number="${BUILD}" \
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ echo "==> compile-macos: signing identity '${IDENTITY}'"
|
|||||||
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
|
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
|
||||||
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
|
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
|
||||||
cd "${REPO_ROOT}/client"
|
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}"
|
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# compile-usercenter.sh — build the Next.js 用户中心 (web/usercenter) as a static
|
# compile-usercenter.sh — build the Next.js 用户中心 (web/usercenter) as a static
|
||||||
# export (output: web/usercenter/out/). Deployed to Cloudflare Pages
|
# export (output: web/usercenter/out/, basePath=/user)。由 combine-site.sh 并入官网
|
||||||
# (project pangolin-usercenter, 自定义域 app.yanmeiai.com) by deploy-usercenter.sh.
|
# dist/user/,随官网一起部署到 CF Pages pangolin-site(pangolin.yanmeiai.com/user/)。
|
||||||
|
# 域名迁移:原独立子域 app.yanmeiai.com 已停用。
|
||||||
#
|
#
|
||||||
# ⚠️ 必须以「真实 API」模式构建:NEXT_PUBLIC_API_MODE=http。否则 lib/api/client.ts
|
# ⚠️ 必须以「真实 API」模式构建:NEXT_PUBLIC_API_MODE=http。否则 lib/api/client.ts
|
||||||
# 默认落到 mock(内存假数据),部署上去登录/订阅全是演示数据。API 域名是公开信息
|
# 默认落到 mock(内存假数据),部署上去登录/订阅全是演示数据。API 域名是公开信息
|
||||||
|
|||||||
@@ -51,7 +51,15 @@ VER=""
|
|||||||
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
|
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
|
||||||
rm -f "$ver_file"
|
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}"
|
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}/.dart_tool" \
|
||||||
"${BUILD_CLIENT}/build"
|
"${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 ──────────────────────────────────────────────────────────────
|
# ── [3/4] build ──────────────────────────────────────────────────────────────
|
||||||
# NOTE(controller): `flutter create` on an existing project only fills in
|
# NOTE(controller): `flutter create` on an existing project only fills in
|
||||||
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
|
# 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.
|
# on first real CI run that none of those get clobbered.
|
||||||
pushd "$BUILD_CLIENT" > /dev/null
|
pushd "$BUILD_CLIENT" > /dev/null
|
||||||
flutter create --platforms=windows . --project-name pangolin_vpn
|
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}"
|
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||||
popd > /dev/null
|
popd > /dev/null
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy-usercenter.sh — 部署 Next.js 用户中心 (web/usercenter/out/) 到 Cloudflare
|
|
||||||
# Pages(项目 pangolin-usercenter,自定义域 app.yanmeiai.com)。纯静态导出,全程
|
|
||||||
# HTTPS,不落 VPS,与节点 :443(sing-box)无冲突。
|
|
||||||
#
|
|
||||||
# 需环境变量:
|
|
||||||
# CLOUDFLARE_API_TOKEN 带 Account > Cloudflare Pages > Edit 权限的 CF token
|
|
||||||
# CLOUDFLARE_ACCOUNT_ID CF 账户 ID
|
|
||||||
# 由 compile-usercenter.sh 先产出 web/usercenter/out/;从 repo 根调用。
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ ! -d web/usercenter/out ]; then
|
|
||||||
echo "==> deploy-usercenter: web/usercenter/out/ 不存在 — 拒绝部署" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! find web/usercenter/out -mindepth 1 -print -quit | grep -q .; then
|
|
||||||
echo "==> deploy-usercenter: web/usercenter/out/ 为空 — 拒绝部署" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
: "${CLOUDFLARE_API_TOKEN:?deploy-usercenter: CLOUDFLARE_API_TOKEN 未设(需带 Pages:Edit)}"
|
|
||||||
: "${CLOUDFLARE_ACCOUNT_ID:?deploy-usercenter: CLOUDFLARE_ACCOUNT_ID 未设}"
|
|
||||||
|
|
||||||
echo "==> deploy-usercenter: wrangler pages deploy → project pangolin-usercenter (branch main)"
|
|
||||||
npx --yes wrangler@4 pages deploy web/usercenter/out \
|
|
||||||
--project-name=pangolin-usercenter --branch=main --commit-dirty=true
|
|
||||||
|
|
||||||
echo "==> deploy-usercenter: done"
|
|
||||||
@@ -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