Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f37f1f3b59 | |||
| c9e266b89a | |||
| 0f05385995 | |||
| fc22300937 | |||
| 4a0c5c4921 |
+19
-10
@@ -16,12 +16,19 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# runner 分配:
|
||||||
|
# · runs-on: ubuntu-latest —— 在 catthehacker 容器里跑,仅用于纯 bash 扫描
|
||||||
|
# (redline/cleartext/portable-sql);容器内**不能**嵌套 `docker run`(DinD 挂载
|
||||||
|
# 失败,$PWD 在宿主不存在),故套 docker 的 job 不能用它。
|
||||||
|
# · runs-on: nas —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主
|
||||||
|
# 真 docker(非嵌套),可正常拉/跑 node/golang/flutter/python/shellcheck 镜像。
|
||||||
|
# golden 保留 ghcr.io/cirruslabs/flutter Linux 容器 → 与入库基线渲染一致。
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
|
# ── Job 1: Lint (shellcheck) ─────────────────────────────────────────────
|
||||||
lint:
|
lint:
|
||||||
name: Lint — shellcheck
|
name: Lint — shellcheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -74,7 +81,7 @@ jobs:
|
|||||||
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
|
# ── Job 2: OpenAPI Sync Check ────────────────────────────────────────────
|
||||||
openapi-check:
|
openapi-check:
|
||||||
name: OpenAPI Sync Check
|
name: OpenAPI Sync Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -113,7 +120,7 @@ jobs:
|
|||||||
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
|
# ── Job 4: Flutter 客户端(分析 + 单测/组件测试)────────────────────────
|
||||||
flutter-client:
|
flutter-client:
|
||||||
name: Flutter — analyze + test
|
name: Flutter — analyze + test
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -149,7 +156,7 @@ jobs:
|
|||||||
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
|
# (改了 design/colors_and_type.css 没重生成,或手改了生成物)。
|
||||||
codegen-drift:
|
codegen-drift:
|
||||||
name: Codegen Drift — token 生成物未漂移
|
name: Codegen Drift — token 生成物未漂移
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -163,7 +170,7 @@ jobs:
|
|||||||
|
|
||||||
ds-flow:
|
ds-flow:
|
||||||
name: DS-flow — 原型/跨端同源/代码色单源闸
|
name: DS-flow — 原型/跨端同源/代码色单源闸
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -182,7 +189,7 @@ jobs:
|
|||||||
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
|
# -tags integration(需 docker 起 mysql/redis),见 go-integration job。
|
||||||
go-server:
|
go-server:
|
||||||
name: Go — build + test
|
name: Go — build + test
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -208,7 +215,7 @@ jobs:
|
|||||||
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
|
# 详见 scripts/e2e-smoke.sh + server/test/e2e/。
|
||||||
e2e-smoke:
|
e2e-smoke:
|
||||||
name: E2E Smoke — L4 进程级端到端
|
name: E2E Smoke — L4 进程级端到端
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -221,7 +228,9 @@ jobs:
|
|||||||
-v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \
|
-v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \
|
||||||
-v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \
|
-v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \
|
||||||
golang:1.25 \
|
golang:1.25 \
|
||||||
bash -c "apt-get update -qq && apt-get install -y -qq openssl curl python3 >/dev/null 2>&1 && bash scripts/e2e-smoke.sh"
|
bash -c "bash scripts/e2e-smoke.sh"
|
||||||
|
# 注:openssl/curl/python3 已在 golang:1.25 镜像内,无需 apt 安装
|
||||||
|
# (原 apt-get 会走 Docker Desktop 代理→本机 clash 死口,徒增网络脆性)。
|
||||||
|
|
||||||
# ── Job 10: Go 集成测试 (L2:真 mysql8/redis 经 testcontainers)──────────
|
# ── Job 10: Go 集成测试 (L2:真 mysql8/redis 经 testcontainers)──────────
|
||||||
# 跨库可移植(支柱 3)+ 按租户流量记账(usage)+ 配额(devices)+ 兑换(codes)+
|
# 跨库可移植(支柱 3)+ 按租户流量记账(usage)+ 配额(devices)+ 兑换(codes)+
|
||||||
@@ -232,7 +241,7 @@ jobs:
|
|||||||
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
|
# · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。
|
||||||
go-integration:
|
go-integration:
|
||||||
name: Go — integration (mysql/redis testcontainers)
|
name: Go — integration (mysql/redis testcontainers)
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -247,7 +256,7 @@ jobs:
|
|||||||
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
|
# tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。
|
||||||
golden:
|
golden:
|
||||||
name: Golden — 视觉回归 (全量:components/auth/desktop/tablet)
|
name: Golden — 视觉回归 (全量:components/auth/desktop/tablet)
|
||||||
runs-on: ubuntu-latest
|
runs-on: nas
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||||
|
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||||
import 'package:pangolin_vpn/models/node.dart';
|
import 'package:pangolin_vpn/models/node.dart';
|
||||||
import 'package:pangolin_vpn/services/connect_api.dart';
|
import 'package:pangolin_vpn/services/connect_api.dart';
|
||||||
import 'package:pangolin_vpn/services/device_identity.dart';
|
import 'package:pangolin_vpn/services/device_identity.dart';
|
||||||
|
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||||
import 'package:pangolin_vpn/state/connection_provider.dart';
|
import 'package:pangolin_vpn/state/connection_provider.dart';
|
||||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||||
import 'package:pangolin_vpn/state/quota_provider.dart';
|
import 'package:pangolin_vpn/state/quota_provider.dart';
|
||||||
|
|
||||||
|
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
||||||
|
class _FixedLocale extends LocaleNotifier {
|
||||||
|
_FixedLocale(AppLang lang) {
|
||||||
|
state = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。
|
// 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。
|
||||||
class _FakeBridge implements VpnBridge {
|
class _FakeBridge implements VpnBridge {
|
||||||
final _status = StreamController<VpnStatus>.broadcast();
|
final _status = StreamController<VpnStatus>.broadcast();
|
||||||
@@ -94,6 +103,7 @@ void main() {
|
|||||||
ProviderContainer makeContainer(_FakeBridge bridge,
|
ProviderContainer makeContainer(_FakeBridge bridge,
|
||||||
{NodesNotifier Function()? nodes, DateTime Function()? now}) =>
|
{NodesNotifier Function()? nodes, DateTime Function()? now}) =>
|
||||||
ProviderContainer(overrides: [
|
ProviderContainer(overrides: [
|
||||||
|
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
||||||
vpnBridgeProvider.overrideWithValue(bridge),
|
vpnBridgeProvider.overrideWithValue(bridge),
|
||||||
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
|
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
|
||||||
deviceIdentityProvider.overrideWithValue(DeviceIdentity(store: _MemKV())),
|
deviceIdentityProvider.overrideWithValue(DeviceIdentity(store: _MemKV())),
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||||
|
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||||
import 'package:pangolin_vpn/models/node.dart';
|
import 'package:pangolin_vpn/models/node.dart';
|
||||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||||
import 'package:pangolin_vpn/screens/nodes_page.dart';
|
import 'package:pangolin_vpn/screens/nodes_page.dart';
|
||||||
|
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||||
import 'package:pangolin_vpn/state/connection_provider.dart';
|
import 'package:pangolin_vpn/state/connection_provider.dart';
|
||||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||||
import 'package:pangolin_vpn/widgets/smart_select_card.dart';
|
import 'package:pangolin_vpn/widgets/smart_select_card.dart';
|
||||||
@@ -31,11 +33,19 @@ const _nodes = [
|
|||||||
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
|
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
||||||
|
class _FixedLocale extends LocaleNotifier {
|
||||||
|
_FixedLocale(AppLang lang) {
|
||||||
|
state = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(disableGoogleFontsFetching);
|
setUpAll(disableGoogleFontsFetching);
|
||||||
const t = StringsZh();
|
const t = StringsZh();
|
||||||
|
|
||||||
ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [
|
ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [
|
||||||
|
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
||||||
nodesProvider.overrideWith(_StubNodes.new),
|
nodesProvider.overrideWith(_StubNodes.new),
|
||||||
vpnBridgeProvider.overrideWithValue(bridge),
|
vpnBridgeProvider.overrideWithValue(bridge),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||||
|
import 'package:pangolin_vpn/l10n/app_text.dart';
|
||||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||||
import 'package:pangolin_vpn/models/node.dart';
|
import 'package:pangolin_vpn/models/node.dart';
|
||||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||||
@@ -20,6 +21,7 @@ import 'package:pangolin_vpn/screens/stats_page.dart';
|
|||||||
import 'package:pangolin_vpn/services/api_client.dart';
|
import 'package:pangolin_vpn/services/api_client.dart';
|
||||||
import 'package:pangolin_vpn/services/token_store.dart';
|
import 'package:pangolin_vpn/services/token_store.dart';
|
||||||
import 'package:pangolin_vpn/state/account_providers.dart';
|
import 'package:pangolin_vpn/state/account_providers.dart';
|
||||||
|
import 'package:pangolin_vpn/state/app_providers.dart';
|
||||||
import 'package:pangolin_vpn/state/auth_provider.dart';
|
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||||
import 'package:pangolin_vpn/widgets/period_card.dart';
|
import 'package:pangolin_vpn/widgets/period_card.dart';
|
||||||
@@ -101,6 +103,13 @@ Finder _metric(String period, int i, String value, String unit) => find.byWidget
|
|||||||
w.metrics[i].unit == unit,
|
w.metrics[i].unit == unit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
||||||
|
class _FixedLocale extends LocaleNotifier {
|
||||||
|
_FixedLocale(AppLang lang) {
|
||||||
|
state = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(disableGoogleFontsFetching);
|
setUpAll(disableGoogleFontsFetching);
|
||||||
const t = StringsZh();
|
const t = StringsZh();
|
||||||
@@ -111,6 +120,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(ProviderScope(
|
await tester.pumpWidget(ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
||||||
tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()),
|
tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()),
|
||||||
apiClientProvider.overrideWithValue(ApiClient(
|
apiClientProvider.overrideWithValue(ApiClient(
|
||||||
baseUrl: 'http://test.local',
|
baseUrl: 'http://test.local',
|
||||||
|
|||||||
Vendored
+1
@@ -57,6 +57,7 @@
|
|||||||
'compass': '<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>',
|
'compass': '<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>',
|
||||||
'chart-no-axes-column': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
'chart-no-axes-column': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
||||||
'chart': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
'chart': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
|
||||||
|
'home': '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
|
||||||
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
|
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
|
||||||
'laptop': '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>',
|
'laptop': '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>',
|
||||||
'monitor-smartphone': '<path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/>',
|
'monitor-smartphone': '<path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/>',
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Register → trial subscription must exist for 7 days.
|
// 2. Register → trial subscription must exist for 7 days.
|
||||||
pair, apiErr := svc.Register(ctx, email, code, pw)
|
pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{})
|
||||||
if apiErr != nil {
|
if apiErr != nil {
|
||||||
t.Fatalf("Register: %v", apiErr)
|
t.Fatalf("Register: %v", apiErr)
|
||||||
}
|
}
|
||||||
@@ -170,12 +170,12 @@ func TestIntegration_FullChain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Force a fresh code regardless of rate limit.
|
// Force a fresh code regardless of rate limit.
|
||||||
_ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err()
|
_ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err()
|
||||||
if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrCodeInvalid.Code {
|
if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||||
t.Fatalf("want code_invalid (anti-enumeration), got %v", e)
|
t.Fatalf("want code_invalid (anti-enumeration), got %v", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Login.
|
// 4. Login.
|
||||||
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7")
|
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7", DeviceMeta{})
|
||||||
if apiErr != nil {
|
if apiErr != nil {
|
||||||
t.Fatalf("Login: %v", apiErr)
|
t.Fatalf("Login: %v", apiErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ package devices
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wangjia/pangolin/server/internal/codes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ctxKey is a private type for context keys to avoid collisions.
|
// ctxKey is a private type for context keys to avoid collisions.
|
||||||
type ctxKey string
|
type ctxKey string
|
||||||
|
|
||||||
// CtxKeyUserID is the context key under which the authenticated user's
|
// CtxKeyUserID is the context key under which the authenticated user's
|
||||||
// internal int64 ID is stored by the JWT auth middleware (module #2).
|
// internal int64 ID is stored by the JWT auth middleware (auth.RequireAuth).
|
||||||
//
|
//
|
||||||
// It mirrors the key used by the codes module so that, once the auth
|
// It is the single canonical key shared with the codes and auth modules
|
||||||
// middleware lands, a single canonical key can be reconciled across modules.
|
// (auth.UserIDFromContext reads codes.CtxKeyUserID). Aliasing it here — rather
|
||||||
const CtxKeyUserID ctxKey = "user_id"
|
// than declaring a distinct devices.ctxKey("user_id") — ensures the devices
|
||||||
|
// middleware/handlers resolve the same value the auth middleware injects.
|
||||||
|
const CtxKeyUserID = codes.CtxKeyUserID
|
||||||
|
|
||||||
// ctxKeyPlan is the context key under which the resolved subscription Plan is
|
// ctxKeyPlan is the context key under which the resolved subscription Plan is
|
||||||
// stored by SubscriptionMiddleware.
|
// stored by SubscriptionMiddleware.
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func applySchema(db *sql.DB) error {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||||
|
|
||||||
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
|
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
|
||||||
VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
VALUES ('free', 1, 10, TRUE), ('pro', 3, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
||||||
}
|
}
|
||||||
for _, stmt := range stmts {
|
for _, stmt := range stmts {
|
||||||
if _, err := db.Exec(stmt); err != nil {
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func applySchema(db *sql.DB) error {
|
|||||||
bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
minutes_used INT NOT NULL DEFAULT 0,
|
minutes_used INT NOT NULL DEFAULT 0,
|
||||||
ad_unlocked_at DATETIME(6) NULL,
|
ad_unlocked_at DATETIME(6) NULL,
|
||||||
|
ad_bonus_minutes INT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (user_id, date)
|
PRIMARY KEY (user_id, date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||||
`CREATE TABLE IF NOT EXISTS usage_hourly (
|
`CREATE TABLE IF NOT EXISTS usage_hourly (
|
||||||
@@ -433,11 +434,16 @@ func TestIntAdsUnlockAccumulates(t *testing.T) {
|
|||||||
t.Errorf("remaining after 2 ads=%d, want 30", rem)
|
t.Errorf("remaining after 2 ads=%d, want 30", rem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exactly one unlock timestamp.
|
// Additive model: the two ads accumulate into a single day row's
|
||||||
var n int
|
// ad_bonus_minutes (10 + 10 = 20). (ad_unlocked_at is legacy from the old
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM usage_daily WHERE user_id=? AND ad_unlocked_at IS NOT NULL`, uid).Scan(&n)
|
// per-day boolean unlock and is no longer stamped by UnlockAd.)
|
||||||
if n != 1 {
|
var rows, bonus int
|
||||||
t.Errorf("expected 1 unlocked day, got %d", n)
|
db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(ad_bonus_minutes),0) FROM usage_daily WHERE user_id=?`, uid).Scan(&rows, &bonus)
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("expected 1 usage_daily row, got %d", rows)
|
||||||
|
}
|
||||||
|
if bonus != 20 {
|
||||||
|
t.Errorf("expected ad_bonus_minutes=20, got %d", bonus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-41
@@ -327,12 +327,12 @@ header .header-meta{color:var(--fg-soft)}
|
|||||||
<header>
|
<header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>feature+windows — 项目 TODO</h1>
|
<h1>feature+windows — 项目 TODO</h1>
|
||||||
<div class="header-meta">生成于 2026-07-07 · 真相源 todo/todo.json</div>
|
<div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-pill"><strong>18</strong>全部</div>
|
<div class="stat-pill"><strong>18</strong>全部</div>
|
||||||
<div class="stat-pill"><strong>8</strong>待开始</div>
|
<div class="stat-pill"><strong>8</strong>待开始</div>
|
||||||
<div class="stat-pill"><strong>1</strong>开发中</div>
|
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||||
<div class="stat-pill"><strong>1</strong>待验收</div>
|
<div class="stat-pill"><strong>2</strong>待验收</div>
|
||||||
<div class="stat-pill"><strong>8</strong>已验收</div>
|
<div class="stat-pill"><strong>8</strong>已验收</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -634,53 +634,18 @@ header .header-meta{color:var(--fg-soft)}
|
|||||||
</div>
|
</div>
|
||||||
<div class="section-block" id="section-doing">
|
<div class="section-block" id="section-doing">
|
||||||
<div class="section-title st-doing" data-toggle="doing">
|
<div class="section-title st-doing" data-toggle="doing">
|
||||||
🔨 开发中 <span class="s-count">1</span>
|
🔨 开发中 <span class="s-count">0</span>
|
||||||
<span class="s-arrow">▴ 收起</span>
|
<span class="s-arrow">▴ 收起</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-list-wrap " id="list-wrap-doing">
|
<div class="section-list-wrap " id="list-wrap-doing">
|
||||||
<ul class="todo-list" id="list-doing">
|
<ul class="todo-list" id="list-doing">
|
||||||
|
<p class="empty-tip">暂无条目</p>
|
||||||
<li class="todo-card s-doing"
|
|
||||||
data-id="19"
|
|
||||||
data-level="mid"
|
|
||||||
data-status="doing"
|
|
||||||
data-tier="1"
|
|
||||||
data-tags="前端,Web,mac,iOS,Android,Windows,CI/CD">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="item-id">#19</span>
|
|
||||||
<span class="item-title">前端设计系统治理重构(ds-flow 全端)</span>
|
|
||||||
<div class="card-badges">
|
|
||||||
<span class="tag status-badge s-doing">开发中</span>
|
|
||||||
<span class="tag t-high">重要</span>
|
|
||||||
<span class="tag tier-1">一级</span>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="item-desc">用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
|
||||||
<div class="gate-block gate-granted">
|
|
||||||
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
|
|
||||||
<span class="gate-kind">一级方案规划</span>
|
|
||||||
<span class="gate-date">2026-07-07</span></div>
|
|
||||||
<div class="gate-note">6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
|
|
||||||
<div class="item-meta">
|
|
||||||
<span class="tag owner-agent">🤖 agent</span>
|
|
||||||
<span class="meta-date">🕐 2026-07-07</span>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-block" id="section-done">
|
<div class="section-block" id="section-done">
|
||||||
<div class="section-title st-done" data-toggle="done">
|
<div class="section-title st-done" data-toggle="done">
|
||||||
🔍 待验收 <span class="s-count">1</span>
|
🔍 待验收 <span class="s-count">2</span>
|
||||||
<span class="s-arrow">▴ 收起</span>
|
<span class="s-arrow">▴ 收起</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-list-wrap " id="list-wrap-done">
|
<div class="section-list-wrap " id="list-wrap-done">
|
||||||
@@ -713,6 +678,42 @@ header .header-meta{color:var(--fg-soft)}
|
|||||||
<span class="tag owner-agent">🤖 agent</span>
|
<span class="tag owner-agent">🤖 agent</span>
|
||||||
<span class="meta-date">🕐 2026-07-01</span>
|
<span class="meta-date">🕐 2026-07-01</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="todo-card s-done"
|
||||||
|
data-id="19"
|
||||||
|
data-level="mid"
|
||||||
|
data-status="done"
|
||||||
|
data-tier="1"
|
||||||
|
data-tags="前端,Web,mac,iOS,Android,Windows,CI/CD">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="item-id">#19</span>
|
||||||
|
<span class="item-title">前端设计系统治理重构(ds-flow 全端)</span>
|
||||||
|
<div class="card-badges">
|
||||||
|
<span class="tag status-badge s-done">待验收</span>
|
||||||
|
<span class="tag t-high">重要</span>
|
||||||
|
<span class="tag tier-1">一级</span>
|
||||||
|
|
||||||
|
<button class="reject-btn" data-id="19" data-title="前端设计系统治理重构(ds-flow 全端)">拒绝验收</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-desc">用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
||||||
|
<div class="gate-block gate-granted">
|
||||||
|
<div class="gate-head"><span class="gate-badge granted">✓ 已确认</span>
|
||||||
|
<span class="gate-kind">一级方案规划</span>
|
||||||
|
<span class="gate-date">2026-07-07</span></div>
|
||||||
|
<div class="gate-note">6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="tag owner-agent">🤖 agent</span>
|
||||||
|
<span class="meta-date">🕐 2026-07-07</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"title": "feature+windows — 项目 TODO",
|
"title": "feature+windows — 项目 TODO",
|
||||||
"updated_at": "2026-07-07T15:54:54.457Z"
|
"updated_at": "2026-07-07T17:45:18.036Z"
|
||||||
},
|
},
|
||||||
"seq": 19,
|
"seq": 19,
|
||||||
"items": [
|
"items": [
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
"CI/CD"
|
"CI/CD"
|
||||||
],
|
],
|
||||||
"owner": "agent",
|
"owner": "agent",
|
||||||
"status": "doing",
|
"status": "done",
|
||||||
"created_at": "2026-07-07T15:41:25.760Z",
|
"created_at": "2026-07-07T15:41:25.760Z",
|
||||||
"done": false,
|
"done": false,
|
||||||
"completed_at": null,
|
"completed_at": null,
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ function checkWebTokenSync(label, webPath) {
|
|||||||
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
|
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
|
||||||
const ucJs = read('web/usercenter/components/icons.tsx');
|
const ucJs = read('web/usercenter/components/icons.tsx');
|
||||||
const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? '';
|
const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? '';
|
||||||
for (const [, id, body] of ucBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) {
|
// 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。
|
||||||
|
for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) {
|
||||||
if (!(id in protoIcons)) {
|
if (!(id in protoIcons)) {
|
||||||
problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`);
|
problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`);
|
||||||
} else if (protoIcons[id] !== body) {
|
} else if (protoIcons[id] !== body) {
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ export default function UserCenter() {
|
|||||||
</div>
|
</div>
|
||||||
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
|
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
|
||||||
{mobile && <div style={{ flex: 1 }} />}
|
{mobile && <div style={{ flex: 1 }} />}
|
||||||
|
<a href="/" title={t('backHome')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
|
||||||
|
<Icon name="home" size={15} color="var(--fg3)" />
|
||||||
|
{!mobile && t('backHome')}
|
||||||
|
</a>
|
||||||
<button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}>
|
<button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}>
|
||||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
|
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const LUCIDE: Record<string, string> = {
|
|||||||
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
||||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||||
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||||
|
home: '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
|
||||||
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
|
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
|
||||||
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const STRINGS: Record<string, Entry> = {
|
|||||||
navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
|
navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' },
|
||||||
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||||
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||||
|
backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' },
|
||||||
|
|
||||||
/* login */
|
/* login */
|
||||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
||||||
|
|||||||
@@ -30,8 +30,18 @@ export default function Header({ lang = 'zh', t = {} }) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [langOpen, setLangOpen] = useState(false);
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
|
const [loggedIn, setLoggedIn] = useState(false);
|
||||||
const langRef = useRef(null);
|
const langRef = useRef(null);
|
||||||
|
|
||||||
|
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh → 显示「用户中心」入口。
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setLoggedIn(!!localStorage.getItem('pg_uc_refresh'));
|
||||||
|
} catch {
|
||||||
|
setLoggedIn(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!langOpen) return;
|
if (!langOpen) return;
|
||||||
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||||||
@@ -75,7 +85,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
|
<div ref={langRef} class="langwrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="langsel"
|
class="langsel"
|
||||||
@@ -83,21 +93,18 @@ export default function Header({ lang = 'zh', t = {} }) {
|
|||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={langOpen}
|
aria-expanded={langOpen}
|
||||||
aria-label="Language"
|
aria-label="Language"
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
|
||||||
>
|
>
|
||||||
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
<svg class="caret" 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" />
|
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{langOpen && (
|
{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 }}>
|
<div class="langmenu" role="listbox">
|
||||||
{langs.map(([code, label]) => {
|
{langs.map(([code, label]) => {
|
||||||
const on = code === lang;
|
const on = code === lang;
|
||||||
return (
|
return (
|
||||||
<a key={code} role="option" aria-selected={on} href={langHref(code)}
|
<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}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -105,7 +112,11 @@ export default function Header({ lang = 'zh', t = {} }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
{loggedIn ? (
|
||||||
|
<a class="linklogin" href={SITE.usercenter}>{t.center}</a>
|
||||||
|
) : (
|
||||||
|
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||||
|
)}
|
||||||
<a class="btn btn-primary" href="#download">
|
<a class="btn btn-primary" href="#download">
|
||||||
<Download />
|
<Download />
|
||||||
<span>{t.get}</span>
|
<span>{t.get}</span>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
|||||||
'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' },
|
||||||
'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' },
|
||||||
'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
||||||
|
'nav.center': { zh: '用户中心', en: 'Account', ja: 'アカウント', ko: '계정', ru: 'Личный кабинет', es: 'Mi cuenta' },
|
||||||
'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' },
|
'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' },
|
||||||
|
|
||||||
'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' },
|
'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' },
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const headerT = {
|
|||||||
docs: t('nav.docs'),
|
docs: t('nav.docs'),
|
||||||
blog: t('nav.blog'),
|
blog: t('nav.blog'),
|
||||||
login: t('nav.login'),
|
login: t('nav.login'),
|
||||||
|
center: t('nav.center'),
|
||||||
get: t('nav.get'),
|
get: t('nav.get'),
|
||||||
suBtn: t('su.btn'),
|
suBtn: t('su.btn'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ img,svg{display:block}
|
|||||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||||
.linklogin:hover{color:var(--accent)}
|
.linklogin:hover{color:var(--accent)}
|
||||||
|
|
||||||
|
/* ---------- language dropdown ---------- */
|
||||||
|
.langwrap{position:relative;display:inline-block}
|
||||||
|
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
|
||||||
|
.langsel:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.langsel .caret{width:11px;height:11px;transition:transform 140ms var(--ease-out)}
|
||||||
|
.langsel[aria-expanded="true"] .caret{transform:rotate(180deg)}
|
||||||
|
.langmenu{position:absolute;top:calc(100% + 6px);right:0;min-width:160px;display:flex;flex-direction:column;gap:1px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:5px;z-index:60}
|
||||||
|
.langmenu a{text-align:left;text-decoration:none;font-family:var(--font-sans);font-size:13px;font-weight:500;color:var(--fg1);background:transparent;padding:8px 11px;border-radius:var(--radius-sm);white-space:nowrap;transition:background var(--dur-fast) var(--ease-out)}
|
||||||
|
.langmenu a:hover{background:var(--bg-subtle)}
|
||||||
|
.langmenu a[aria-selected="true"]{color:var(--accent);background:var(--accent-subtle);font-weight:700}
|
||||||
|
|
||||||
/* mobile nav */
|
/* mobile nav */
|
||||||
.menu-btn{display:none;border:none;background:transparent;cursor:pointer;padding:7px;border-radius:var(--radius-sm);color:var(--fg1)}
|
.menu-btn{display:none;border:none;background:transparent;cursor:pointer;padding:7px;border-radius:var(--radius-sm);color:var(--fg1)}
|
||||||
.menu-btn svg{width:22px;height:22px}
|
.menu-btn svg{width:22px;height:22px}
|
||||||
|
|||||||
Reference in New Issue
Block a user