Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 805bf8e2ca | |||
| 3311430db6 | |||
| 4d274aad7a | |||
| 84448b3064 | |||
| 386e0bce90 | |||
| b584188ce2 | |||
| 60cd28e085 | |||
| 11ca7531a3 | |||
| e6b4ec0459 | |||
| 0b86138b55 | |||
| aaffe85a4f | |||
| 4940278ea5 | |||
| 6a46e3109a |
@@ -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
|
||||
|
||||
@@ -24,19 +24,18 @@ jobs:
|
||||
SITE_URL: https://pangolin.yanmeiai.com
|
||||
run: bash scripts/ci/compile-site.sh
|
||||
|
||||
# 用户中心(web/usercenter,basePath=/user,http 模式)构建 → out/。
|
||||
- name: Compile (用户中心 Next.js)
|
||||
run: bash scripts/ci/compile-usercenter.sh
|
||||
|
||||
# 合并:用户中心并入官网 dist/user/ + _headers 按 /user/* 分域(见脚本)。
|
||||
# 域名迁移:原独立子域 app.yanmeiai.com → 主站子路径 pangolin.yanmeiai.com/user/。
|
||||
- name: Combine (官网 + 用户中心)
|
||||
run: bash scripts/ci/combine-site.sh
|
||||
|
||||
# 单次部署合并产物到 pangolin-site:/ 官网、/user/ 用户中心。
|
||||
- name: Deploy → Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
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
|
||||
|
||||
@@ -90,6 +90,50 @@ cd web/website && npm run gen:tokens
|
||||
- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。
|
||||
- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。
|
||||
|
||||
## 前端设计系统治理(ds-flow)
|
||||
|
||||
> **落地中**(分阶段收口,见计划 `docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md`
|
||||
> / 阅读版 `docs/frontend-ds-refactor-plan.html` / todo #19)。以下是**目标模型与硬规则**;
|
||||
> 标注「⏳」的部件正在建,未标注的已生效。参考样板 `~/code/jiu`。
|
||||
|
||||
**心智模型**:设计只有一个出生地(**原型单源**),代码永远是镜像;跨端副本是否走样由
|
||||
**静态闸**在提交/CI 前拦截,像素是否还原由 **golden/fidelity 双级验收**兜底。主题:**light / dark 双主题**。
|
||||
|
||||
**原型单源** `design/prototype/`(⏳ 建设中,Phase 1):
|
||||
- `tokens.css` — 令牌真源:基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块。
|
||||
(当前真源仍是 `design/colors_and_type.css`,Phase 1 迁移后数值不变、结构规整)
|
||||
- `atoms.css` — 公用组件原子(按钮/卡片/输入/语言下拉/徽章/状态药丸),**只引 `var(--token)`,禁硬编码**。
|
||||
- `icons.js` — SVG sprite 单源(`<symbol id="i-*">`);website/usercenter/Flutter 三处图标集须 ⊆ 此集。
|
||||
- `index.html` — 活登记页:主题切换 + 声明式色板 + 全组件/图标展示卡。**每个 atom 必须在此登记**。
|
||||
- `serve.mjs` — 零依赖热重载预览;评审给 URL,**不截图**。
|
||||
|
||||
**三层治理**:
|
||||
- **L1 设计系统**:新增颜色/组件/图标——**先登记原型,再同步代码**,无例外。跨端映射(状态词→图标)两端同集要有闸。
|
||||
- **L2 屏级三态**(台账记 `design/CONTRACT.md`):`同步`=入 fidelity;`快照`=原型退役、golden+契约为准;`代码先行`=无原型屏、golden 唯一基准。
|
||||
- **L3 新屏/改版**:design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。
|
||||
|
||||
**codegen**:
|
||||
- Flutter:`node design/codegen/gen_flutter_tokens.mjs` → `pangolin_tokens.gen.dart`(**勿手改**)。
|
||||
- Web:`build-tokens.mjs`(website→`src/styles/tokens.gen.css` / usercenter→`public/colors_and_type.css`),
|
||||
**只同源不重复生成设计决策**;靠同源闸逐值校验(不建跨端共享组件包,两端各自实现)。
|
||||
|
||||
**四道静态闸 —「违规谁拦」**(规则没上闸 = 没有规则):
|
||||
|
||||
| 闸 | 拦什么 | 何时 |
|
||||
|---|---|---|
|
||||
| 原型校验 `check-ds.mjs`(⏳ Phase 5) | 硬编码色/未定义 token/未登记组件/魔法数断点… 12 道 | pre-commit(动了原型)+ CI |
|
||||
| 跨端同源 `check-l1-sync.mjs`(⏳ Phase 2) | tokens 逐值/icons 同集/Web hex 白名单 | CI |
|
||||
| 代码色单源 `check_ds_code.mjs`(⏳ Phase 5) | Flutter 裸 `Color(0x)`/具名 `Colors.x`(`// ds-ignore: 理由` 豁免) | pre-commit(`--changed`)+ CI |
|
||||
| codegen 零 diff `ci/check-codegen-drift.sh`(**已生效**) | 重生成 token 后 `git diff` 非空即 fail | pre-commit + CI |
|
||||
|
||||
**双级像素验收**:
|
||||
- **golden**(**已有**,`client/test/golden/`):多主题回归自比(同渲染器),抓串色/漏 token;真字体加载防豆腐块、钉死 viewport/dpr/动态值。重录 `flutter test --update-goldens`,随功能 commit 入库。
|
||||
- **fidelity**(⏳ Phase 5,本地体检**不进 CI**):原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp;跨渲染器噪声大,故不入 CI。
|
||||
|
||||
**pre-commit**:`.githooks/pre-commit` 已写,**每台机需 `bash ci/install-hooks.sh` 启用一次**(设 `core.hooksPath=.githooks`)。
|
||||
|
||||
**发现硬编码色**:换 token;确属例外(`#fff/#000`/品牌 logo 固定色)加 `// ds-ignore: 理由` 或列白名单。
|
||||
|
||||
## client/ macOS 原生隧道(PacketTunnel 系统扩展 + 内嵌 libbox)
|
||||
|
||||
内嵌 sing-box(`Libbox.xcframework`)的 `NEPacketTunnelProvider` **系统扩展**(站外 Developer ID
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-codegen-drift.sh — codegen 漂移检查(支柱 2:契约单源 / token 单源)。
|
||||
#
|
||||
# design/colors_and_type.css 是唯一 token 真相源,client/lib/pangolin_tokens.gen.dart
|
||||
# design/prototype/tokens.css 是唯一 token 真相源,client/lib/pangolin_tokens.gen.dart
|
||||
# 由 design/codegen/gen_flutter_tokens.mjs 生成、勿手改(CLAUDE.md 铁律)。本检查重新
|
||||
# 生成一遍,若生成物与已提交版本不一致 → 漂移:要么改了 CSS 没重生成,要么手改了
|
||||
# 生成物。两者都会让"单源"失效、契约悄悄分叉。
|
||||
@@ -22,7 +22,7 @@ echo "→ 重新生成 Flutter token (design/codegen/gen_flutter_tokens.mjs) ...
|
||||
node design/codegen/gen_flutter_tokens.mjs
|
||||
|
||||
if ! git diff --quiet -- "$GEN"; then
|
||||
echo "❌ codegen 漂移:$GEN 与 design/colors_and_type.css 不一致。" >&2
|
||||
echo "❌ codegen 漂移:$GEN 与 design/prototype/tokens.css 不一致。" >&2
|
||||
echo " 原因:改了 CSS 真相源却没重生成,或手改了生成物(铁律:勿手改 *.gen.dart)。" >&2
|
||||
echo " 修复:node design/codegen/gen_flutter_tokens.mjs 后提交生成物。" >&2
|
||||
echo " ── diff(前 40 行)──" >&2
|
||||
|
||||
@@ -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 各实现一份。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// pangolin_tokens.gen.dart
|
||||
// AUTO-GENERATED — 勿手改。
|
||||
// 源: design/colors_and_type.css
|
||||
// 源: design/prototype/tokens.css
|
||||
// 生成器: design/codegen/gen_flutter_tokens.mjs
|
||||
//
|
||||
// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow
|
||||
@@ -9,7 +9,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ── Primitive color ramps ───────────────────────────────────────────
|
||||
/// Auto-generated from design/colors_and_type.css :root color ramps.
|
||||
/// Auto-generated from design/prototype/tokens.css :root color ramps.
|
||||
class PangolinColors {
|
||||
PangolinColors._();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,3 +8,11 @@ const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'https://api.yanmeiai.com',
|
||||
);
|
||||
|
||||
/// 用户中心(网页)基址(单源)。已从独立子域 app.yanmeiai.com 迁到主站子路径
|
||||
/// pangolin.yanmeiai.com/user/(旧子域已停用)。末尾不带斜杠;调用方自行拼 path。
|
||||
/// 本地联调可 --dart-define=PANGOLIN_USERCENTER_URL=http://127.0.0.1:3000 覆盖。
|
||||
const String kWebUserCenterBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_USERCENTER_URL',
|
||||
defaultValue: 'https://pangolin.yanmeiai.com/user',
|
||||
);
|
||||
|
||||
@@ -8,9 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../state/account_providers.dart';
|
||||
|
||||
/// 用户中心(网页版)基址。
|
||||
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
|
||||
import 'api_config.dart';
|
||||
|
||||
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
|
||||
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* gen_flutter_tokens.mjs — CSS token → Dart codegen
|
||||
*
|
||||
* 唯一真相源:design/colors_and_type.css
|
||||
* 唯一真相源:design/prototype/tokens.css
|
||||
* 输出: client/lib/pangolin_tokens.gen.dart
|
||||
*
|
||||
* 生成内容(纯数据层,无 Flutter 实现逻辑):
|
||||
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../colors_and_type.css');
|
||||
const SRC = resolve(__dirname, '../prototype/tokens.css');
|
||||
const OUT = resolve(__dirname, '../../client/lib/pangolin_tokens.gen.dart');
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
@@ -125,7 +125,7 @@ const lines = [];
|
||||
|
||||
lines.push(`// pangolin_tokens.gen.dart`);
|
||||
lines.push(`// AUTO-GENERATED — 勿手改。`);
|
||||
lines.push(`// 源: design/colors_and_type.css`);
|
||||
lines.push(`// 源: design/prototype/tokens.css`);
|
||||
lines.push(`// 生成器: design/codegen/gen_flutter_tokens.mjs`);
|
||||
lines.push(`//`);
|
||||
lines.push(`// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow`);
|
||||
@@ -136,7 +136,7 @@ lines.push(``);
|
||||
|
||||
// ── PangolinColors ──────────────────────────────────────────────────
|
||||
lines.push(`/// ── Primitive color ramps ───────────────────────────────────────────`);
|
||||
lines.push(`/// Auto-generated from design/colors_and_type.css :root color ramps.`);
|
||||
lines.push(`/// Auto-generated from design/prototype/tokens.css :root color ramps.`);
|
||||
lines.push(`class PangolinColors {`);
|
||||
lines.push(` PangolinColors._();`);
|
||||
lines.push(``);
|
||||
|
||||
+6
-216
@@ -1,218 +1,8 @@
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
Single source of truth: color ramps, semantic colors (light + dark),
|
||||
typography, spacing, radii, shadows, motion.
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens (兼容别名)
|
||||
⚠️ 真源已迁至 design/prototype/tokens.css(ds-flow 原型单源)。
|
||||
本文件仅为浏览器内 @import 转发,供仍 <link> 此路径的历史 preview/*.html 使用。
|
||||
codegen(Flutter gen_flutter_tokens.mjs / Web build-tokens.mjs)已改读 prototype/tokens.css。
|
||||
改 token 只改 design/prototype/tokens.css,勿在此加变量(不会被 codegen 解析)。
|
||||
============================================================= */
|
||||
|
||||
/* ---- Webfonts (open-source; documented as the brand's chosen faces) ----
|
||||
Sora — display / headings (geometric, friendly)
|
||||
Manrope — body / UI (humanist geometric)
|
||||
Noto Sans SC — Chinese (CJK companion)
|
||||
JetBrains Mono — data readouts (IP, speed, keys) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* ===========================================================
|
||||
1. PRIMITIVE COLOR RAMPS
|
||||
=========================================================== */
|
||||
|
||||
/* Clay / Copper — the pangolin-armor primary (warm earth) */
|
||||
--clay-50: #FAF3ED;
|
||||
--clay-100: #F2E2D4;
|
||||
--clay-200: #E6C7AC;
|
||||
--clay-300: #D9A982;
|
||||
--clay-400: #CC8B5C;
|
||||
--clay-500: #B96A3D; /* ← brand primary */
|
||||
--clay-600: #9E5630;
|
||||
--clay-700: #7E4426;
|
||||
--clay-800: #5E331D;
|
||||
--clay-900: #3D2213;
|
||||
|
||||
/* Sand / Taupe — warm neutral ramp */
|
||||
--sand-50: #FAF8F4;
|
||||
--sand-100: #F2EEE7;
|
||||
--sand-200: #E6DFD3;
|
||||
--sand-300: #D2C8B8;
|
||||
--sand-400: #B0A491;
|
||||
--sand-500: #8C8270;
|
||||
--sand-600: #6B6253;
|
||||
--sand-700: #4E4940;
|
||||
--sand-800: #2E2A24;
|
||||
--sand-900: #1F1C18;
|
||||
--sand-950: #14110E; /* warm espresso near-black */
|
||||
|
||||
/* Semantic hues (earth-tuned) */
|
||||
--green-400: #7FB07A;
|
||||
--green-500: #5B8C5A; /* connected / secure */
|
||||
--green-600: #467046;
|
||||
--amber-400: #E2B05A;
|
||||
--amber-500: #D69A3C; /* connecting / warning */
|
||||
--amber-600: #B47E29;
|
||||
--red-400: #D4715A;
|
||||
--red-500: #C0533B; /* error / disconnect */
|
||||
--red-600: #9E4230;
|
||||
|
||||
/* ===========================================================
|
||||
2. SEMANTIC TOKENS — LIGHT THEME (default)
|
||||
=========================================================== */
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bg: var(--sand-50); /* app canvas */
|
||||
--bg-subtle: var(--sand-100); /* striped / inset regions */
|
||||
--surface: #FFFFFF; /* cards, sheets */
|
||||
--surface-2: var(--sand-50); /* nested surface */
|
||||
--overlay: rgba(31, 28, 24, 0.45);
|
||||
|
||||
/* Foreground / text */
|
||||
--fg1: var(--sand-900); /* primary text */
|
||||
--fg2: var(--sand-600); /* secondary text */
|
||||
--fg3: var(--sand-500); /* tertiary / captions */
|
||||
--fg-on-accent: #FFFFFF; /* text on clay fills */
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: var(--clay-500);
|
||||
--accent-hover: var(--clay-600);
|
||||
--accent-press: var(--clay-700);
|
||||
--accent-subtle: var(--clay-50);
|
||||
--accent-border: var(--clay-200);
|
||||
|
||||
/* Borders & lines */
|
||||
--border: var(--sand-200);
|
||||
--border-strong: var(--sand-300);
|
||||
--ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */
|
||||
|
||||
/* Status */
|
||||
--success: var(--green-500);
|
||||
--success-subtle: #E9F0E6;
|
||||
--warning: var(--amber-500);
|
||||
--warning-subtle: #F8EED6;
|
||||
--danger: var(--red-500);
|
||||
--danger-subtle: #F6E1DA;
|
||||
|
||||
/* ===========================================================
|
||||
3. TYPOGRAPHY
|
||||
=========================================================== */
|
||||
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
/* Type scale (root 16px) */
|
||||
--text-display-xl: 3rem; /* 48 */
|
||||
--text-display: 2.25rem; /* 36 */
|
||||
--text-h1: 1.875rem; /* 30 */
|
||||
--text-h2: 1.5rem; /* 24 */
|
||||
--text-h3: 1.25rem; /* 20 */
|
||||
--text-body-lg: 1.125rem; /* 18 */
|
||||
--text-body: 1rem; /* 16 */
|
||||
--text-sm: 0.875rem; /* 14 */
|
||||
--text-caption: 0.75rem; /* 12 */
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-snug: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-caps: 0.08em;
|
||||
|
||||
/* ===========================================================
|
||||
4. SPACING (4px base)
|
||||
=========================================================== */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
--space-4: 1rem; /* 16 */
|
||||
--space-5: 1.25rem; /* 20 */
|
||||
--space-6: 1.5rem; /* 24 */
|
||||
--space-8: 2rem; /* 32 */
|
||||
--space-10: 2.5rem; /* 40 */
|
||||
--space-12: 3rem; /* 48 */
|
||||
--space-16: 4rem; /* 64 */
|
||||
|
||||
/* ===========================================================
|
||||
5. RADII (generous = friendly)
|
||||
=========================================================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===========================================================
|
||||
6. SHADOWS (warm-tinted, soft)
|
||||
=========================================================== */
|
||||
--shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06);
|
||||
--shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12);
|
||||
--shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16);
|
||||
--shadow-focus: 0 0 0 4px var(--ring);
|
||||
|
||||
/* ===========================================================
|
||||
7. MOTION
|
||||
=========================================================== */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--dur-fast: 140ms; /* @kind other */
|
||||
--dur-base: 220ms; /* @kind other */
|
||||
--dur-slow: 360ms; /* @kind other */
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
DARK THEME — warm espresso
|
||||
Apply via [data-theme="dark"] on <html> or any container.
|
||||
=========================================================== */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--sand-950);
|
||||
--bg-subtle: var(--sand-900);
|
||||
--surface: #221E19;
|
||||
--surface-2: #2A251F;
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--fg1: #F4EFE8;
|
||||
--fg2: #B6AC9C;
|
||||
--fg3: #897F6F;
|
||||
--fg-on-accent: #1F1C18;
|
||||
|
||||
--accent: var(--clay-400);
|
||||
--accent-hover: var(--clay-300);
|
||||
--accent-press: var(--clay-500);
|
||||
--accent-subtle: rgba(204, 139, 92, 0.14);
|
||||
--accent-border: rgba(204, 139, 92, 0.30);
|
||||
|
||||
--border: rgba(242, 238, 231, 0.10);
|
||||
--border-strong: rgba(242, 238, 231, 0.18);
|
||||
--ring: rgba(204, 139, 92, 0.45);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-subtle: rgba(127, 176, 122, 0.16);
|
||||
--warning: var(--amber-400);
|
||||
--warning-subtle: rgba(226, 176, 90, 0.16);
|
||||
--danger: var(--red-400);
|
||||
--danger-subtle: rgba(212, 113, 90, 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
SEMANTIC TYPE CLASSES (use directly in markup)
|
||||
=========================================================== */
|
||||
.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); }
|
||||
.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); }
|
||||
.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); }
|
||||
.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; }
|
||||
@import url('./prototype/tokens.css');
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 零依赖本地热重载静态服务器 — pangolin 原型单源预览
|
||||
// 用法: node design/prototype/serve.mjs [port] 默认 5180
|
||||
// 评审给 URL,不截图(ds-flow L3)。
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.argv[2]) || 5180;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
'.otf': 'font/otf',
|
||||
'.ttf': 'font/ttf',
|
||||
};
|
||||
|
||||
const clients = new Set();
|
||||
const RELOAD_SNIPPET = `\n<script>
|
||||
(function(){ try{
|
||||
var es=new EventSource('/__reload');
|
||||
es.onmessage=function(){ location.reload(); };
|
||||
}catch(e){} })();
|
||||
</script>\n`;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/__reload') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 500\n\n');
|
||||
clients.add(res);
|
||||
req.on('close', () => clients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
const filePath = path.join(ROOT, urlPath);
|
||||
if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('forbidden'); return; }
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>404</h1>'); return; }
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = MIME[ext] || 'application/octet-stream';
|
||||
const noCache = { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' };
|
||||
if (ext === '.html') {
|
||||
const html = data.toString('utf8').replace('</body>', RELOAD_SNIPPET + '</body>');
|
||||
res.writeHead(200, { 'Content-Type': mime, ...noCache });
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': mime, ...noCache });
|
||||
res.end(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 设计系统守门:启动 + 每次改动自动跑 check-ds.mjs(Phase 5 建立后生效)。
|
||||
const CHECK_DS = path.join(ROOT, 'tools', 'check-ds.mjs');
|
||||
function runDsCheck() {
|
||||
if (!fs.existsSync(CHECK_DS)) return; // Phase 5 前无闸,静默跳过
|
||||
const p = spawn(process.execPath, [CHECK_DS], { cwd: ROOT });
|
||||
let out = '';
|
||||
p.stdout.on('data', d => out += d);
|
||||
p.stderr.on('data', d => out += d);
|
||||
p.on('close', code => {
|
||||
const t = new Date().toLocaleTimeString();
|
||||
if (code === 0) console.log(`\x1b[32m[设计系统 ✓ ${t}] 颜色全部走 token、变量均已定义\x1b[0m`);
|
||||
else console.log(`\x1b[31m[设计系统 ✗ ${t}] 存在违规 —— 运行 node tools/check-ds.mjs 看详情\x1b[0m`);
|
||||
});
|
||||
}
|
||||
|
||||
let debounce;
|
||||
fs.watch(ROOT, { recursive: true }, (_ev, file) => {
|
||||
if (file && file.endsWith('serve.mjs')) return;
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
for (const c of clients) c.write('data: reload\n\n');
|
||||
if (file && /\.(html|css|js)$/.test(file)) runDsCheck();
|
||||
}, 80);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`prototype live at http://localhost:${PORT}/ (watching ${ROOT})`);
|
||||
runDsCheck();
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
Single source of truth: color ramps, semantic colors (light + dark),
|
||||
typography, spacing, radii, shadows, motion.
|
||||
============================================================= */
|
||||
|
||||
/* ---- Webfonts (open-source; documented as the brand's chosen faces) ----
|
||||
Sora — display / headings (geometric, friendly)
|
||||
Manrope — body / UI (humanist geometric)
|
||||
Noto Sans SC — Chinese (CJK companion)
|
||||
JetBrains Mono — data readouts (IP, speed, keys) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* ===========================================================
|
||||
1. PRIMITIVE COLOR RAMPS
|
||||
=========================================================== */
|
||||
|
||||
/* Clay / Copper — the pangolin-armor primary (warm earth) */
|
||||
--clay-50: #FAF3ED;
|
||||
--clay-100: #F2E2D4;
|
||||
--clay-200: #E6C7AC;
|
||||
--clay-300: #D9A982;
|
||||
--clay-400: #CC8B5C;
|
||||
--clay-500: #B96A3D; /* ← brand primary */
|
||||
--clay-600: #9E5630;
|
||||
--clay-700: #7E4426;
|
||||
--clay-800: #5E331D;
|
||||
--clay-900: #3D2213;
|
||||
|
||||
/* Sand / Taupe — warm neutral ramp */
|
||||
--sand-50: #FAF8F4;
|
||||
--sand-100: #F2EEE7;
|
||||
--sand-200: #E6DFD3;
|
||||
--sand-300: #D2C8B8;
|
||||
--sand-400: #B0A491;
|
||||
--sand-500: #8C8270;
|
||||
--sand-600: #6B6253;
|
||||
--sand-700: #4E4940;
|
||||
--sand-800: #2E2A24;
|
||||
--sand-900: #1F1C18;
|
||||
--sand-950: #14110E; /* warm espresso near-black */
|
||||
|
||||
/* Semantic hues (earth-tuned) */
|
||||
--green-400: #7FB07A;
|
||||
--green-500: #5B8C5A; /* connected / secure */
|
||||
--green-600: #467046;
|
||||
--amber-400: #E2B05A;
|
||||
--amber-500: #D69A3C; /* connecting / warning */
|
||||
--amber-600: #B47E29;
|
||||
--red-400: #D4715A;
|
||||
--red-500: #C0533B; /* error / disconnect */
|
||||
--red-600: #9E4230;
|
||||
|
||||
/* ===========================================================
|
||||
2. SEMANTIC TOKENS — LIGHT THEME (default)
|
||||
=========================================================== */
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bg: var(--sand-50); /* app canvas */
|
||||
--bg-subtle: var(--sand-100); /* striped / inset regions */
|
||||
--surface: #FFFFFF; /* cards, sheets */
|
||||
--surface-2: var(--sand-50); /* nested surface */
|
||||
--overlay: rgba(31, 28, 24, 0.45);
|
||||
|
||||
/* Foreground / text */
|
||||
--fg1: var(--sand-900); /* primary text */
|
||||
--fg2: var(--sand-600); /* secondary text */
|
||||
--fg3: var(--sand-500); /* tertiary / captions */
|
||||
--fg-on-accent: #FFFFFF; /* text on clay fills */
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: var(--clay-500);
|
||||
--accent-hover: var(--clay-600);
|
||||
--accent-press: var(--clay-700);
|
||||
--accent-subtle: var(--clay-50);
|
||||
--accent-border: var(--clay-200);
|
||||
|
||||
/* Borders & lines */
|
||||
--border: var(--sand-200);
|
||||
--border-strong: var(--sand-300);
|
||||
--ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */
|
||||
|
||||
/* Status */
|
||||
--success: var(--green-500);
|
||||
--success-subtle: #E9F0E6;
|
||||
--warning: var(--amber-500);
|
||||
--warning-subtle: #F8EED6;
|
||||
--danger: var(--red-500);
|
||||
--danger-subtle: #F6E1DA;
|
||||
|
||||
/* ===========================================================
|
||||
3. TYPOGRAPHY
|
||||
=========================================================== */
|
||||
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
/* Type scale (root 16px) */
|
||||
--text-display-xl: 3rem; /* 48 */
|
||||
--text-display: 2.25rem; /* 36 */
|
||||
--text-h1: 1.875rem; /* 30 */
|
||||
--text-h2: 1.5rem; /* 24 */
|
||||
--text-h3: 1.25rem; /* 20 */
|
||||
--text-body-lg: 1.125rem; /* 18 */
|
||||
--text-body: 1rem; /* 16 */
|
||||
--text-sm: 0.875rem; /* 14 */
|
||||
--text-caption: 0.75rem; /* 12 */
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-snug: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-caps: 0.08em;
|
||||
|
||||
/* ===========================================================
|
||||
4. SPACING (4px base)
|
||||
=========================================================== */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
--space-4: 1rem; /* 16 */
|
||||
--space-5: 1.25rem; /* 20 */
|
||||
--space-6: 1.5rem; /* 24 */
|
||||
--space-8: 2rem; /* 32 */
|
||||
--space-10: 2.5rem; /* 40 */
|
||||
--space-12: 3rem; /* 48 */
|
||||
--space-16: 4rem; /* 64 */
|
||||
|
||||
/* ===========================================================
|
||||
5. RADII (generous = friendly)
|
||||
=========================================================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===========================================================
|
||||
6. SHADOWS (warm-tinted, soft)
|
||||
=========================================================== */
|
||||
--shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06);
|
||||
--shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12);
|
||||
--shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16);
|
||||
--shadow-focus: 0 0 0 4px var(--ring);
|
||||
|
||||
/* ===========================================================
|
||||
7. MOTION
|
||||
=========================================================== */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--dur-fast: 140ms; /* @kind other */
|
||||
--dur-base: 220ms; /* @kind other */
|
||||
--dur-slow: 360ms; /* @kind other */
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
DARK THEME — warm espresso
|
||||
Apply via [data-theme="dark"] on <html> or any container.
|
||||
=========================================================== */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--sand-950);
|
||||
--bg-subtle: var(--sand-900);
|
||||
--surface: #221E19;
|
||||
--surface-2: #2A251F;
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--fg1: #F4EFE8;
|
||||
--fg2: #B6AC9C;
|
||||
--fg3: #897F6F;
|
||||
--fg-on-accent: #1F1C18;
|
||||
|
||||
--accent: var(--clay-400);
|
||||
--accent-hover: var(--clay-300);
|
||||
--accent-press: var(--clay-500);
|
||||
--accent-subtle: rgba(204, 139, 92, 0.14);
|
||||
--accent-border: rgba(204, 139, 92, 0.30);
|
||||
|
||||
--border: rgba(242, 238, 231, 0.10);
|
||||
--border-strong: rgba(242, 238, 231, 0.18);
|
||||
--ring: rgba(204, 139, 92, 0.45);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-subtle: rgba(127, 176, 122, 0.16);
|
||||
--warning: var(--amber-400);
|
||||
--warning-subtle: rgba(226, 176, 90, 0.16);
|
||||
--danger: var(--red-400);
|
||||
--danger-subtle: rgba(212, 113, 90, 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
SEMANTIC TYPE CLASSES (use directly in markup)
|
||||
=========================================================== */
|
||||
.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); }
|
||||
.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); }
|
||||
.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); }
|
||||
.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; }
|
||||
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>前端设计系统治理重构(ds-flow)· 实现计划</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:29px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 28px}
|
||||
h2{font-size:20px;margin:38px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:22px 0 8px;color:var(--accent)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.86em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
a{color:var(--accent2);text-decoration:none} a:hover{text-decoration:underline}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 20px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
.phase{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 20px;margin:14px 0}
|
||||
.phase h2{margin-top:4px;border:none;padding:0;font-size:18px}
|
||||
ul{margin:8px 0;padding-left:22px} li{margin:5px 0}
|
||||
.box{overflow-x:auto;margin:14px 0}
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:560px}
|
||||
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--panel2);color:var(--fg)}
|
||||
.ok{color:var(--ok);font-weight:700} .bad{color:var(--bad);font-weight:700} .warn{color:var(--warn);font-weight:700}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;margin-left:6px}
|
||||
.pill.big{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;background:rgba(94,194,122,.14);color:var(--ok);margin-left:8px;vertical-align:middle}
|
||||
.back{color:var(--fg2);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<p class="back"><a href="index.html">← 文档索引</a></p>
|
||||
<h1>前端设计系统治理重构 <span class="tag">ds-flow</span></h1>
|
||||
<p class="sub">用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源 · 代码镜像 · 静态闸拦漂移 · 双级像素验收兜底」</p>
|
||||
|
||||
<div class="lead">
|
||||
<strong>阅读版</strong>;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。<br>
|
||||
<strong>关键前提</strong>:pangolin <b>不是从零 bootstrap,已约 65% 达标</b>——token 单源(含暗色)、Flutter codegen + drift 闸、golden + CI 闸、pre-commit(写好未启用)都在。本计划是<b>补缺口 + Web 共享原子层去重</b>,非推倒重来。主题保持 <b>light / dark 双主题</b>。
|
||||
</div>
|
||||
|
||||
<h2>现状盘点</h2>
|
||||
<div class="box">
|
||||
<table>
|
||||
<tr><th>维度</th><th>现状</th><th>缺口</th></tr>
|
||||
<tr><td>Token 单源</td><td class="ok">✓ colors_and_type.css(含 [data-theme=dark])</td><td>迁为 ds-flow 原型结构</td></tr>
|
||||
<tr><td>Flutter codegen / 主题层</td><td class="ok">✓ gen 层/实现层分离 + drift 闸</td><td>—</td></tr>
|
||||
<tr><td>Flutter UI 硬编码</td><td class="ok">✓ 零裸 hex(唯 1 处裸色 adaptive_menu)</td><td>清 1 处</td></tr>
|
||||
<tr><td>Flutter golden</td><td class="ok">✓ 36 张 + harness + 真字体 + CI</td><td class="warn">6 张 failure;缺 CJK 测试字体;覆盖不全</td></tr>
|
||||
<tr><td>website / usercenter</td><td class="ok">✓ 179 / 171 处 var(--token)</td><td class="bad">零前端测试</td></tr>
|
||||
<tr><td>跨端共享组件</td><td class="bad">✗ 下拉/按钮/卡片两端各写一遍</td><td>抽 atoms 对齐</td></tr>
|
||||
<tr><td>原型三件套</td><td class="warn">⚠ 有 _ds_manifest/preview/ui_kits</td><td>缺 atoms.css / icons.js / index.html</td></tr>
|
||||
<tr><td>静态闸</td><td class="ok">✓ redline / analyze+test / codegen-drift / golden</td><td class="bad">✗ 硬编码色扫描 / fidelity / Web 同源</td></tr>
|
||||
<tr><td>pre-commit</td><td class="warn">⚠ .githooks 写好</td><td class="bad">默认未启用</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>已定决策</h2>
|
||||
<ul>
|
||||
<li><b>Web 去重</b>:两端<b>各自实现 + 同源闸</b>(不建跨端组件包;成本低、风险小,符合 jiu 取舍)</li>
|
||||
<li><b>ui_kits</b>:jsx/css 端原型<b>收敛为纯 HTML 原型</b>并删副本(消除与「禁向 design/ 提组件代码副本」的冲突)</li>
|
||||
<li><b>节奏</b>:<b>先定稿本计划,再逐刀执行</b>(每刀 commit,tier-1 大改走确认闸)</li>
|
||||
</ul>
|
||||
|
||||
<h2>执行阶段(6 阶段)</h2>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 0 — 更新 CLAUDE.md + 计划落库</h2>
|
||||
<ul>
|
||||
<li>CLAUDE.md 补「前端设计系统治理(ds-flow)」章节:原型单源位置、codegen 命令、L1/L2/L3 三层规则、四道静态闸 +「违规谁拦」对照表、golden/fidelity 双闸定位</li>
|
||||
<li>本计划 .md 定稿 + HTML 阅读版 + 登记 docs/index.html</li>
|
||||
<li>/todo 建 tier-1 条目 + 6 子任务</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 1 — 原型单源三件套(design/prototype/)</h2>
|
||||
<p class="small">把散在 ui_kits / preview / _ds_manifest.json 的东西收敛为 ds-flow 标准三件套。</p>
|
||||
<ul>
|
||||
<li><code>serve.mjs</code> 照搬 jiu(零依赖热重载)</li>
|
||||
<li><code>tokens.css</code>:现有 token <b>数值不变</b>,重排为「基础 :root 标量 + [data-theme=dark] 颜色覆盖」结构</li>
|
||||
<li><code>atoms.css</code>:按钮/卡片/输入/<b>语言下拉</b>/徽章/状态药丸公用原子(只引 var(--token))</li>
|
||||
<li><code>icons.js</code>:SVG sprite 单源,收敛 website / usercenter / Flutter 三处图标集</li>
|
||||
<li><code>index.html</code>:活登记页——light/dark 切换 + 声明式色板 + 全组件/图标展示卡(每 atom 必登记)</li>
|
||||
<li>ui_kits jsx 副本提炼后<b>删除</b>;屏级布局参考迁 <code>prototype/screens/</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 2 — Web token 升为一等公民 + 同源闸</h2>
|
||||
<ul>
|
||||
<li>两端 token 落点统一指向原型 tokens.css(website→tokens.gen.css / usercenter→public/colors_and_type.css)</li>
|
||||
<li><code>tools/check-l1-sync.mjs</code>(照搬 jiu 裁剪):website / usercenter token 值逐值同源 + icons 同集 + Web hex 白名单扫描</li>
|
||||
<li>build-tokens 幂等:重跑零 diff(纳入 CI)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 3 — Web 共享原子层对齐 <span class="pill big">工作量最大</span></h2>
|
||||
<p class="small">各自实现 + 同源闸:两端对齐同一 atoms.css,靠闸防漂移。</p>
|
||||
<ul>
|
||||
<li>抽公共原子:langsel / button / card / input / badge / pill → atoms.css canonical</li>
|
||||
<li>website:website.css/site-extra.css 对齐 atoms 语义,非白/黑/logo 硬编码清零</li>
|
||||
<li>usercenter:shared.tsx 的 card/input/LangSeg 对齐 atoms 语义,13 处硬编码核对</li>
|
||||
<li>两端 langsel 一致性纳入登记;更新 CONTRACT.md(Web 原子清单 + 屏级三态台账)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 4 — Flutter 收尾 + golden 补齐</h2>
|
||||
<ul>
|
||||
<li>清 adaptive_menu.dart 唯 1 处裸色 → token</li>
|
||||
<li>测试字体补 CJK 子集(make-cjk-subset.sh → client/test/fonts + flutter_test_config 注册)</li>
|
||||
<li>处理现存 6 张 failure diff,逐张确认后 --update-goldens 重录</li>
|
||||
<li>golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵</li>
|
||||
<li>harness 对齐 jiu:多主题循环 + 钉死 viewport/dpr + ProviderScope 固定数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<h2>Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检</h2>
|
||||
<ul>
|
||||
<li>硬编码色扫描:Flutter <code>check_ds_code.mjs</code>(含 --changed)+ Web hex 并入 check-l1-sync</li>
|
||||
<li>原型校验 <code>check-ds.mjs</code>(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)</li>
|
||||
<li>CI 串起来:原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(补 mobile+主题)</li>
|
||||
<li>启用 pre-commit:install-hooks 纳入文档,增挂 check-ds --changed(条件触发)</li>
|
||||
<li>fidelity 像素闸(本地体检,不进 CI):screens.mjs + fidelity.mjs,逐屏阈值=实测残差+2pp</li>
|
||||
<li>全景文档 docs/frontend-overview.html(照搬 jiu 十节)+ 登记索引</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Verification(端到端)</h2>
|
||||
<ul>
|
||||
<li><b>原型</b>:serve.mjs 逐屏目检 light/dark;check-ds 12 道全绿</li>
|
||||
<li><b>同源</b>:check-l1-sync 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)</li>
|
||||
<li><b>Flutter</b>:analyze + test(golden ×双主题);check_ds_code 绿;codegen 重跑零 diff</li>
|
||||
<li><b>Web</b>:两端 build 通过;token 同源绿;langsel/button/card 对齐 atoms</li>
|
||||
<li><b>fidelity</b>:逐屏残差在阈内(首次校准记录实测值)</li>
|
||||
<li><b>闸生效</b>:install-hooks 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下</li>
|
||||
</ul>
|
||||
|
||||
<h2>不在本轮</h2>
|
||||
<ul>
|
||||
<li>新功能 / 新屏开发(本轮是治理重构)</li>
|
||||
<li>iOS/iPad 专属布局深度优化(响应式已覆盖)</li>
|
||||
<li>三主题扩展(保持 light/dark)</li>
|
||||
</ul>
|
||||
|
||||
<p class="small" style="margin-top:32px">真相源(含 checkbox 执行跟踪):<code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -86,6 +86,11 @@
|
||||
</a>
|
||||
|
||||
<h2>实现计划 / Plans</h2>
|
||||
<a class="doc" href="frontend-ds-refactor-plan.html">
|
||||
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
|
||||
<div class="path">docs/frontend-ds-refactor-plan.html · 真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div>
|
||||
</a>
|
||||
<a class="doc" href="control-plane-tls-tunnel.html">
|
||||
<div class="t">控制面 TLS(Cloudflare Tunnel 前置)实现计划 <span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md</code>(含 checkbox)。把控制面 API 从明文 <code>http://103.119.13.48:8080</code> 迁到 <code>https://api.yanmeiai.com</code>(cloudflared 出站隧道前置,源站仅绑 127.0.0.1,数据面 sing-box REALITY :443 全程不动)。6 任务:CF Tunnel 供给 → 客户端切 https/Android 去明文 → CI 守护禁明文 → PANGOLIN_PUBLIC_URL 切 https → 退役明文口(收 loopback,带上线顺序闸)→ 文档。</div>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# 前端设计系统治理重构(ds-flow 落地全端)
|
||||
|
||||
> 用 ds-flow 方法论把 pangolin 全部前端(Flutter 五端 + 官网 website + 用户中心 usercenter)
|
||||
> 收口到「设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸在提交/CI 前拦截,
|
||||
> 走样由 golden/fidelity 双级像素验收兜底」。
|
||||
>
|
||||
> **关键前提(摸底结论)**:pangolin 不是从零 bootstrap,已约 65% 达标——
|
||||
> token 单源(`design/colors_and_type.css` 含 `[data-theme=dark]`)、Flutter codegen + drift 闸、
|
||||
> golden + CI 闸、pre-commit(写好未启用)都在。本计划是**补缺口 + Web 共享原子层去重**,
|
||||
> 不是推倒重来。
|
||||
>
|
||||
> 主题模型:pangolin 用 **light / dark 两主题**(非 jiu 的 a/b/c 三主题),全程保持。
|
||||
>
|
||||
> 已定决策:① Web 两端**各自实现 + 同源闸**(不建跨端共享组件包);
|
||||
> ② `design/ui_kits/` 的 jsx/css 端原型**收敛为纯 HTML 原型**并删副本;
|
||||
> ③ **先定稿本计划,再逐刀执行**(每刀 commit)。
|
||||
>
|
||||
> 参考样板:`~/code/jiu`(`design/prototype/` + `tools/` + `client/lib/core/theme/` + `docs/frontend-overview.html`)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 更新 CLAUDE.md + 计划落库
|
||||
|
||||
- [x] 0.1 CLAUDE.md 新增「## 前端设计系统治理(ds-flow)」章节:
|
||||
- 原型单源位置(`design/prototype/`:tokens/atoms/icons/index.html 登记簿)+ 只读约定
|
||||
- codegen 命令(Flutter `gen_flutter_tokens.mjs`;Web `build-tokens.mjs` 同源)
|
||||
- 三层治理 L1/L2/L3 规则速查
|
||||
- 四道静态闸清单 + 「违规谁拦」对照表(原型校验 / 跨端同源 / 代码色单源 / codegen 零 diff)
|
||||
- golden(多主题回归自比)/ fidelity(对原型 pixelmatch,本地体检不进 CI)双闸定位
|
||||
- [x] 0.2 本 `.md` 定稿 + 生成 HTML 阅读版 `docs/frontend-ds-refactor-plan.html`,登记进 `docs/index.html`「实现计划」
|
||||
- [x] 0.3 `/todo` 建 tier-1 条目跟踪本重构,拆 6 个子任务(对应 Phase 1-5 + 收尾)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 原型单源三件套(design/prototype/)
|
||||
|
||||
把散在 `ui_kits/`(6 端 jsx/css 原型)+ `preview/`(20 规格 HTML)+ `_ds_manifest.json`(登记簿)
|
||||
的东西收敛成 ds-flow 标准三件套。
|
||||
|
||||
- [x] 1.1 建 `design/prototype/` 目录;`serve.mjs` 照搬 jiu(零依赖热重载,默认端口按 jiu)
|
||||
- [x] 1.2 `design/prototype/tokens.css`:从现有 `colors_and_type.css` 迁移/规整为
|
||||
「基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块」结构。
|
||||
**保持数值不变**,只重排为 ds-flow 结构;`colors_and_type.css` 作为兼容别名或迁移为薄封装(不破坏现有 codegen)
|
||||
- [ ] 1.3 `design/prototype/atoms.css`:把按钮/卡片/输入/**语言下拉**/徽章/状态药丸等公用原子类沉淀为
|
||||
只引 `var(--token)` 的 CSS(镜像 `design/preview/` 现有规格 + client widgets 实现语义)
|
||||
- [ ] 1.4 `design/prototype/icons.js`:SVG sprite 单源(`<symbol id="i-*">`),
|
||||
收敛现有分散图标(website Icon.astro / usercenter icons.tsx / Flutter pangolin_icons.dart 三处的图标集)
|
||||
- [ ] 1.5 `design/prototype/index.html`:活登记页——三…两主题(light/dark)切换 + `data-swatches` 声明式色板 +
|
||||
字号梯度 + 圆角/间距/阴影 + 全部公用组件原子展示卡 + 图标库全展示。**每个 atom 必须在此登记**
|
||||
- [ ] 1.6 `design/ui_kits/` 的 jsx/css 端原型:提炼进 prototype 后**删除 jsx 组件副本**(消除与
|
||||
「禁向 design/ 提组件代码副本」的冲突 + 漂移源);保留必要的屏级 HTML 布局参考迁进 `prototype/screens/`
|
||||
- [ ] 1.7 更新/退役 `_ds_manifest.json` + `_ds_bundle.js`:登记簿职责交给 `index.html`,
|
||||
manifest 若仍被消费则保留为派生产物(记清谁是真源)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Web token 升为一等公民 + 同源闸
|
||||
|
||||
现状:Web 的 `build-tokens.mjs` 只是「原样拷 css,删 Google Fonts 行」。升级为受闸守护的同源关系。
|
||||
|
||||
- [ ] 2.1 确认两端 token 落点与生成链:website→`src/styles/tokens.gen.css`、
|
||||
usercenter→`public/colors_and_type.css`;源统一指向 `design/prototype/tokens.css`(Phase 1 后)
|
||||
- [ ] 2.2 建 `tools/check-l1-sync.mjs`(照搬 jiu 裁剪):
|
||||
- ① website `tokens.gen.css` token 值 ≡ 原型 tokens.css(逐值)
|
||||
- ② usercenter `public/colors_and_type.css` ≡ 原型(逐值)
|
||||
- ③ icons 同源:website / usercenter / Flutter 三处图标集 ⊆ 原型 icons.js sprite
|
||||
- ④ Web 硬编码色扫描(白名单 `#fff/#000/logo 固定色`,其余报警)
|
||||
- [ ] 2.3 codegen 幂等:重跑 `build-tokens.mjs` 后 `git diff` 零差异(纳入 CI,见 Phase 5)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Web 共享原子层对齐(各自实现 + 同源闸)★工作量最大
|
||||
|
||||
不建跨端组件包;两端各自实现,但都对齐 `design/prototype/atoms.css`,靠闸保证不漂移。
|
||||
|
||||
- [ ] 3.1 抽公共原子清单:langsel(语言下拉,刚修的两套合规范)/ button / card / input / badge / pill。
|
||||
对每个原子在 `atoms.css` 定义 canonical 样式
|
||||
- [ ] 3.2 website:`website.css` + `site-extra.css` 里的按钮/卡片/下拉 class 对齐 atoms.css 语义,
|
||||
残留 `#fff/#000`/logo 外的硬编码色清零(当前业务硬编码 ~30 处,多为可保留的白/黑/logo)
|
||||
- [ ] 3.3 usercenter:`shared.tsx` 的 `card/input/LangSeg` 内联对象对齐 atoms.css 语义;
|
||||
残留 13 处硬编码(基本 `#fff`)核对,非白/黑/logo 的清零
|
||||
- [ ] 3.4 两端 langsel 行为/样式一致性核对(此前刚统一为自定义下拉,纳入 atoms 登记)
|
||||
- [ ] 3.5 更新 `design/CONTRACT.md`:Web 原子清单 + 屏级台账(同步/快照/代码先行三态)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Flutter 收尾 + golden 补齐
|
||||
|
||||
Flutter 已很干净(UI 层零裸 hex),只需收尾。
|
||||
|
||||
- [ ] 4.1 清 `client/lib/widgets/adaptive_menu.dart` 唯 1 处裸 Material 色 → 走 token
|
||||
- [ ] 4.2 测试字体补 CJK 子集:用 `tools/fonts/make-cjk-subset.sh` 生成 Noto Sans SC 子集放
|
||||
`client/test/fonts/`,`flutter_test_config.dart` 注册——消除 golden 中文与生产渲染差异
|
||||
- [ ] 4.3 处理现存 6 张 `client/test/golden/failures/` diff:逐张确认「原型对得上」后 `--update-goldens` 重录入库
|
||||
- [ ] 4.4 golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵
|
||||
(现有 `desktop_pages/tablet_pages/components/auth` → 补 mobile + 主题维度)
|
||||
- [ ] 4.5 `client/test/helpers/harness.dart` 对齐 jiu `golden_harness.dart` 手法:
|
||||
多主题循环辅助 + 钉死 viewport/dpr + ProviderScope 固定数据(防动态值翻车)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检
|
||||
|
||||
- [ ] 5.1 硬编码色扫描闸:
|
||||
- Flutter `client/tool/check_ds_code.mjs`(照搬 jiu,含 `--changed` 供 pre-commit):禁 `Color(0x..)`/裸 `Colors.x`
|
||||
- Web hex 扫描并入 `check-l1-sync.mjs` ④
|
||||
- [ ] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)
|
||||
- [ ] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补):
|
||||
原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(已有,补 mobile+主题)
|
||||
- [ ] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md,
|
||||
`.githooks/pre-commit` 增挂 `check-ds --changed`(只在动了 `design/prototype/` 时跑,轻量条件触发)
|
||||
- [ ] 5.5 fidelity 像素闸(本地体检,不进 CI):`tools/screens.mjs` 屏注册表 + `tools/fidelity.mjs`
|
||||
(原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp,两边统一注入 CJK 字体)
|
||||
- [ ] 5.6 全景文档 `docs/frontend-overview.html`(照搬 jiu 十节):一次 UI 改动标准路径 + 目录地图 +
|
||||
三层分治 + 闸全景 + 像素验收体系 + 响应式范式 + 规则速查,登记进 docs/index.html
|
||||
|
||||
---
|
||||
|
||||
## Verification(端到端)
|
||||
|
||||
- **原型**:`node design/prototype/serve.mjs` 起服务,浏览器逐屏目检 light/dark;`check-ds.mjs` 12 道全绿
|
||||
- **同源**:`node tools/check-l1-sync.mjs` 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)
|
||||
- **Flutter**:`flutter analyze` + `flutter test`(含 golden ×双主题);`check_ds_code.mjs` 全绿;codegen 重跑零 diff
|
||||
- **Web**:两端 `npm run build` 通过;token 同源闸绿;langsel/button/card 对齐 atoms
|
||||
- **fidelity**:`node tools/fidelity.mjs` 逐屏残差在阈内(首次校准记录各屏实测值)
|
||||
- **闸生效**:`ci/install-hooks.sh` 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下
|
||||
|
||||
## 不在本轮
|
||||
|
||||
- 新功能/新屏开发(本轮是治理重构,不加业务)
|
||||
- iOS/iPad 专属布局深度优化(响应式已覆盖,超阈再单独立项)
|
||||
- 三主题扩展(保持 light/dark 双主题)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)/模拟器
|
||||
# (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"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# compile-usercenter.sh — build the Next.js 用户中心 (web/usercenter) as a static
|
||||
# export (output: web/usercenter/out/). Deployed to Cloudflare Pages
|
||||
# (project pangolin-usercenter, 自定义域 app.yanmeiai.com) by deploy-usercenter.sh.
|
||||
# export (output: web/usercenter/out/, basePath=/user)。由 combine-site.sh 并入官网
|
||||
# dist/user/,随官网一起部署到 CF Pages pangolin-site(pangolin.yanmeiai.com/user/)。
|
||||
# 域名迁移:原独立子域 app.yanmeiai.com 已停用。
|
||||
#
|
||||
# ⚠️ 必须以「真实 API」模式构建:NEXT_PUBLIC_API_MODE=http。否则 lib/api/client.ts
|
||||
# 默认落到 mock(内存假数据),部署上去登录/订阅全是演示数据。API 域名是公开信息
|
||||
|
||||
@@ -51,7 +51,15 @@ VER=""
|
||||
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
|
||||
rm -f "$ver_file"
|
||||
|
||||
echo "==> compile-windows: tag=${TAG} version=${VER}"
|
||||
# build number 同 android/macos 规则(MAJOR*10000+MINOR*100+PATCH)。
|
||||
MAJOR="${VER%%.*}"
|
||||
_REST="${VER#*.}"
|
||||
MINOR="${_REST%%.*}"
|
||||
PATCH="${_REST#*.}"
|
||||
PATCH="${PATCH%%-*}"
|
||||
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
|
||||
echo "==> compile-windows: tag=${TAG} version=${VER} build=${BUILD}"
|
||||
|
||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||
|
||||
@@ -79,6 +87,12 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
|
||||
"${BUILD_CLIENT}/.dart_tool" \
|
||||
"${BUILD_CLIENT}/build"
|
||||
|
||||
# 同步 Flutter app 版本到 tag(GNU sed,windows runner)。否则 package_info 卡在
|
||||
# pubspec committed 值(1.0.48),设置页显示旧版 + 自动更新永远判"有新版"。
|
||||
# 此前只改了 Inno 安装器 MyAppVersion(下方),App 内部版本没跟上——本次修复。
|
||||
sed -i "s/^version:.*/version: ${VER}+${BUILD}/" "${BUILD_CLIENT}/pubspec.yaml"
|
||||
echo "==> compile-windows: pubspec version -> ${VER}+${BUILD}"
|
||||
|
||||
# ── [3/4] build ──────────────────────────────────────────────────────────────
|
||||
# NOTE(controller): `flutter create` on an existing project only fills in
|
||||
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
|
||||
@@ -89,6 +103,7 @@ rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
|
||||
# on first real CI run that none of those get clobbered.
|
||||
pushd "$BUILD_CLIENT" > /dev/null
|
||||
flutter create --platforms=windows . --project-name pangolin_vpn
|
||||
flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh)
|
||||
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
|
||||
popd > /dev/null
|
||||
|
||||
|
||||
@@ -1,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.Recoverer)
|
||||
r.Use(apierr.Middleware)
|
||||
// CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。
|
||||
// CORS:Web 用户中心(pangolin.yanmeiai.com/user)跨域调 /v1/*;原生端不受影响。
|
||||
r.Use(httpapi.NewCORS())
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
func NewCORS() func(http.Handler) http.Handler {
|
||||
raw := os.Getenv("CORS_ORIGINS")
|
||||
if raw == "" {
|
||||
raw = "https://app.yanmeiai.com,https://pangolin-usercenter.pages.dev"
|
||||
// 用户中心已迁到主站子路径 pangolin.yanmeiai.com/user/,其 origin 即主站域名
|
||||
// (旧 app.yanmeiai.com 已停用)。pages.dev 为 CF Pages 直连兜底。
|
||||
raw = "https://pangolin.yanmeiai.com,https://pangolin-site.pages.dev"
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, o := range strings.Split(raw, ",") {
|
||||
@@ -33,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,21 +3,22 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCORS(t *testing.T) {
|
||||
t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com")
|
||||
t.Setenv("CORS_ORIGINS", "https://pangolin.yanmeiai.com")
|
||||
mw := NewCORS()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||
h := mw(next)
|
||||
|
||||
// 允许的 Origin:补 Allow-Origin,普通请求继续。
|
||||
r := httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||
r.Header.Set("Origin", "https://pangolin.yanmeiai.com")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.yanmeiai.com" {
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://pangolin.yanmeiai.com" {
|
||||
t.Fatalf("allowed origin: want header, got %q", got)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
@@ -26,7 +27,7 @@ func TestCORS(t *testing.T) {
|
||||
|
||||
// OPTIONS 预检:204,不落到业务。
|
||||
r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil)
|
||||
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||
r.Header.Set("Origin", "https://pangolin.yanmeiai.com")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNoContent {
|
||||
@@ -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)
|
||||
|
||||
+138
-6
@@ -100,6 +100,8 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
|
||||
.ver-badge { font-weight: 700; color: #1f7a44; }
|
||||
.sub-progress-badge { background: #f0f4ff; color: #3730a3; }
|
||||
.owner-user{background:rgba(232,192,122,.16);border:1px solid #e8c07a;color:#e8c07a}
|
||||
.owner-agent{background:rgba(126,224,162,.14);border:1px solid #7ee0a2;color:#7ee0a2}
|
||||
|
||||
/* 状态徽章 */
|
||||
.status-badge { font-size: 11.5px; }
|
||||
@@ -246,6 +248,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
.modal-cmd-code { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 13px; color: #86efac; word-break: break-all; display: block; }
|
||||
.modal-copy-btn { margin-top: 10px; padding: 5px 14px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
|
||||
.modal-copy-btn:hover { background: #475569; }
|
||||
/* ════ 家族深色主题覆盖层(2026-07-06 对齐 brain/docs 家族风格;后置覆盖上方浅色基础样式) ════ */
|
||||
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--amber:#d29922;--red:#f85149}
|
||||
body{color:var(--fg);background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg)}
|
||||
header{background:linear-gradient(135deg,#101a2c 0%,#15294a 100%);border-bottom:1px solid var(--border)}
|
||||
header .header-meta{color:var(--fg-soft)}
|
||||
.stat-pill{background:rgba(88,166,255,.12);border:1px solid var(--border)}
|
||||
.filter-bar{background:rgba(13,17,23,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border)}
|
||||
.filter-label{color:var(--muted)}
|
||||
.filter-chip,.filter-btn{background:var(--card);border-color:var(--border);color:var(--fg-soft)}
|
||||
.filter-chip:hover,.filter-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.filter-chip.active,.filter-btn.active{background:var(--accent);border-color:var(--accent);color:#0d1117}
|
||||
.filter-sep{background:var(--border)}
|
||||
.section-title .s-count{background:rgba(255,255,255,.08)}
|
||||
.st-open {background:rgba(88,166,255,.10);color:#79b8ff;border-left-color:var(--accent)}
|
||||
.st-doing {background:rgba(210,153,34,.10);color:#e3b341;border-left-color:var(--amber)}
|
||||
.st-done {background:rgba(63,185,80,.08);color:#7ee787;border-left-color:var(--green)}
|
||||
.st-accepted{background:rgba(63,185,80,.12);color:#7ee787;border-left-color:var(--green)}
|
||||
.todo-card{background:var(--card);border-color:var(--border)}
|
||||
.todo-card.s-doing{border-left-color:var(--amber)}
|
||||
.todo-card.s-done{border-left-color:var(--green)}
|
||||
.todo-card.s-accepted{background:rgba(63,185,80,.06);border-left-color:var(--green)}
|
||||
.item-id{color:var(--accent);background:#0b1020;border-color:var(--border)}
|
||||
.item-title{color:var(--fg)}
|
||||
.todo-card.s-accepted .item-title{color:var(--muted)}
|
||||
.item-desc{color:var(--fg-soft)}
|
||||
.item-desc code{background:#0b1020;border:1px solid var(--border);color:#c9d6e6}
|
||||
.item-meta{color:var(--muted)}
|
||||
.t-block{background:rgba(248,81,73,.15);color:#ff9492}
|
||||
.t-high{background:rgba(210,153,34,.15);color:#e3b341}
|
||||
.t-low{background:rgba(88,166,255,.12);color:#79b8ff}
|
||||
.t-tag{background:var(--card-2);color:var(--fg-soft)}
|
||||
.t-tag:hover{background:rgba(88,166,255,.15);color:var(--accent)}
|
||||
.ver-badge{color:var(--green)}
|
||||
.sub-progress-badge{background:rgba(88,166,255,.12);color:#79b8ff}
|
||||
.s-open{background:rgba(88,166,255,.15);color:#79b8ff}
|
||||
.s-doing{background:rgba(210,153,34,.18);color:#e3b341}
|
||||
.s-done{background:rgba(63,185,80,.15);color:#7ee787}
|
||||
.s-accepted{background:rgba(63,185,80,.18);color:#7ee787}
|
||||
.empty-tip{color:var(--muted)}
|
||||
.tier-1{background:rgba(163,113,247,.15);color:#d2a8ff}
|
||||
.tier-2{background:rgba(57,197,187,.12);color:#76e3db}
|
||||
.tier-3{background:var(--card-2);color:var(--fg-soft)}
|
||||
.reject-btn{background:transparent;border-color:var(--red);color:#ff9492}
|
||||
.reject-btn:hover{background:var(--red);color:#0d1117}
|
||||
.reject-note{background:rgba(248,81,73,.08);border-color:rgba(248,81,73,.4);color:#ff9492}
|
||||
.reject-date{color:rgba(248,81,73,.6)}
|
||||
.gate-pending{background:rgba(210,153,34,.08);border-color:rgba(210,153,34,.4)}
|
||||
.gate-granted{background:rgba(63,185,80,.08);border-color:rgba(63,185,80,.4)}
|
||||
.gate-info{background:var(--card-2);border-color:var(--border)}
|
||||
.gate-kind,.gate-note,.gate-ref{color:var(--fg-soft)}
|
||||
.gate-date{color:var(--muted)}
|
||||
.gate-note code,.gate-ref code{background:#0b1020;border:1px solid var(--border);color:#c9d6e6}
|
||||
.subtask-block{background:var(--card-2);border-color:var(--border)}
|
||||
.subtask-label{color:var(--muted)}
|
||||
.subtask-progress-text{color:var(--fg-soft)}
|
||||
.subtask-progress-bar{background:var(--border)}
|
||||
.subtask-item{background:var(--card);border-color:var(--border)}
|
||||
.subtask-item.s-done,.subtask-item.s-accepted{background:rgba(63,185,80,.06);border-color:rgba(63,185,80,.3)}
|
||||
.subtask-item.s-doing{background:rgba(210,153,34,.06);border-color:rgba(210,153,34,.3)}
|
||||
.sub-icon.s-open{color:var(--muted)}
|
||||
.sub-sid{color:var(--muted)}
|
||||
.subtask-item.s-done .sub-title,.subtask-item.s-accepted .sub-title{color:var(--muted)}
|
||||
.dep-label{color:var(--muted)}
|
||||
.dep-done{background:rgba(63,185,80,.15);color:#7ee787}
|
||||
.dep-pending{background:rgba(248,81,73,.15);color:#ff9492}
|
||||
.modal-overlay{background:rgba(0,0,0,.6)}
|
||||
.modal-box{background:var(--card);border:1px solid var(--border)}
|
||||
.modal-box h3{color:var(--fg)}
|
||||
.modal-subtitle{color:var(--fg-soft)}
|
||||
.modal-label{color:var(--fg-soft)}
|
||||
.modal-textarea{background:#0b1020;border-color:var(--border);color:var(--fg)}
|
||||
.modal-btn-cancel{background:var(--card-2);color:var(--fg-soft)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -253,14 +327,14 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>feature+windows — 项目 TODO</h1>
|
||||
<div class="header-meta">生成于 2026-07-01 · 真相源 todo/todo.json</div>
|
||||
<div class="header-meta">生成于 2026-07-07 · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>17</strong>全部</div>
|
||||
<div class="stat-pill"><strong>8</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>18</strong>全部</div>
|
||||
<div class="stat-pill"><strong>9</strong>待开始</div>
|
||||
<div class="stat-pill"><strong>0</strong>开发中</div>
|
||||
<div class="stat-pill"><strong>1</strong>待验收</div>
|
||||
<div class="stat-pill"><strong>8</strong>已验收</div>
|
||||
|
||||
<div class="stat-pill gate-stat"><strong>1</strong>待确认</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -293,7 +367,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">平台 / 标签</span>
|
||||
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button>
|
||||
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,7 +376,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
<div class="section-block" id="section-open">
|
||||
<div class="section-title st-open" data-toggle="open">
|
||||
📋 待开始 <span class="s-count">8</span>
|
||||
📋 待开始 <span class="s-count">9</span>
|
||||
<span class="s-arrow">▴ 收起</span>
|
||||
</div>
|
||||
<div class="section-list-wrap " id="list-wrap-open">
|
||||
@@ -332,6 +406,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-22</span>
|
||||
|
||||
</div>
|
||||
@@ -362,6 +437,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-22</span>
|
||||
|
||||
</div>
|
||||
@@ -392,6 +468,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="运维">运维</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
|
||||
</div>
|
||||
@@ -422,6 +499,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
|
||||
</div>
|
||||
@@ -452,6 +530,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
|
||||
</div>
|
||||
@@ -482,6 +561,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-29</span>
|
||||
|
||||
</div>
|
||||
@@ -512,12 +592,54 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-30</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="19"
|
||||
data-level="mid"
|
||||
data-status="open"
|
||||
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-open">待开始</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-pending">
|
||||
<div class="gate-head"><span class="gate-badge pending">⏸ 待确认</span>
|
||||
<span class="gate-kind">一级方案规划 · 确认后方可继续开发</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>
|
||||
<button class="approve-btn" data-id="19">确认执行 ✓</button>
|
||||
<div class="approve-cmd" id="approve-cmd-19" style="display:none">
|
||||
<div class="approve-cmd-label">在终端运行:</div>
|
||||
<code class="approve-cmd-code">/todo approve 19</code>
|
||||
<button class="approve-copy-btn" data-cmd="/todo approve 19">复制命令</button>
|
||||
</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>
|
||||
|
||||
<li class="todo-card s-open"
|
||||
data-id="3"
|
||||
data-level="low"
|
||||
@@ -542,6 +664,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="Windows">Windows</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-22</span>
|
||||
|
||||
</div>
|
||||
@@ -593,6 +716,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-07-01</span>
|
||||
|
||||
</div>
|
||||
@@ -633,6 +757,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -663,6 +788,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -693,6 +819,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -723,6 +850,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="前端">前端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-30</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -753,6 +881,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -783,6 +912,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="gRPC">gRPC</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-29</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-30</span>
|
||||
</div>
|
||||
@@ -813,6 +943,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-28</span>
|
||||
</div>
|
||||
@@ -843,6 +974,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<div class="card-footer">
|
||||
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
|
||||
<div class="item-meta">
|
||||
<span class="tag owner-agent">🤖 agent</span>
|
||||
<span class="meta-date">🕐 2026-06-28</span>
|
||||
<span class="meta-date">✅ 验收 2026-06-28</span>
|
||||
</div>
|
||||
|
||||
+66
-19
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "feature+windows — 项目 TODO",
|
||||
"updated_at": "2026-06-30T23:10:43.458Z"
|
||||
"updated_at": "2026-07-07T15:41:36.537Z"
|
||||
},
|
||||
"seq": 18,
|
||||
"seq": 19,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -18,7 +18,8 @@
|
||||
"created_at": "2026-06-22T09:32:42.340Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@@ -33,7 +34,8 @@
|
||||
"created_at": "2026-06-22T09:32:56.098Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -48,7 +50,8 @@
|
||||
"created_at": "2026-06-22T09:32:56.169Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
@@ -64,7 +67,8 @@
|
||||
"created_at": "2026-06-27T16:39:31.549Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
@@ -79,7 +83,8 @@
|
||||
"created_at": "2026-06-27T23:37:24.980Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-28T13:19:24.207Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
@@ -95,7 +100,8 @@
|
||||
"created_at": "2026-06-28T00:21:30.990Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-28T13:19:24.299Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -110,7 +116,8 @@
|
||||
"created_at": "2026-06-28T00:26:44.498Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:35.082Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
@@ -125,7 +132,8 @@
|
||||
"created_at": "2026-06-28T00:38:22.413Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
@@ -141,7 +149,8 @@
|
||||
"created_at": "2026-06-28T09:32:26.008Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:34.810Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
@@ -156,7 +165,8 @@
|
||||
"created_at": "2026-06-28T09:32:26.080Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:34.991Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
@@ -171,7 +181,8 @@
|
||||
"created_at": "2026-06-28T11:27:56.637Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:47:39.343Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
@@ -187,7 +198,8 @@
|
||||
"created_at": "2026-06-28T11:27:56.708Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
@@ -204,7 +216,8 @@
|
||||
"created_at": "2026-06-29T04:55:46.733Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-29T16:59:39.926Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
@@ -221,7 +234,8 @@
|
||||
"created_at": "2026-06-29T09:36:14.004Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
@@ -237,7 +251,8 @@
|
||||
"created_at": "2026-06-30T10:56:48.894Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-30T14:44:34.900Z",
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
@@ -253,7 +268,8 @@
|
||||
"created_at": "2026-06-30T14:33:48.996Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
@@ -269,7 +285,38 @@
|
||||
"created_at": "2026-06-30T23:00:25.193Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"version": null,
|
||||
"owner": "agent"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"title": "前端设计系统治理重构(ds-flow 全端)",
|
||||
"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",
|
||||
"level": "mid",
|
||||
"tier": 1,
|
||||
"tags": [
|
||||
"前端",
|
||||
"Web",
|
||||
"mac",
|
||||
"iOS",
|
||||
"Android",
|
||||
"Windows",
|
||||
"CI/CD"
|
||||
],
|
||||
"owner": "agent",
|
||||
"status": "open",
|
||||
"created_at": "2026-07-07T15:41:25.760Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null,
|
||||
"gate": {
|
||||
"kind": "plan",
|
||||
"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。",
|
||||
"ref": "docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md",
|
||||
"approval": "pending",
|
||||
"proposed_at": "2026-07-07T15:41:36.536Z",
|
||||
"approved_at": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
// basePath 前缀(与 next.config.mjs 同源)。public/ 的根绝对资源(colors_and_type.css)
|
||||
// 不会被 Next 自动加 basePath,需手动拼,否则挂到 /user/ 下会 404。
|
||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/user';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
<link rel="stylesheet" href="/colors_and_type.css" />
|
||||
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
|
||||
</head>
|
||||
<body>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
|
||||
@@ -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,32 +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: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 999,
|
||||
padding: '5px 9px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--fg2)',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
trailingSlash: true,
|
||||
// 用户中心从独立子域 app.yanmeiai.com 迁到主站子路径:与官网合并部署到同一
|
||||
// CF Pages(pangolin-site),挂在 pangolin.yanmeiai.com/user/。basePath 让所有
|
||||
// 路由/资源前缀带 /user;可用 NEXT_PUBLIC_BASE_PATH 覆盖(如本地 dev 置空)。
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '/user',
|
||||
// 静态导出无图片优化服务,关闭以保证产物无服务端依赖。
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* build-tokens.mjs — 设计令牌同源生成器(usercenter)
|
||||
*
|
||||
* 唯一真相来源是仓库根的 `design/colors_and_type.css`。
|
||||
* 唯一真相来源是仓库根的 `design/prototype/tokens.css`。
|
||||
* 本脚本把它原样读入,仅删除 Google Fonts @import 行后写入
|
||||
* `public/colors_and_type.css`(usercenter 通过 <link> 引入此文件)。
|
||||
*
|
||||
@@ -15,11 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../../../design/colors_and_type.css');
|
||||
const SRC = resolve(__dirname, '../../../design/prototype/tokens.css');
|
||||
const OUT = resolve(__dirname, '../public/colors_and_type.css');
|
||||
|
||||
const BANNER =
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。' +
|
||||
'生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
@@ -39,4 +39,4 @@ const stripped = raw
|
||||
.join('\n');
|
||||
|
||||
writeFileSync(OUT, BANNER + stripped, 'utf8');
|
||||
console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
|
||||
console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/prototype/tokens.css)`);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* build-tokens.mjs — 设计令牌同源生成器
|
||||
*
|
||||
* 唯一真相来源是仓库根的 `design/colors_and_type.css`(铁律 1:颜色只用语义 token)。
|
||||
* 唯一真相来源是仓库根的 `design/prototype/tokens.css`(铁律 1:颜色只用语义 token)。
|
||||
* 本脚本把它原样读入,仅做一处「必须的」改写后写入 `src/styles/tokens.gen.css`:
|
||||
*
|
||||
* 删除其中加载 Google Fonts 的 `@import url('https://fonts.googleapis.com/...')` 一行。
|
||||
@@ -20,11 +20,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../../../design/colors_and_type.css');
|
||||
const SRC = resolve(__dirname, '../../../design/prototype/tokens.css');
|
||||
const OUT = resolve(__dirname, '../src/styles/tokens.gen.css');
|
||||
|
||||
const BANNER =
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。' +
|
||||
'生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
@@ -50,4 +50,4 @@ if (/fonts\.(googleapis|gstatic)\.com/i.test(stripped.replace(/^\s*(\/\/|\*|\/\*
|
||||
}
|
||||
|
||||
writeFileSync(OUT, BANNER + stripped, 'utf8');
|
||||
console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
|
||||
console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/prototype/tokens.css)`);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -21,7 +21,7 @@ export const SITE = {
|
||||
store: { label: 'shop.pangolin.vpn' },
|
||||
|
||||
/** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
|
||||
usercenter: 'https://app.yanmeiai.com',
|
||||
usercenter: 'https://pangolin.yanmeiai.com/user/',
|
||||
|
||||
/**
|
||||
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
||||
|
||||
@@ -40,19 +40,24 @@
|
||||
}
|
||||
.langseg a.on { background: var(--accent); color: var(--fg-on-accent); }
|
||||
|
||||
/* 6 语用原生下拉(横排段控会挤);沿用 langseg 的药丸视觉。 */
|
||||
/* 6 语用原生下拉(横排段控会挤)。圆角矩形 + 柔和边框(原纯 --border 太生硬)。 */
|
||||
.langsel {
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid color-mix(in oklab, var(--border), transparent 55%);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 5px 9px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--fg2);
|
||||
background: var(--bg-subtle);
|
||||
box-shadow: 0 1px 2px rgba(45, 30, 20, 0.05);
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
.langsel:hover {
|
||||
border-color: color-mix(in oklab, var(--accent), transparent 40%);
|
||||
box-shadow: 0 1px 4px rgba(45, 30, 20, 0.08);
|
||||
}
|
||||
.langsel:hover { border-color: var(--accent); }
|
||||
|
||||
/* 视觉隐藏(无障碍用,当前未强依赖) */
|
||||
.visually-hidden {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
|
||||
Reference in New Issue
Block a user