Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84448b3064 | |||
| 386e0bce90 | |||
| b584188ce2 | |||
| 60cd28e085 | |||
| 11ca7531a3 | |||
| e6b4ec0459 | |||
| 0b86138b55 | |||
| aaffe85a4f |
@@ -34,7 +34,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: mac
|
||||
runs-on: nas
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
# simply picks up whatever artifacts DO exist — an absent "macos"
|
||||
# artifact is not an error there.
|
||||
build-macos:
|
||||
runs-on: mac
|
||||
runs-on: nas
|
||||
continue-on-error: true
|
||||
env:
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
build-ios:
|
||||
runs-on: mac
|
||||
runs-on: nas
|
||||
continue-on-error: true
|
||||
env:
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
# (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。
|
||||
release-deploy:
|
||||
needs: [build-android]
|
||||
runs-on: mac
|
||||
runs-on: nas
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -308,6 +308,24 @@ extension AppLangMisc on AppLang {
|
||||
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 各实现一份。
|
||||
|
||||
@@ -15,10 +15,8 @@ import '../state/settings_provider.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
import '../widgets/update_dialog.dart';
|
||||
|
||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||
/// 有更新则弹 [showUpdateDialog]。
|
||||
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。
|
||||
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
||||
final info = await ref.read(updateProvider.notifier).forceCheck();
|
||||
if (!context.mounted) return;
|
||||
@@ -30,7 +28,10 @@ Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) asy
|
||||
showPangolinToast(context, t.updateUpToDate);
|
||||
return;
|
||||
}
|
||||
await showUpdateDialog(context, t, info);
|
||||
// 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。
|
||||
// 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。
|
||||
ref.read(dismissedUpdateVersionProvider.notifier).state = null;
|
||||
showPangolinToast(context, t.updateAvailableTitle(info.latestVersion));
|
||||
}
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
|
||||
@@ -22,27 +22,36 @@ class HomeShell extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
|
||||
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询);
|
||||
// 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免
|
||||
// rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。
|
||||
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。
|
||||
// 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。
|
||||
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);
|
||||
});
|
||||
if (info != null && info.hasUpdate && info.forceUpdate) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
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) {
|
||||
FormFactor.desktop => const DesktopShell(),
|
||||
FormFactor.tablet => const TabletShell(),
|
||||
FormFactor.mobile => const MobileShell(),
|
||||
};
|
||||
return Scaffold(backgroundColor: c.bg, body: body);
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: showBanner
|
||||
? Column(children: [UpdateBanner(info: info), Expanded(child: body)])
|
||||
: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,6 @@ class AppUpdateInfo {
|
||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
Timer? _timer;
|
||||
bool _dismissed = false;
|
||||
|
||||
/// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。
|
||||
bool get isDismissed => _dismissed;
|
||||
|
||||
@override
|
||||
Future<AppUpdateInfo?> build() async {
|
||||
@@ -58,18 +54,13 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
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;
|
||||
@@ -133,6 +124,11 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||
final updateProvider =
|
||||
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||
|
||||
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
|
||||
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
|
||||
/// 供 shell 顶部 banner 的显隐判断。
|
||||
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/update/app_updater.dart';
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../state/app_providers.dart';
|
||||
import '../state/update_provider.dart';
|
||||
import 'pangolin_icons.dart';
|
||||
|
||||
@@ -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}"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,14 +22,19 @@ rm -rf "${DIST}/user"
|
||||
mkdir -p "${DIST}/user"
|
||||
cp -R "${UC}/." "${DIST}/user/"
|
||||
|
||||
# 2) 合并 _headers:用户中心路径规则前缀 /user,追加到官网 _headers 之后。
|
||||
# 删掉并入产物里那份会被 CF 忽略的 /user/_headers,避免误导。
|
||||
# 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,combine-site.sh 从 usercenter/_headers 重定 ===="
|
||||
# 行首 '/'(路径规则)前缀 /user;'#' 注释与缩进的 header 行不动。
|
||||
sed -E 's#^/#/user/#' "${UC}/_headers"
|
||||
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/* 分域合并"
|
||||
|
||||
@@ -131,6 +131,7 @@ EOF
|
||||
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
|
||||
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
|
||||
cd client
|
||||
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||
flutter build apk --release --split-per-abi \
|
||||
"--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||
cd ..
|
||||
|
||||
@@ -173,6 +173,7 @@ EOF
|
||||
|
||||
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
|
||||
cd "${REPO_ROOT}/client"
|
||||
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||
flutter build ipa --release \
|
||||
--build-name="${VER}" \
|
||||
--build-number="${BUILD}" \
|
||||
|
||||
@@ -182,6 +182,7 @@ echo "==> compile-macos: signing identity '${IDENTITY}'"
|
||||
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
|
||||
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
|
||||
cd "${REPO_ROOT}/client"
|
||||
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
|
||||
@@ -51,7 +51,15 @@ VER=""
|
||||
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
|
||||
rm -f "$ver_file"
|
||||
|
||||
echo "==> compile-windows: tag=${TAG} version=${VER}"
|
||||
# build number 同 android/macos 规则(MAJOR*10000+MINOR*100+PATCH)。
|
||||
MAJOR="${VER%%.*}"
|
||||
_REST="${VER#*.}"
|
||||
MINOR="${_REST%%.*}"
|
||||
PATCH="${_REST#*.}"
|
||||
PATCH="${PATCH%%-*}"
|
||||
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
|
||||
echo "==> compile-windows: tag=${TAG} version=${VER} build=${BUILD}"
|
||||
|
||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||
|
||||
@@ -79,6 +87,12 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
|
||||
"${BUILD_CLIENT}/.dart_tool" \
|
||||
"${BUILD_CLIENT}/build"
|
||||
|
||||
# 同步 Flutter app 版本到 tag(GNU sed,windows runner)。否则 package_info 卡在
|
||||
# pubspec committed 值(1.0.48),设置页显示旧版 + 自动更新永远判"有新版"。
|
||||
# 此前只改了 Inno 安装器 MyAppVersion(下方),App 内部版本没跟上——本次修复。
|
||||
sed -i "s/^version:.*/version: ${VER}+${BUILD}/" "${BUILD_CLIENT}/pubspec.yaml"
|
||||
echo "==> compile-windows: pubspec version -> ${VER}+${BUILD}"
|
||||
|
||||
# ── [3/4] build ──────────────────────────────────────────────────────────────
|
||||
# NOTE(controller): `flutter create` on an existing project only fills in
|
||||
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
|
||||
@@ -89,6 +103,7 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
|
||||
# on first real CI run that none of those get clobbered.
|
||||
pushd "$BUILD_CLIENT" > /dev/null
|
||||
flutter create --platforms=windows . --project-name pangolin_vpn
|
||||
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||
popd > /dev/null
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ func NewCORS() func(http.Handler) http.Handler {
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
// X-Refresh-Token:静默续期/登出把 refresh token 放在此自定义头里
|
||||
// (header token 方案,见 web/usercenter/lib/api/http.ts)。不放行则浏览器
|
||||
// 预检拦截 /v1/auth/refresh → 登录后一刷新即掉线(login 不带此头故不受影响)。
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Refresh-Token")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,11 @@ func TestCORS(t *testing.T) {
|
||||
if w.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||
t.Fatalf("preflight missing Allow-Methods")
|
||||
}
|
||||
// 静默续期/登出把 refresh token 放 X-Refresh-Token 头,必须在允许头里,
|
||||
// 否则浏览器预检拦截 /v1/auth/refresh → 登录后一刷新即掉线。
|
||||
if ah := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(ah, "X-Refresh-Token") {
|
||||
t.Fatalf("preflight Allow-Headers must include X-Refresh-Token, got %q", ah)
|
||||
}
|
||||
|
||||
// 未白名单 Origin:不补 Allow-Origin。
|
||||
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang)
|
||||
import React from 'react';
|
||||
'use client';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { Lang } from '../lib/i18n';
|
||||
|
||||
export const card: React.CSSProperties = {
|
||||
@@ -22,34 +23,110 @@ export const input: React.CSSProperties = {
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const LANGS: [Lang, string][] = [
|
||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||
];
|
||||
|
||||
// 自定义下拉:原生 <select> 的弹层由系统定位(macOS 锚在选中项、会"漂移"),
|
||||
// 改成自控菜单(按钮 + 绝对定位列表),不漂移、样式统一、跨端一致。
|
||||
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||
// 6 语言用原生下拉(段控横排会挤)。默认 en。
|
||||
const langs: [Lang, string][] = [
|
||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||
];
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const current = LANGS.find(([v]) => v === lang)?.[1] ?? String(lang);
|
||||
return (
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as Lang)}
|
||||
aria-label="Language"
|
||||
style={{
|
||||
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
|
||||
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 10,
|
||||
padding: '5px 10px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--fg2)',
|
||||
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||
}}
|
||||
>
|
||||
{langs.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label="Language"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 10,
|
||||
padding: '5px 10px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--fg2)',
|
||||
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||
}}
|
||||
>
|
||||
<span>{current}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4"
|
||||
strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
right: 0,
|
||||
minWidth: 132,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)',
|
||||
padding: 5,
|
||||
zIndex: 60,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{LANGS.map(([v, l]) => {
|
||||
const on = v === lang;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
onClick={() => { setLang(v); setOpen(false); }}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent',
|
||||
color: on ? 'var(--accent)' : 'var(--fg1)',
|
||||
fontWeight: on ? 700 : 500,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 13,
|
||||
padding: '8px 11px',
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
|
||||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
|
||||
@@ -29,6 +29,17 @@ function Mark() {
|
||||
export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const langRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') setLangOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||||
}, [langOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
@@ -64,16 +75,36 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<select
|
||||
class="langsel"
|
||||
value={lang}
|
||||
onChange={(e) => { window.location.href = langHref(e.target.value); }}
|
||||
aria-label="Language"
|
||||
>
|
||||
{langs.map(([code, label]) => (
|
||||
<option key={code} value={code}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
class="langsel"
|
||||
onClick={() => setLangOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={langOpen}
|
||||
aria-label="Language"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||
style={{ transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{langOpen && (
|
||||
<div role="listbox" style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 132, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', padding: 5, zIndex: 60, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{langs.map(([code, label]) => {
|
||||
const on = code === lang;
|
||||
return (
|
||||
<a key={code} role="option" aria-selected={on} href={langHref(code)}
|
||||
style={{ textAlign: 'left', textDecoration: 'none', background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', color: on ? 'var(--accent)' : 'var(--fg1)', fontWeight: on ? 700 : 500, fontFamily: 'var(--font-sans)', fontSize: 13, padding: '8px 11px', borderRadius: 8, whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
|
||||
Reference in New Issue
Block a user