Compare commits

...

8 Commits

Author SHA1 Message Date
wangjia 07c339c18e feat(server/devices): users.max_devices_override 单用户设备上限覆盖(NULL走套餐默认)+ 迁移000025
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 16s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m8s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m37s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 10:15:07 +08:00
wangjia 3d24451835 fix(nodes): 桌面 Nodes 页统一 600 宽度(原型挂 view-body + Flutter PageBody)+ 双列网格改单列镜像原型
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 10:13:48 +08:00
wangjia a61641b53b refactor(client): SubScaffold 六合一(视觉零变化)+ tablet 注释订正 + 平板通知铃入口(#20) 2026-07-13 10:07:18 +08:00
wangjia 3a8ff64921 fix(client): 审计修复——device_limit 相对时间走 relativeTime 单源+裸 Icons 换 PangolinIcons+Payment onDone 三处接通+内联双语文案收进 AppText 2026-07-13 09:56:22 +08:00
wangjia e95995f20f fix(prototype): 桌面补 redeem 兑换视图 + accRedeemRow 接线改指 redeem(原为误指购买页) 2026-07-13 09:53:25 +08:00
wangjia fa9a5c2d5e design+client: 抽 .view-body/PageBody 统一桌面内容页宽度(600/wide 840)——stats·contact 补约束,settings·contact 560→600,九页收口
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 09:46:57 +08:00
wangjia b88921a030 fix(prototype): ui-desktop 补 [hidden] display:none 守卫——登录静态屏 signup 表单不再与 signin 并排溢出
.login-form{display:flex} 作者样式覆盖了 hidden 属性的 UA display:none,致带 hidden 的
signupForm 被顶出来与 signin 并排、溢出 760px 分屏卡被裁。补 ui-mobile.html:93 同款
[hidden]{display:none!important} 全局守卫;已审计全部 hidden 元素均用 .hidden 属性切换,
无 style.display 依赖冲突。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:29:39 +08:00
wangjia a48e38cac0 design(prototype): 回填邀请屏——战绩三格+TG任务卡两态+具体天数(镜像 invite_page.dart,补原型先行欠账) 2026-07-13 09:23:14 +08:00
53 changed files with 696 additions and 268 deletions
+3
View File
@@ -565,6 +565,9 @@ abstract class AppText {
String get switchPayMethod; // 换一种支付方式 / Switch payment method
String get cancelOrder; // 取消订单 / Cancel order
String get qrNotSupported;
String get orderCreateFailed; // 下单失败,请稍后重试 / Failed to create order, please retry(errorZh/En 为空时兜底)
String get payUnsupportedRenderType; // 暂不支持,请换支付方式 / Unsupported, switch payment method
String get payNoActiveOrder; // 暂无进行中的订单 / No active order
// ── 支付渠道 switch + 订单 ──
String get payChannel;
+6
View File
@@ -427,6 +427,12 @@ class StringsEn extends AppText {
String get cancelOrder => 'Cancel order';
@override
String get qrNotSupported => 'Copy and open in Alipay';
@override
String get orderCreateFailed => 'Failed to create order, please retry';
@override
String get payUnsupportedRenderType => 'Unsupported, switch payment method';
@override
String get payNoActiveOrder => 'No active order';
// ── 支付渠道 switch + 订单 ──
@override
+6
View File
@@ -428,6 +428,12 @@ class StringsEs extends AppText {
String get cancelOrder => 'Cancelar pedido';
@override
String get qrNotSupported => 'Copia el contenido y ábrelo en Alipay';
@override
String get orderCreateFailed => 'Error al crear el pedido, inténtalo de nuevo más tarde';
@override
String get payUnsupportedRenderType => 'No compatible, cambia el método de pago';
@override
String get payNoActiveOrder => 'No hay pedidos activos';
// ── 支付渠道 switch + 订单 ──
@override
+6
View File
@@ -428,6 +428,12 @@ class StringsJa extends AppText {
String get cancelOrder => '注文をキャンセル';
@override
String get qrNotSupported => '内容をコピーしてAlipayで開いてください';
@override
String get orderCreateFailed => '注文の作成に失敗しました。しばらくして再試行してください';
@override
String get payUnsupportedRenderType => '未対応です。支払い方法を変更してください';
@override
String get payNoActiveOrder => '進行中の注文はありません';
// ── 支付渠道 switch + 订单 ──
@override
+6
View File
@@ -428,6 +428,12 @@ class StringsKo extends AppText {
String get cancelOrder => '주문 취소';
@override
String get qrNotSupported => '내용을 복사해 Alipay에서 여세요';
@override
String get orderCreateFailed => '주문 생성에 실패했습니다. 잠시 후 다시 시도하세요';
@override
String get payUnsupportedRenderType => '지원되지 않습니다. 결제 방법을 변경하세요';
@override
String get payNoActiveOrder => '진행 중인 주문이 없습니다';
// ── 支付渠道 switch + 订单 ──
@override
+6
View File
@@ -428,6 +428,12 @@ class StringsRu extends AppText {
String get cancelOrder => 'Отменить заказ';
@override
String get qrNotSupported => 'Скопируйте и откройте в Alipay';
@override
String get orderCreateFailed => 'Не удалось создать заказ, повторите попытку позже';
@override
String get payUnsupportedRenderType => 'Не поддерживается, смените способ оплаты';
@override
String get payNoActiveOrder => 'Нет активных заказов';
// ── 支付渠道 switch + 订单 ──
@override
+6
View File
@@ -421,6 +421,12 @@ class StringsZh extends AppText {
String get cancelOrder => '取消订单';
@override
String get qrNotSupported => '请复制内容后在支付宝内打开';
@override
String get orderCreateFailed => '下单失败,请稍后重试';
@override
String get payUnsupportedRenderType => '暂不支持,请换支付方式';
@override
String get payNoActiveOrder => '暂无进行中的订单';
// ── 支付渠道 switch + 订单 ──
@override
+8 -6
View File
@@ -12,6 +12,7 @@ import '../state/navigation_provider.dart';
import '../widgets/account_screens.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/notification_bell.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
import 'invite_page.dart';
import 'notifications_page.dart';
@@ -50,7 +51,7 @@ class AccountPage extends ConsumerWidget {
}
}
final body = ListView(
final body = PageBody(child: ListView(
padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24),
children: [
_PlanBanner(
@@ -63,8 +64,8 @@ class AccountPage extends ConsumerWidget {
NavView.purchase,
PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))),
onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))),
),
),
),
@@ -86,8 +87,9 @@ class AccountPage extends ConsumerWidget {
NavView.purchase,
PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))),
onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) =>
PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))),
),
)),
Divider(height: 1, color: c.border),
@@ -127,7 +129,7 @@ class AccountPage extends ConsumerWidget {
),
]),
],
);
));
if (isWide) return body;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+2 -2
View File
@@ -10,6 +10,7 @@ import '../pangolin_theme.dart';
import '../services/channel_launch.dart';
import '../services/contact_channels.dart';
import '../state/app_providers.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
class ContactPage extends ConsumerWidget {
@@ -36,8 +37,7 @@ class ContactPage extends ConsumerWidget {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(32, 16, 32, 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: PageBody(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2)),
const SizedBox(height: 16),
+10 -16
View File
@@ -12,6 +12,7 @@ import '../services/auth_api.dart';
import '../services/device_identity.dart';
import '../state/account_providers.dart';
import '../state/auth_provider.dart';
import '../widgets/pangolin_icons.dart';
class DeviceLimitScreen extends ConsumerStatefulWidget {
const DeviceLimitScreen({super.key, required this.t, required this.limit});
@@ -84,7 +85,7 @@ class _DeviceLimitScreenState extends ConsumerState<DeviceLimitScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Icon(Icons.devices_other_rounded, size: 40, color: c.accent),
Icon(PangolinIcons.monitorSmartphone, size: 40, color: c.accent),
const SizedBox(height: 14),
Text(t.deviceLimitTitle,
textAlign: TextAlign.center,
@@ -97,7 +98,7 @@ class _DeviceLimitScreenState extends ConsumerState<DeviceLimitScreen> {
if (removableCount > 0)
OutlinedButton.icon(
onPressed: _busyUuid != null ? null : () => _removeOldest(devices, currentId),
icon: const Icon(Icons.history_rounded, size: 18),
icon: const Icon(PangolinIcons.clock, size: 18),
label: Text(t.deviceLimitRemoveOldest),
style: OutlinedButton.styleFrom(
foregroundColor: c.accent,
@@ -136,7 +137,9 @@ class _DeviceLimitScreenState extends ConsumerState<DeviceLimitScreen> {
final busy = _busyUuid == d.uuid;
final sub = d.online
? t.devOnline
: (d.lastSeen != null ? '${t.devLastOnline} ${_rel(d.lastSeen!)}' : t.devOffline);
: (d.lastSeen != null
? '${t.devLastOnline} ${t.lang.relativeTime(DateTime.now().difference(d.lastSeen!.toLocal()))}'
: t.devOffline);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
@@ -184,18 +187,9 @@ class _DeviceLimitScreenState extends ConsumerState<DeviceLimitScreen> {
}
static IconData _platformIcon(DevicePlatform p) => switch (p) {
DevicePlatform.ios || DevicePlatform.android => Icons.smartphone_rounded,
DevicePlatform.windows || DevicePlatform.linux => Icons.computer_rounded,
DevicePlatform.macos => Icons.laptop_mac_rounded,
DevicePlatform.unknown => Icons.devices_other_rounded,
DevicePlatform.ios || DevicePlatform.android => PangolinIcons.smartphone,
DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.monitor,
DevicePlatform.macos => PangolinIcons.laptop,
DevicePlatform.unknown => PangolinIcons.monitorSmartphone,
};
// 相对时间(粗粒度):x 分钟/小时/天前。
static String _rel(DateTime t) {
final d = DateTime.now().toUtc().difference(t.toUtc());
if (d.inMinutes < 1) return '刚刚';
if (d.inMinutes < 60) return '${d.inMinutes} 分钟前';
if (d.inHours < 24) return '${d.inHours} 小时前';
return '${d.inDays} 天前';
}
}
+9 -21
View File
@@ -12,9 +12,11 @@ import '../pangolin_theme.dart';
import '../services/channel_launch.dart';
import '../services/invite_api.dart';
import '../state/invite_provider.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_button.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/sub_scaffold.dart';
class InviteScreen extends ConsumerWidget {
const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false});
@@ -58,35 +60,21 @@ class InviteScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final c = context.pangolin;
final inviteAsync = ref.watch(inviteProvider);
Widget body = inviteAsync.when(
Widget body = PageBody(child: inviteAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _Placeholder(t: t),
data: (info) => info == null
? _Placeholder(t: t)
: _InviteContent(t: t, info: info, onCopy: _copy, onVerifyAndClaim: (ctx) => _verifyAndClaim(ctx, ref)),
);
));
if (embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
return SubScaffold(
title: t.inviteTitle,
onBack: onBack,
embedded: embedded,
child: body,
);
}
}
+21 -19
View File
@@ -13,6 +13,7 @@ import '../state/nodes_provider.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/country_code.dart';
import '../widgets/pangolin_field.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/server_tile.dart';
import '../widgets/smart_select_card.dart';
@@ -128,31 +129,32 @@ class _NodesPageState extends ConsumerState<NodesPage> {
]);
if (widget.isWide) {
// 对照 dapp.jsx DServerspadding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。
// 桌面 Nodes 页宽度统一为 view-body(600 居中)、单列 server-row 行列表——
// 镜像原型 design/prototype/screens/ui-desktop.html 的 .nodes-view(真相源)。
// 智能卡置顶(CLAUDE.md §5 规范,三端一致)。
return RefreshIndicator(
color: c.accent,
backgroundColor: c.surface,
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 4, 28, 24),
children: [
smartCard,
const SizedBox(height: 14),
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow),
const SizedBox(height: 14),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 4.4,
child: PageBody(
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 4, 28, 24),
children: [
for (final n in nodes)
_NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _onTapNode(n)),
smartCard,
const SizedBox(height: 14),
ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow),
const SizedBox(height: 14),
for (var i = 0; i < nodes.length; i++) ...[
_NodeGridTile(
node: nodes[i],
lang: t.lang,
active: !smart && nodes[i].code == selected,
onTap: () => _onTapNode(nodes[i]),
),
if (i < nodes.length - 1) const SizedBox(height: 10),
],
],
),
],
),
);
}
@@ -226,7 +228,6 @@ class _SearchBox extends StatelessWidget {
}
}
/// 宽屏双列网格中的节点块。
/// 刷新按钮(搜索框右侧):点击刷新节点列表(受 10s 限流);刷新中显示 spinner。
class _RefreshButton extends StatelessWidget {
const _RefreshButton({required this.refreshing, required this.onTap});
@@ -256,6 +257,7 @@ class _RefreshButton extends StatelessWidget {
}
}
/// 宽屏 Nodes 页单列节点行(镜像原型 .server-row);自然高度撑高,不依赖网格 aspect ratio。
class _NodeGridTile extends StatelessWidget {
const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap});
final Node node;
+7 -18
View File
@@ -9,6 +9,7 @@ import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/status_pill.dart';
import '../widgets/sub_scaffold.dart';
typedef _Notice = ({
IconData icon,
@@ -136,24 +137,12 @@ class NotificationsScreen extends ConsumerWidget {
],
);
if (embedded) return content();
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.notifTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: content()),
]),
),
return SubScaffold(
title: t.notifTitle,
onBack: onBack,
embedded: embedded,
wrapPageBody: true,
child: content(),
);
}
}
+17 -37
View File
@@ -13,6 +13,8 @@ import '../models/payment.dart';
import '../pangolin_theme.dart';
import '../state/navigation_provider.dart';
import '../state/orders_provider.dart';
import '../widgets/page_body.dart';
import '../widgets/sub_scaffold.dart';
import '../widgets/pangolin_button.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/status_pill.dart';
@@ -57,38 +59,6 @@ String _fmtDateTime(DateTime d) {
return '${l.year}-${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}';
}
/// 带返回栏的子页骨架(embedded=true 只返回内容,返回/标题由外层 shell 顶栏提供)。
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false});
final String title;
final Widget child;
final VoidCallback? onBack;
final bool embedded;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (embedded) return child;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
]),
),
);
}
}
/// 订单页入口:内部在「列表」与「详情」两屏间切换(与原型单帧同构)。
class OrdersScreen extends ConsumerStatefulWidget {
const OrdersScreen({super.key, required this.t, this.onBack, this.embedded = false});
@@ -195,7 +165,7 @@ class _OrderListView extends ConsumerWidget {
]),
);
return _SubScaffold(title: t.ordersTitle, onBack: onBack, embedded: embedded, child: content());
return SubScaffold(title: t.ordersTitle, onBack: onBack, embedded: embedded, wrapPageBody: true, child: content());
}
Widget _row(BuildContext context, PangolinScheme c, PayOrderSummary o, bool divider) {
@@ -251,7 +221,17 @@ class _OrderDetailView extends ConsumerWidget {
builder: (_) => PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PaymentScreen(t: t)),
MaterialPageRoute(
builder: (_) => PaymentScreen(
t: t,
onDone: () {
Navigator.of(context).pop();
// 支付页「完成/取消」回到订单详情:失效列表 + 本单详情,拉新状态。
ref.invalidate(ordersProvider);
ref.invalidate(orderDetailProvider(order.orderNo));
},
),
),
),
),
));
@@ -278,7 +258,7 @@ class _OrderDetailView extends ConsumerWidget {
final expiresAt = detailAsync.valueOrNull?.expiresAt;
final pill = _statusPill(t, o.status);
// 内容区(mobile 套 _SubScaffold;desktop embedded 前置一条返回条回到列表)。
// 内容区(mobile 套 SubScaffold;desktop embedded 前置一条返回条回到列表)。
Widget summaryCard() => Container(
width: double.infinity,
decoration: BoxDecoration(
@@ -362,10 +342,10 @@ class _OrderDetailView extends ConsumerWidget {
]),
),
),
Expanded(child: body()),
Expanded(child: PageBody(child: body())),
]);
}
return _SubScaffold(title: t.orderDetailTitle, onBack: onBack, child: body());
return SubScaffold(title: t.orderDetailTitle, onBack: onBack, wrapPageBody: true, child: body());
}
Widget _kv(PangolinScheme c, String label, String value, {VoidCallback? onCopy}) {
+10 -22
View File
@@ -15,6 +15,7 @@ import '../state/payment_provider.dart';
import '../widgets/pangolin_button.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/sub_scaffold.dart';
class PaymentScreen extends ConsumerStatefulWidget {
const PaymentScreen({super.key, required this.t, this.onBack, this.onDone, this.embedded = false});
@@ -209,8 +210,9 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
Text(t.qrNotSupported, style: PangolinText.caption.copyWith(color: c.fg3)),
]));
default:
renderBody = _card(c, child: Text('${session.renderType}: ${_zh ? "暂不支持,请换支付方式" : "Unsupported, switch method"}',
style: PangolinText.body.copyWith(color: c.fg2)));
renderBody = _card(c,
child: Text('${session.renderType}: ${t.payUnsupportedRenderType}',
style: PangolinText.body.copyWith(color: c.fg2)));
}
return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [
@@ -306,29 +308,15 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
PaymentPhase.succeeded => _succeeded(context, s),
PaymentPhase.failed => _failed(context, ref, s),
PaymentPhase.idle => Center(
child: Text(_zh ? '暂无进行中的订单' : 'No active order',
child: Text(t.payNoActiveOrder,
style: PangolinText.body.copyWith(color: c.fg3))),
};
if (embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.paymentTitle,
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
return SubScaffold(
title: t.paymentTitle,
onBack: onBack,
embedded: embedded,
child: body,
);
}
}
+12 -26
View File
@@ -9,7 +9,9 @@ import '../l10n/app_text.dart';
import '../models/payment.dart';
import '../pangolin_theme.dart';
import '../state/payment_provider.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/sub_scaffold.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/plan_card.dart';
import '../widgets/seg_switch.dart';
@@ -37,6 +39,7 @@ class PurchaseScreen extends ConsumerStatefulWidget {
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
AppText get t => widget.t;
bool get _zh => t.lang == AppLang.zh;
// 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。
PayChannel _channel = PayChannel.usdt;
@@ -58,9 +61,7 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
// 下单失败给可见 toast,别让用户以为「点了没反应」。
showPangolinToast(
context,
t.lang == AppLang.zh
? (st.errorZh ?? '下单失败,请稍后重试')
: (st.errorEn ?? 'Failed to create order, please retry'),
(_zh ? st.errorZh : st.errorEn) ?? t.orderCreateFailed,
);
}
}
@@ -70,14 +71,13 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
final c = context.pangolin;
final catalog = ref.watch(payCatalogProvider);
final body = catalog.when(
final body = PageBody(wide: true, child: catalog.when(
loading: () => const Center(
child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
error: (_, __) => Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry',
style: PangolinText.body.copyWith(color: c.fg3)),
child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)),
),
),
data: (items) {
@@ -132,27 +132,13 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
],
);
},
);
));
if (widget.embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: widget.onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(t.purchaseTitle,
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
return SubScaffold(
title: t.purchaseTitle,
onBack: widget.onBack,
embedded: widget.embedded,
child: body,
);
}
}
+2 -2
View File
@@ -13,6 +13,7 @@ import '../services/web_launch.dart';
import '../state/app_providers.dart';
import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
@@ -54,8 +55,7 @@ class SettingsPage extends ConsumerWidget {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(32, 16, 32, 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: PageBody(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
// ① 连接(功能开关组;开机自启是桌面端登录项概念,iOS/Android 无此能力,仅桌面显示)
_SectionLabel(text: t.settingsGroupConn),
+3 -2
View File
@@ -11,6 +11,7 @@ import '../state/account_providers.dart';
import '../state/app_providers.dart';
import '../state/auth_provider.dart';
import '../widgets/app_top_bar.dart';
import '../widgets/page_body.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/period_card.dart';
import '../widgets/usage_line_chart.dart';
@@ -69,7 +70,7 @@ class StatsPage extends ConsumerWidget {
ref.invalidate(deviceUsageProvider(30));
await ref.read(meProvider.notifier).refresh();
},
child: ListView(
child: PageBody(child: ListView(
padding: EdgeInsets.fromLTRB(isWide ? 32 : 20, isWide ? 8 : 4, isWide ? 32 : 20, 24),
children: [
// 顶行:标题(宽屏) + 设备下拉(右上角)
@@ -115,7 +116,7 @@ class StatsPage extends ConsumerWidget {
]),
),
],
),
)),
);
if (isWide) return body;
+17 -2
View File
@@ -10,6 +10,7 @@ import '../screens/account_page.dart';
import '../screens/connect_page.dart';
import '../screens/contact_page.dart';
import '../screens/nodes_page.dart';
import '../screens/notifications_page.dart';
import '../screens/settings_page.dart';
import '../screens/stats_page.dart';
import '../state/app_providers.dart';
@@ -27,7 +28,7 @@ class TabletShell extends ConsumerWidget {
final t = ref.watch(appTextProvider);
final view = ref.watch(navViewProvider);
// 「我的」移到侧栏顶部品牌按钮(见 NavSidebar);设置/联系我们对齐桌面侧栏。
// 「我的」经侧栏底部 PlanBadgeCard 进入(见 NavSidebar;品牌块本身不可点);设置/联系我们对齐桌面侧栏。
final items = <NavSidebarItem>[
(icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect),
(icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers),
@@ -61,11 +62,19 @@ class TabletShell extends ConsumerWidget {
return const SettingsPage();
case NavView.contact:
return const ContactPage();
case NavView.notifications:
return NotificationsScreen(t: t, embedded: true);
default: // account / plans / redeem / devices → 账户(其余下钻在账户页内)
return const AccountPage(isWide: true);
}
}
// 通知子页返回目标:回账户(镜像桌面 desktop_shell.dart::backTarget)。
VoidCallback? backTarget() {
if (view == NavView.notifications) return () => go(NavView.account);
return null;
}
return ColoredBox(
color: c.bg,
// iPad 上内容会顶到状态栏(时间/电量/notch)。SafeArea 加顶部安全区,避开重叠;
@@ -76,7 +85,13 @@ class TabletShell extends ConsumerWidget {
NavSidebar(items: items, width: 232, dense: false),
Expanded(
child: Column(children: [
ContentTopBar(title: titles[view] ?? t.tabMe, showThemeToggle: false, titleSize: 19),
ContentTopBar(
title: titles[view] ?? t.tabMe,
onBack: backTarget(),
onBell: () => go(NavView.notifications),
showThemeToggle: false,
titleSize: 19,
),
Expanded(child: content()),
]),
),
+5 -37
View File
@@ -20,39 +20,7 @@ import '../services/auth_api.dart';
import '../state/account_providers.dart';
import 'pangolin_button.dart';
import 'pangolin_icons.dart';
/// 通用:带返回箭头的子页骨架。embedded=true 时只返回内容(desktop 内容区下钻,
/// 返回/标题由外层 shell 顶栏提供),否则整页 Scaffoldmobile/tablet 全屏 push)。
class _SubScaffold extends StatelessWidget {
const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false});
final String title;
final Widget child;
final VoidCallback? onBack;
final bool embedded;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
if (embedded) return child;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: child),
]),
),
);
}
}
import 'sub_scaffold.dart';
/// ── 设置(移动端从「我的」下钻)── 复用桌面 SettingsPage 内容,套移动返回栏。
class SettingsScreen extends StatelessWidget {
@@ -62,7 +30,7 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _SubScaffold(title: t.settingsTitle, onBack: onBack, child: const SettingsPage());
return SubScaffold(title: t.settingsTitle, onBack: onBack, child: const SettingsPage());
}
}
@@ -113,7 +81,7 @@ class DevicesScreen extends ConsumerWidget {
]),
);
return _SubScaffold(
return SubScaffold(
title: t.myDevices, onBack: onBack, embedded: embedded, child: _DevicesAutoRefresh(child: content()));
}
@@ -314,7 +282,7 @@ class _RedeemScreenState extends ConsumerState<RedeemScreen> {
Widget build(BuildContext context) {
final c = context.pangolin;
final t = widget.t;
return _SubScaffold(
return SubScaffold(
title: t.redeemTitle,
onBack: widget.onBack,
embedded: widget.embedded,
@@ -379,7 +347,7 @@ class ContactScreen extends StatelessWidget {
accent: ch.accent,
))
.toList();
return _SubScaffold(
return SubScaffold(
title: t.contactTitle,
onBack: onBack,
child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [
+20
View File
@@ -0,0 +1,20 @@
// page_body.dart — 桌面内容页统一宽度容器(镜像 design/prototype/atoms.css 的 .view-body)。
// 默认 600 居中;wide=true 840(多列网格页,如购买页)。手机屏宽 < 600 时约束自然不生效,可无条件包。
import 'package:flutter/widgets.dart';
const double kViewMaxW = 600; // atoms.css .view-body
const double kViewMaxWWide = 840; // atoms.css .view-body.is-wide
class PageBody extends StatelessWidget {
const PageBody({super.key, required this.child, this.wide = false});
final Widget child;
final bool wide;
@override
Widget build(BuildContext context) => Center(
child: ConstrainedBox(
constraints:
BoxConstraints(maxWidth: wide ? kViewMaxWWide : kViewMaxW),
child: child,
),
);
}
+57
View File
@@ -0,0 +1,57 @@
// sub_scaffold.dart — 通用「返回箭头 + 标题」子页脚手架(六处收敛,视觉零变化)。
//
// 收敛自 account_screens.dart/orders_page.dart 的私有 `_SubScaffold`,以及
// purchase_page.dart/payment_page.dart/invite_page.dart/notifications_page.dart
// 里结构相同的内联等价实现。逐像素沿用原实现(同 padding/字号/图标),纯收敛非重设计。
//
// embedded=true 时只返回内容(desktop 内容区下钻,返回/标题由外层 shell 顶栏提供);
// 否则整页 Scaffold + SafeArea + 返回箭头行(mobile/tablet 全屏 push)。
// wrapPageBody=true 时由本组件把 child 套进 PageBody(桌面内容页统一宽度容器);默认
// false——调用方若已自行用 PageBody 包裹 child(如购买页需要 wide 变体),避免重复包裹。
import 'package:flutter/material.dart';
import '../pangolin_theme.dart';
import 'page_body.dart';
import 'pangolin_icons.dart';
class SubScaffold extends StatelessWidget {
const SubScaffold({
super.key,
required this.title,
required this.child,
this.onBack,
this.embedded = false,
this.wrapPageBody = false,
});
final String title;
final Widget child;
final VoidCallback? onBack;
final bool embedded;
final bool wrapPageBody;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
final body = wrapPageBody ? PageBody(child: child) : child;
if (embedded) return body;
return Scaffold(
backgroundColor: c.bg,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
child: Row(children: [
IconButton(
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
),
Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
]),
),
Expanded(child: body),
]),
),
);
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ class UpdateBanner extends ConsumerWidget {
IconButton(
onPressed: () =>
ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion,
icon: Icon(Icons.close, size: 18, color: c.fg3),
icon: Icon(PangolinIcons.x, size: 18, color: c.fg3),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
tooltip: t.updateLater,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

+10
View File
@@ -92,6 +92,16 @@
.btn-block { width: 100%; }
/* =============================================================
VIEW BODY · .view-body (+ .is-wide)
桌面客户端内容视图统一宽度容器 —— 对齐 Flutter PageBody(client/lib/widgets/page_body.dart)。
默认 600px 居中(单栏内容页:账户/设置/联系我们/统计/订单/通知/邀请);
.is-wide 840px(多列网格页:购买套餐)。宽度标量本身无对应 token,字面写死属正常。
============================================================= */
.view-body{ max-width:600px; margin:0 auto; padding:var(--space-5) var(--space-6) var(--space-6); }
.view-body.is-wide{ max-width:840px; }
/* =============================================================
CARD · .card
surface 底 + border + radius-lg + shadow-sm
+24
View File
@@ -189,6 +189,7 @@
<div class="dgroup">组件 Atoms</div>
<a class="dnav" data-s="a-btn">按钮 .btn</a>
<a class="dnav" data-s="a-card">卡片 .card</a>
<a class="dnav" data-s="a-viewbody">内容容器 .view-body</a>
<a class="dnav" data-s="a-input">输入 .input</a>
<a class="dnav" data-s="a-pill">胶囊 .pill / .badge</a>
<a class="dnav" data-s="a-lang">语言下拉 .langsel</a>
@@ -433,6 +434,29 @@
</div></div>
</section>
<section class="section" id="a-viewbody">
<h2>内容容器 · .view-body</h2>
<p class="desc">桌面客户端内容视图统一宽度容器:默认居中 <code>600px</code>(账户/设置/联系我们/统计/订单/通知/邀请等单栏页),<code>.is-wide</code> 修饰 <code>840px</code>(购买套餐等多列网格页)。padding 固定 <code>space-5 space-6 space-6</code>。对齐 Flutter <code>PageBody</code>(<code>client/lib/widgets/page_body.dart</code>)。</p>
<div class="pcard"><div class="pcard-h"><h3>默认 · 600px</h3><span class="tk">.view-body</span></div><div class="pcard-b col">
<div style="border:1px dashed var(--border-strong); border-radius:var(--radius-md); background:var(--bg-subtle)">
<div class="view-body" style="border:1px solid var(--accent-border); background:var(--surface); border-radius:var(--radius-md)">
<div class="t-overline" style="color:var(--fg3)">.view-body</div>
<div class="t-h3" style="margin:var(--space-2) 0 var(--space-1)">单栏内容视图</div>
<div class="t-sm" style="color:var(--fg2)">max-width:600px · margin:0 auto 居中 · padding 5/6/6</div>
</div>
</div>
</div></div>
<div class="pcard"><div class="pcard-h"><h3>宽 · 840px</h3><span class="tk">.view-body.is-wide</span></div><div class="pcard-b col">
<div style="border:1px dashed var(--border-strong); border-radius:var(--radius-md); background:var(--bg-subtle)">
<div class="view-body is-wide" style="border:1px solid var(--accent-border); background:var(--surface); border-radius:var(--radius-md)">
<div class="t-overline" style="color:var(--fg3)">.view-body.is-wide</div>
<div class="t-h3" style="margin:var(--space-2) 0 var(--space-1)">多列网格页(购买套餐)</div>
<div class="t-sm" style="color:var(--fg2)">max-width:840px · padding 沿用默认 5/6/6</div>
</div>
</div>
</div></div>
</section>
<section class="section" id="a-input">
<h2>输入 · .input / .field / .flabel</h2>
<p class="desc">强描边 + radius-md + focus clay 光环。状态 <code>.is-focused</code> / <code>.is-error</code> / <code>:disabled</code></p>
+102 -41
View File
@@ -116,7 +116,7 @@
.rate-chip .u{ font-size:10px; color:var(--fg3); }
/* ---- 节点视图 ---- */
.nodes-view{ padding:var(--space-5) var(--space-6) var(--space-6); }
/* .nodes-view 宽度/内边距由 view-body 原子承担(见 atoms.css),此处不再重复声明 */
.searchbar{ display:flex; align-items:center; gap:9px; background:var(--bg-subtle);
border-radius:var(--radius-md); padding:9px 13px; margin-bottom:var(--space-4); max-width:360px; }
.searchbar svg{ width:17px; height:17px; color:var(--fg3); }
@@ -125,7 +125,6 @@
.searchbar input::placeholder{ color:var(--fg3); }
/* ---- 统计视图 ---- */
.stats-view{ padding:var(--space-5) var(--space-6) var(--space-6); }
/* 顶行:标题左 + 设备筛选下拉右(对齐线上 stats_page.dart 的 _DeviceDropdown) */
.stats-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-4); }
.stats-head .sh-t{ font-family:var(--font-display); font-weight:700; font-size:20px; color:var(--fg1); }
@@ -155,10 +154,12 @@
.sc-g1{ stop-color:var(--accent); stop-opacity:0; }
/* ---- 账户视图 ---- */
.account-view{ padding:var(--space-5) var(--space-6) var(--space-6); max-width:600px; margin:0 auto; }
.account-view .plan-banner{ margin-bottom:var(--space-5); }
.st-change{ font-family:var(--font-sans); font-size:13px; font-weight:600; color:var(--accent); }
/* ---- 兑换码视图(账户页「兑换码」入口) ---- */
.redeem-view .card{ display:flex; flex-direction:column; gap:var(--space-4); }
/* 设备行辅件(对齐线上设备管理) */
.dv-lead{ width:22px; height:22px; color:var(--fg2); flex:none; }
.dv-cur{ font-family:var(--font-sans); font-size:12px; font-weight:600; color:var(--accent);
@@ -176,11 +177,9 @@
.dv-menu .menu-item.is-danger:hover{ background:var(--danger-subtle); }
/* ---- 设置视图 ---- */
.settings-view{ padding:var(--space-5) var(--space-6) var(--space-6); max-width:600px; margin:0 auto; }
.side .plan-badge{ width:100%; }
/* ---- 联系视图 ---- */
.contact-view{ padding:var(--space-5) var(--space-6) var(--space-6); max-width:none; }
.contact-intro{ font-size:13px; color:var(--fg2); line-height:var(--leading-relaxed); margin:0 0 var(--space-4); }
.icon-box.is-plain{ background:var(--bg-subtle); color:var(--fg2); }
.hours-card{ display:flex; align-items:center; gap:10px; background:var(--bg-subtle);
@@ -190,7 +189,7 @@
.hours-card .hc-s{ font-family:var(--font-mono); font-size:12px; color:var(--fg3); margin-top:2px; }
/* ---- 购买视图(兑换 & 购买流程,接入主框架内容区) ---- */
.purchase-view{ padding:var(--space-5) var(--space-6) var(--space-8); max-width:840px; margin:0 auto; }
.purchase-view{ padding:var(--space-5) var(--space-6) var(--space-8); }
.pv-back{ display:inline-flex; align-items:center; gap:6px; border:none; background:transparent;
color:var(--accent); font-family:var(--font-sans); font-size:13px; font-weight:600; cursor:pointer;
padding:6px 12px 6px 8px; margin-bottom:var(--space-4); border-radius:var(--radius-full);
@@ -242,7 +241,6 @@
.pv-qr-cap{ font-size:var(--text-sm); font-weight:600; color:var(--fg1); }
/* ---- 我的订单视图(列表 → 详情,接入主框架内容区) ---- */
.orders-view{ padding:var(--space-5) var(--space-6) var(--space-8); max-width:600px; margin:0 auto; }
.od-screen{ display:none; }
.od-screen.is-active{ display:block; }
/* 订单详情 = 一张「收据」卡:顶部渐变条 + 摘要头(金额左/状态右) + 虚线撕裂线 + 明细 */
@@ -294,6 +292,9 @@
background:var(--bg-subtle); border:1px solid var(--border);
border-radius:var(--radius-sm); padding:1px 6px; }
.frame-note{ font-size:var(--text-caption); color:var(--fg3); font-weight:400; }
/* hidden 属性必须赢过任何作者 display 规则(ui-mobile.html:93 同款守卫)——
否则 .login-form{display:flex} 会把带 hidden 的 signupForm 顶出来,与 signin 并排溢出卡片 */
[hidden]{ display:none !important; }
/* 登录分屏卡 */
.login-win{ width:760px; max-width:100%; margin:0 auto; height:460px; display:flex; border-radius:16px; overflow:hidden;
@@ -343,7 +344,6 @@
background:var(--danger); border:1.5px solid var(--bg); }
/* ---- 通知收件箱视图 ---- */
.notif-view{ padding:var(--space-5) var(--space-6) var(--space-6); max-width:600px; margin:0 auto; }
.notif-row{ display:flex; align-items:flex-start; gap:var(--space-3); padding:14px 16px; border-top:1px solid var(--border); }
.notif-row:first-child{ border-top:none; }
.notif-dot{ flex:none; width:8px; height:8px; margin-top:6px; border-radius:var(--radius-full); background:var(--danger); }
@@ -356,7 +356,6 @@
.notif-s{ font-size:12.5px; color:var(--fg3); margin-top:2px; line-height:var(--leading-normal); }
/* ---- 邀请好友视图 ---- */
.invite-view{ padding:var(--space-5) var(--space-6) var(--space-6); max-width:600px; margin:0 auto; }
.invite-code-card{ margin-bottom:var(--space-5); display:flex; flex-direction:column; gap:var(--space-3); }
.inv-lead{ font-size:13px; color:var(--fg2); line-height:var(--leading-relaxed); }
.inv-code-box, .inv-link-box{ display:flex; align-items:center; gap:var(--space-2);
@@ -368,6 +367,18 @@
.inv-prog-row + .inv-prog-row{ border-top:1px solid var(--border); }
.inv-prog-k{ font-size:13px; color:var(--fg2); }
.inv-prog-v{ font-family:var(--font-mono); font-size:15px; font-weight:700; color:var(--accent); }
/* 战绩三格统计卡(镜像 invite_page.dart _StatCell 横排三格) */
.inv-stats{ display:flex; }
.inv-stat-cell{ flex:1; display:flex; flex-direction:column; align-items:center; gap:4px;
padding:var(--space-4) var(--space-2); }
.inv-stat-cell + .inv-stat-cell{ border-left:1px solid var(--border); }
.inv-stat-v{ font-family:var(--font-mono); font-size:20px; font-weight:700; color:var(--fg1); }
.inv-stat-k{ font-size:11.5px; color:var(--fg3); }
/* TG 任务卡(镜像 invite_page.dart _TgTaskCard,待验证 / 已领取两态) */
.inv-tg-card{ display:flex; flex-direction:column; gap:var(--space-3); margin-bottom:var(--space-3); }
.inv-tg-head{ display:flex; align-items:center; gap:var(--space-3); }
.inv-tg-actions{ display:flex; flex-direction:column; gap:var(--space-2); }
.inv-tg-demo-tag{ font-size:11px; color:var(--fg3); font-style:italic; }
</style>
</head>
<body>
@@ -463,7 +474,7 @@
<!-- 节点视图 -->
<section class="view" data-view="nodes">
<div class="nodes-view">
<div class="view-body nodes-view">
<div class="searchbar"><svg><use href="#i-search"/></svg><input data-i18n-ph="nodes.search" placeholder="搜索加速线路…"></div>
<div class="card card-flush">
<div class="server-row is-smart is-selected" data-node="smart">
@@ -516,7 +527,7 @@
<!-- 统计视图 -->
<section class="view" data-view="stats">
<div class="stats-view">
<div class="view-body stats-view">
<div class="stats-head">
<span class="sh-t" data-i18n="nav.stats">统计</span>
<span class="langsel stats-dd" id="devSel">
@@ -568,7 +579,7 @@
<!-- 账户视图 -->
<section class="view" data-view="account">
<div class="account-view">
<div class="view-body account-view">
<div class="plan-banner" id="planBanner">
<div class="pbn-row">
<span class="pbn-badge" id="pbnBadge"><svg id="pbnIcon"><use href="#i-user"/></svg></span>
@@ -641,7 +652,7 @@
<!-- 设备管理视图(自账户页下钻) -->
<section class="view" data-view="devices">
<div class="account-view">
<div class="view-body account-view">
<button class="pv-back" id="dvBack"><svg><use href="#i-arrow-left"/></svg><span data-i18n="dev.back">返回</span></button>
<div class="sec-label"><span data-i18n="acc.devices">设备管理</span> · <span id="devHeaderCount">1 / 5</span></div>
<div class="card card-flush" id="deviceList" style="margin-bottom:var(--space-5)"></div>
@@ -650,7 +661,7 @@
<!-- 设置视图 -->
<section class="view" data-view="settings">
<div class="settings-view">
<div class="view-body settings-view">
<div class="sec-label" data-i18n="set.grpConn">连接</div>
<div class="card card-flush" style="margin-bottom:var(--space-5)">
<div class="setting-row">
@@ -719,7 +730,7 @@
<!-- 联系视图 -->
<section class="view" data-view="contact">
<div class="contact-view">
<div class="view-body">
<p class="contact-intro" data-i18n="contact.intro">遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。</p>
<div class="card card-flush" style="margin-bottom:var(--space-3)">
<div class="setting-row is-tap">
@@ -750,7 +761,7 @@
<!-- 购买视图(兑换 & 购买流程,接入主框架内容区) -->
<section class="view" data-view="purchase">
<div class="purchase-view">
<div class="view-body is-wide purchase-view">
<button class="pv-back" id="pvBack"><svg><use href="#i-arrow-left"/></svg><span data-i18n="pur.back">返回</span></button>
<!-- ① 档位选择:特性统一置顶 + 3 档精简卡 -->
@@ -856,7 +867,7 @@
<!-- 我的订单视图(列表 → 详情,接入主框架内容区) -->
<section class="view" data-view="orders">
<div class="orders-view">
<div class="view-body orders-view">
<button class="pv-back" id="odBack"><svg><use href="#i-arrow-left"/></svg><span data-i18n="ord.back">返回</span></button>
<!-- 列表 -->
@@ -884,7 +895,7 @@
<!-- 通知收件箱视图(占位 · Spec ③) -->
<section class="view" data-view="notifications">
<div class="notif-view">
<div class="view-body notif-view">
<button class="pv-back" id="ntBack"><svg><use href="#i-arrow-left"/></svg><span data-i18n="nt.back">返回</span></button>
<div class="sec-label" data-i18n="nt.title">通知</div>
<div class="card card-flush">
@@ -918,7 +929,7 @@
<!-- 邀请好友视图(占位 · Spec ②) -->
<section class="view" data-view="invite">
<div class="invite-view">
<div class="view-body invite-view">
<button class="pv-back" id="invBack"><svg><use href="#i-arrow-left"/></svg><span data-i18n="inv.back">返回</span></button>
<div class="sec-label" data-i18n="inv.title">邀请好友</div>
<div class="card invite-code-card">
@@ -937,31 +948,73 @@
<div class="card card-flush" style="margin-bottom:var(--space-5)">
<div class="setting-row">
<span class="icon-box"><svg><use href="#i-user"/></svg></span>
<div class="st-body"><div class="st-title" data-i18n="inv.r1">好友注册</div><div class="st-sub" data-i18n="inv.r1s">双方各得会员天数</div></div>
<div class="st-body"><div class="st-title" data-i18n="inv.r1">好友注册</div><div class="st-sub" data-i18n="inv.r1s">双方各得 3 天</div></div>
<span class="st-trail"><span class="pill pill-outline">+3d</span></span>
</div>
<div class="setting-row">
<span class="icon-box"><svg><use href="#i-crown"/></svg></span>
<div class="st-body"><div class="st-title" data-i18n="inv.r2">好友首次购买</div><div class="st-sub" data-i18n="inv.r2s">双方各得更多天数</div></div>
<span class="st-trail"><span class="pill pill-outline">+30d</span></span>
<div class="st-body"><div class="st-title" data-i18n="inv.r2">好友首次购买</div><div class="st-sub" data-i18n="inv.r2s">双方各得 7 天</div></div>
<span class="st-trail"><span class="pill pill-outline">+7d</span></span>
</div>
<div class="setting-row">
<span class="icon-box"><svg><use href="#i-send"/></svg></span>
<div class="st-body"><div class="st-title" data-i18n="inv.r3">加入 TG 频道</div><div class="st-sub" data-i18n="inv.r3s">额外获得会员天数</div></div>
<span class="st-trail"><span class="pill pill-outline">+7d</span></span>
<div class="st-body"><div class="st-title" data-i18n="inv.r3">加入 TG 频道</div><div class="st-sub" data-i18n="inv.r3s">获得 3 天</div></div>
<span class="st-trail"><span class="pill pill-outline">+3d</span></span>
</div>
</div>
<div class="sec-label" data-i18n="inv.progHead">奖励进度</div>
<div class="sec-label" data-i18n="inv.progHead">我的奖励</div>
<div class="card card-flush" style="margin-bottom:var(--space-5)">
<div class="inv-stats">
<div class="inv-stat-cell"><span class="inv-stat-v">3</span><span class="inv-stat-k" data-i18n="inv.invited">已邀请</span></div>
<div class="inv-stat-cell"><span class="inv-stat-v">1</span><span class="inv-stat-k" data-i18n="inv.converted">已转化</span></div>
<div class="inv-stat-cell"><span class="inv-stat-v">16</span><span class="inv-stat-k" data-i18n="inv.earned">累计获赠(天)</span></div>
</div>
</div>
<div class="sec-label" data-i18n="inv.tasksHead">更多得会员</div>
<div class="card inv-tg-card">
<div class="inv-tg-head">
<span class="icon-box"><svg><use href="#i-send"/></svg></span>
<div class="st-body">
<div class="st-title" data-i18n="inv.tgTitle">加入 Telegram 频道</div>
<div class="st-sub" data-i18n="inv.tgSub">+3 天</div>
</div>
</div>
<div class="inv-tg-actions">
<button class="btn btn-subtle btn-block"><svg><use href="#i-send"/></svg><span data-i18n="inv.tgJoinBtn">加入频道</span></button>
<button class="btn btn-primary btn-block"><span data-i18n="inv.tgVerifyBtn">验证领取</span></button>
</div>
<div class="inv-tg-demo-tag" data-i18n="inv.tgDemoPending">演示态 · 待验证</div>
</div>
<div class="card inv-tg-card" style="margin-bottom:0">
<div class="inv-tg-head">
<span class="icon-box"><svg><use href="#i-send"/></svg></span>
<div class="st-body">
<div class="st-title" data-i18n="inv.tgTitle">加入 Telegram 频道</div>
<div class="st-sub" data-i18n="inv.tgSub">+3 天</div>
</div>
</div>
<div class="inv-tg-actions">
<button class="btn btn-subtle btn-block is-disabled" disabled><svg><use href="#i-check-circle"/></svg><span data-i18n="inv.tgDoneBtn">已领 +3 天</span></button>
</div>
<div class="inv-tg-demo-tag" data-i18n="inv.tgDemoDone">演示态 · 已领取</div>
</div>
</div>
</section>
<!-- 兑换码视图(账户页「兑换码」入口 · 静态默认表单态,同移动原型深度) -->
<section class="view" data-view="redeem">
<div class="view-body redeem-view">
<button class="pv-back" id="rdBack"><svg><use href="#i-arrow-left"/></svg><span data-i18n="rd.back">返回</span></button>
<div class="sec-label" data-i18n="rd.title">兑换码</div>
<div class="card">
<div class="inv-prog-row">
<span class="inv-prog-k" data-i18n="inv.invited">已邀请好友</span>
<span class="inv-prog-v">3</span>
</div>
<div class="inv-prog-row">
<span class="inv-prog-k" data-i18n="inv.earned">累计获得会员</span>
<span class="inv-prog-v">+42d</span>
<div class="field">
<label class="flabel" data-i18n="rd.label">兑换码</label>
<input class="input" data-i18n-ph="rd.ph" placeholder="输入兑换 / 激活码" readonly>
</div>
<button class="btn btn-primary btn-block btn-lg" data-i18n="rd.btn">立即兑换</button>
<div class="pv-hint-mut" data-i18n="rd.hint">输入卡密即可激活对应时长,激活后立即生效。</div>
</div>
</div>
</section>
@@ -1062,7 +1115,7 @@ var I18N = {
'nav.orders':{zh:'我的订单',en:'Orders'},
'nav.account':{zh:'账户',en:'Account'},
'nav.notifications':{zh:'通知',en:'Notifications'}, 'nav.invite':{zh:'邀请好友',en:'Invite friends'},
'nav.devices':{zh:'设备管理',en:'Devices'},
'nav.devices':{zh:'设备管理',en:'Devices'}, 'nav.redeem':{zh:'兑换码',en:'Redeem code'},
'acc.billing':{zh:'购买 & 订单 & 兑换',en:'Buy · Orders · Redeem'},
'acc.buy':{zh:'购买套餐',en:'Buy a plan'}, 'acc.buySub':{zh:'升级 PRO 会员',en:'Upgrade to PRO'},
'acc.orders':{zh:'我的订单',en:'My orders'}, 'acc.ordersSub':{zh:'查看订单记录',en:'View order history'},
@@ -1076,11 +1129,18 @@ var I18N = {
'inv.back':{zh:'返回',en:'Back'}, 'inv.title':{zh:'邀请好友',en:'Invite friends'},
'inv.lead':{zh:'分享你的邀请码或链接,好友注册后双方都得会员天数。',en:'Share your code or link — when a friend signs up, both of you earn membership days.'},
'inv.rewardsHead':{zh:'奖励说明 · 双方都得',en:'Rewards · both sides earn'},
'inv.r1':{zh:'好友注册',en:'Friend signs up'}, 'inv.r1s':{zh:'双方各得会员天数',en:'Both earn membership days'},
'inv.r2':{zh:'好友首次购买',en:'Friend first purchase'}, 'inv.r2s':{zh:'双方各得更多天数',en:'Both earn more days'},
'inv.r3':{zh:'加入 TG 频道',en:'Join the TG channel'}, 'inv.r3s':{zh:'额外获得会员天数',en:'Earn extra days'},
'inv.progHead':{zh:'奖励进度',en:'Reward progress'},
'inv.invited':{zh:'已邀请好友',en:'Friends invited'}, 'inv.earned':{zh:'累计获得会员',en:'Membership earned'},
'inv.r1':{zh:'好友注册',en:'Friend signs up'}, 'inv.r1s':{zh:'双方各得 3 天',en:'Both earn 3 days each'},
'inv.r2':{zh:'好友首次购买',en:'Friend first purchase'}, 'inv.r2s':{zh:'双方各得 7 天',en:'Both earn 7 days each'},
'inv.r3':{zh:'加入 TG 频道',en:'Join the TG channel'}, 'inv.r3s':{zh:'获得 3 天',en:'Earn 3 days'},
'inv.progHead':{zh:'我的奖励',en:'My rewards'},
'inv.invited':{zh:'已邀请',en:'Friends invited'}, 'inv.converted':{zh:'已转化',en:'Converted'}, 'inv.earned':{zh:'累计获赠(天)',en:'Days earned'},
'inv.tasksHead':{zh:'更多得会员',en:'More ways to earn'},
'inv.tgTitle':{zh:'加入 Telegram 频道',en:'Join Telegram channel'}, 'inv.tgSub':{zh:'+3 天',en:'+3 days'},
'inv.tgJoinBtn':{zh:'加入频道',en:'Join channel'}, 'inv.tgVerifyBtn':{zh:'验证领取',en:'Verify & claim'}, 'inv.tgDoneBtn':{zh:'已领 +3 天',en:'Claimed +3 days'},
'inv.tgDemoPending':{zh:'演示态 · 待验证',en:'Demo · pending'}, 'inv.tgDemoDone':{zh:'演示态 · 已领取',en:'Demo · claimed'},
'rd.back':{zh:'返回',en:'Back'}, 'rd.title':{zh:'兑换码',en:'Redeem code'},
'rd.label':{zh:'兑换码',en:'Redemption code'}, 'rd.ph':{zh:'输入兑换 / 激活码',en:'Enter activation code'},
'rd.btn':{zh:'立即兑换',en:'Redeem'}, 'rd.hint':{zh:'输入卡密即可激活对应时长,激活后立即生效。',en:'Enter a code to instantly activate plan time.'},
'set.orders':{zh:'我的订单',en:'My orders'}, 'set.ordersSub':{zh:'查看订单记录',en:'View order history'},
'ord.back':{zh:'返回',en:'Back'}, 'ord.details':{zh:'明细',en:'Details'},
'ord.kOrderNo':{zh:'订单号',en:'Order No.'}, 'ord.kPlan':{zh:'套餐',en:'Plan'}, 'ord.kAmount':{zh:'金额',en:'Amount'},
@@ -1626,8 +1686,7 @@ function pvEnterPay(){
}
function openPurchase(from){ PUR.from=from||'settings'; pvShow('plans'); setView('purchase'); }
/* 入口:设置页「兑换 & 购买」+ 账户页「续费 / 升级」 */
var _redeem=document.getElementById('redeemRow'); if(_redeem) _redeem.addEventListener('click', function(){ openPurchase('settings'); });
/* 入口:账户页「续费 / 升级」(设置页「兑换 & 购买」行已随 IA 重构移除,见 accRedeemRow */
var _pbnBtn=document.getElementById('pbnBtn'); if(_pbnBtn) _pbnBtn.addEventListener('click', function(){ openPurchase('account'); });
/* 支付渠道段控:切币种 → 套餐价随之刷新 */
@@ -1758,13 +1817,15 @@ var _odBack=document.getElementById('odBack'); if(_odBack) _odBack.addEventListe
/* ========= 账户新入口 / 通知铃铛 / 邀请(IA 重构) ========= */
var _accBuy=document.getElementById('accBuyRow'); if(_accBuy) _accBuy.addEventListener('click', function(){ openPurchase('account'); });
var _accOrders=document.getElementById('accOrdersRow'); if(_accOrders) _accOrders.addEventListener('click', function(){ openOrders('account'); });
var _accRedeem=document.getElementById('accRedeemRow'); if(_accRedeem) _accRedeem.addEventListener('click', function(){ openPurchase('account'); });
function openRedeem(){ setView('redeem'); }
var _accRedeem=document.getElementById('accRedeemRow'); if(_accRedeem) _accRedeem.addEventListener('click', function(){ openRedeem(); });
var _accInvite=document.getElementById('accInviteRow'); if(_accInvite) _accInvite.addEventListener('click', function(){ setView('invite'); });
var _accDevices=document.getElementById('accDevicesRow'); if(_accDevices) _accDevices.addEventListener('click', function(){ setView('devices'); });
var _dvBack=document.getElementById('dvBack'); if(_dvBack) _dvBack.addEventListener('click', function(){ setView('account'); });
var _tbBell=document.getElementById('tbBell'); if(_tbBell) _tbBell.addEventListener('click', function(){ if(S.view!=='notifications'){ S.notifFrom=S.view; } setView('notifications'); });
var _ntBack=document.getElementById('ntBack'); if(_ntBack) _ntBack.addEventListener('click', function(){ setView(S.notifFrom||'connect'); });
var _invBack=document.getElementById('invBack'); if(_invBack) _invBack.addEventListener('click', function(){ setView('account'); });
var _rdBack=document.getElementById('rdBack'); if(_rdBack) _rdBack.addEventListener('click', function(){ setView('account'); });
document.querySelectorAll('.js-inv-copy').forEach(function(b){ b.addEventListener('click', function(){ pvToast((S.lang==='zh'?'已复制:':'Copied: ')+b.dataset.copy); }); });
/* ========= 初始化 ========= */
+60 -9
View File
@@ -359,6 +359,18 @@
.inv-bar{ height:8px; border-radius:var(--radius-full); background:var(--bg-subtle); overflow:hidden; }
.inv-bar > i{ display:block; height:100%; border-radius:var(--radius-full);
background:linear-gradient(90deg,var(--clay-500),var(--clay-800)); }
/* 战绩三格统计卡(镜像 invite_page.dart _StatCell 横排三格) */
.inv-stats{ display:flex; }
.inv-stat-cell{ flex:1; display:flex; flex-direction:column; align-items:center; gap:4px;
padding:var(--space-4) var(--space-2); }
.inv-stat-cell + .inv-stat-cell{ border-left:1px solid var(--border); }
.inv-stat-v{ font-family:var(--font-mono); font-size:20px; font-weight:700; color:var(--fg1); }
.inv-stat-k{ font-size:11px; color:var(--fg3); }
/* TG 任务卡(镜像 invite_page.dart _TgTaskCard,待验证 / 已领取两态) */
.inv-tg-card + .inv-tg-card{ margin-top:var(--space-3); }
.inv-tg-head{ display:flex; align-items:center; gap:var(--space-3); margin-bottom:var(--space-3); }
.inv-tg-actions{ display:flex; flex-direction:column; gap:var(--space-2); }
.inv-tg-demo-tag{ font-size:11px; color:var(--fg3); font-style:italic; margin-top:var(--space-2); }
/* 子屏 toast */
.sub-toast{ position:absolute; left:50%; bottom:var(--space-6); transform:translate(-50%, 12px);
@@ -970,14 +982,45 @@
<div class="pur-feat-h" data-i18n="inviteRewards">奖励规则</div>
<div class="plan-feats" style="margin:0">
<div class="plan-feat"><svg><use href="#i-check"/></svg><span data-i18n="inviteR1">好友注册 · 双方各得 3 天</span></div>
<div class="plan-feat"><svg><use href="#i-check"/></svg><span data-i18n="inviteR2">好友首次购买 · 双方各得 30</span></div>
<div class="plan-feat"><svg><use href="#i-check"/></svg><span data-i18n="inviteR3">加入 Telegram 社区 · 2</span></div>
<div class="plan-feat"><svg><use href="#i-check"/></svg><span data-i18n="inviteR2">好友首次购买 · 双方各得 7</span></div>
<div class="plan-feat"><svg><use href="#i-check"/></svg><span data-i18n="inviteR3">加入 TG 频道 · 3</span></div>
</div>
</div>
<div class="card">
<div class="inv-prog-row"><span class="st-title" data-i18n="inviteProgress">邀请进度</span><span class="inv-prog-v">2 · +6d</span></div>
<div class="inv-bar"><i style="width:40%"></i></div>
<p class="sub-note" style="text-align:left;margin-top:var(--space-2)" data-i18n="inviteProgressSub">已邀请 2 位好友,累计获得 6 天会员(演示占位)</p>
<div class="pur-feat-h" data-i18n="inviteProgress">我的奖励</div>
<div class="inv-stats">
<div class="inv-stat-cell"><span class="inv-stat-v">3</span><span class="inv-stat-k" data-i18n="inviteStatInvited">已邀请</span></div>
<div class="inv-stat-cell"><span class="inv-stat-v">1</span><span class="inv-stat-k" data-i18n="inviteStatConverted">已转化</span></div>
<div class="inv-stat-cell"><span class="inv-stat-v">16</span><span class="inv-stat-k" data-i18n="inviteStatDays">累计获赠(天)</span></div>
</div>
</div>
<div class="pur-feat-h" style="margin-top:var(--space-2)" data-i18n="inviteTasksTitle">更多得会员</div>
<div class="card inv-tg-card">
<div class="inv-tg-head">
<span class="icon-box"><svg><use href="#i-send"/></svg></span>
<div class="st-body">
<div class="st-title" data-i18n="inviteJoinTgTitle">加入 Telegram 频道</div>
<div class="st-sub" data-i18n="inviteTgSub">+3 天</div>
</div>
</div>
<div class="inv-tg-actions">
<button class="btn btn-subtle btn-block"><svg><use href="#i-send"/></svg><span data-i18n="inviteJoinTgBtn">加入频道</span></button>
<button class="btn btn-primary btn-block"><span data-i18n="inviteVerifyBtn">验证领取</span></button>
</div>
<div class="inv-tg-demo-tag" data-i18n="inviteTgDemoPending">演示态 · 待验证</div>
</div>
<div class="card inv-tg-card">
<div class="inv-tg-head">
<span class="icon-box"><svg><use href="#i-send"/></svg></span>
<div class="st-body">
<div class="st-title" data-i18n="inviteJoinTgTitle">加入 Telegram 频道</div>
<div class="st-sub" data-i18n="inviteTgSub">+3 天</div>
</div>
</div>
<div class="inv-tg-actions">
<button class="btn btn-subtle btn-block is-disabled" disabled><svg><use href="#i-check-circle"/></svg><span data-i18n="inviteTaskDone">已领 +3 天</span></button>
</div>
<div class="inv-tg-demo-tag" data-i18n="inviteTgDemoDone">演示态 · 已领取</div>
</div>
<button class="btn btn-primary btn-block btn-lg sub-copy" data-copy="pangolin.app/i/8F3A2K"><svg><use href="#i-send"/></svg><span data-i18n="inviteShare">分享邀请链接</span></button>
</div>
@@ -1239,8 +1282,12 @@ const I18N = {
noti4:'欢迎使用穿山甲,体验期 7 天已开启', noti4sub:'新闻 · 1 周前',
inviteTitle:'邀请好友', inviteHeadline:'邀请好友得会员', inviteLede:'好友注册、首购、加入社区,双方都各得订阅天数。',
inviteCode:'邀请码', inviteLink:'邀请链接', inviteRewards:'奖励规则',
inviteR1:'好友注册 · 双方各得 3 天', inviteR2:'好友首次购买 · 双方各得 30 天', inviteR3:'加入 Telegram 社区 · 2 天',
inviteProgress:'邀请进度', inviteProgressSub:'已邀请 2 位好友,累计获得 6 天会员(演示占位)', inviteShare:'分享邀请链接',
inviteR1:'好友注册 · 双方各得 3 天', inviteR2:'好友首次购买 · 双方各得 7 天', inviteR3:'加入 TG 频道 · 3 天',
inviteProgress:'我的奖励', inviteProgressSub:'已邀请 2 位好友,累计获得 6 天会员(演示占位)', inviteShare:'分享邀请链接',
inviteStatInvited:'已邀请', inviteStatConverted:'已转化', inviteStatDays:'累计获赠(天)',
inviteTasksTitle:'更多得会员', inviteJoinTgTitle:'加入 Telegram 频道', inviteTgSub:'+3 天',
inviteJoinTgBtn:'加入频道', inviteVerifyBtn:'验证领取', inviteTaskDone:'已领 +3 天',
inviteTgDemoPending:'演示态 · 待验证', inviteTgDemoDone:'演示态 · 已领取',
redeemLabel:'兑换码', redeemPh:'输入兑换 / 激活码', redeemBtn:'立即兑换', redeemHint:'输入卡密即可激活对应时长,激活后立即生效。'
},
en:{
@@ -1313,8 +1360,12 @@ const I18N = {
noti4:'Welcome — your 7-day trial is on', noti4sub:'News · 1w ago',
inviteTitle:'Invite friends', inviteHeadline:'Invite friends, earn membership', inviteLede:'When friends sign up, buy, or join the community, both of you earn plan days.',
inviteCode:'Invite code', inviteLink:'Invite link', inviteRewards:'How rewards work',
inviteR1:'Friend signs up · +3 days each', inviteR2:'Friend first purchase · +30 days each', inviteR3:'Join Telegram · +2 days each',
inviteProgress:'Your progress', inviteProgressSub:'Invited 2 friends · earned 6 days (demo)', inviteShare:'Share invite link',
inviteR1:'Friend signs up · +3 days each', inviteR2:'Friend first purchase · +7 days each', inviteR3:'Join TG channel · earn 3 days',
inviteProgress:'My rewards', inviteProgressSub:'Invited 2 friends · earned 6 days (demo)', inviteShare:'Share invite link',
inviteStatInvited:'Invited', inviteStatConverted:'Converted', inviteStatDays:'Days earned',
inviteTasksTitle:'More ways to earn', inviteJoinTgTitle:'Join Telegram channel', inviteTgSub:'+3 days',
inviteJoinTgBtn:'Join channel', inviteVerifyBtn:'Verify & claim', inviteTaskDone:'Claimed +3 days',
inviteTgDemoPending:'Demo · pending', inviteTgDemoDone:'Demo · claimed',
redeemLabel:'Redemption code', redeemPh:'Enter your code', redeemBtn:'Redeem now', redeemHint:'Enter a code to instantly activate plan time.'
}
};
@@ -0,0 +1,209 @@
package devices
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store"
)
// --------------------------------------------------------------------------
// SQLite fixture for migration 000025 (users.max_devices_override). No
// container needed — modernc.org/sqlite is pure Go — so these run in normal
// CI alongside run_sqlite_test.sh's data-layer tests.
// --------------------------------------------------------------------------
func openOverrideTestDB(t *testing.T) *sql.DB {
t.Helper()
cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"}
db, err := store.Open(cfg)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := store.MigrateUp(db, "sqlite"); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
return db
}
// insertOverrideTestUser inserts an active user with an optional
// max_devices_override (NULL when override.Valid is false).
func insertOverrideTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 {
t.Helper()
var (
res sql.Result
err error
)
if override.Valid {
res, err = db.Exec(
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override)
VALUES (?, ?, 'x', ?, 'active', ?)`,
"uuid-"+email, email, "dp-"+email, override.Int64)
} else {
res, err = db.Exec(
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status)
VALUES (?, ?, 'x', ?, 'active')`,
"uuid-"+email, email, "dp-"+email)
}
if err != nil {
t.Fatalf("insert user %s: %v", email, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
// giveOverrideTestSubscription inserts an active subscription for userID on
// the given seeded plan code (free/pro/team, from migration 000007).
func giveOverrideTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) {
t.Helper()
var planID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil {
t.Fatalf("plan lookup %s: %v", planCode, err)
}
if _, err := db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`,
userID, planID, expiresAt.UTC(), source); err != nil {
t.Fatalf("give subscription: %v", err)
}
}
// insertOverrideTestDevice inserts an active (recently-seen) device row so it
// counts toward CountActiveDevices.
func insertOverrideTestDevice(t *testing.T, db *sql.DB, userID int64, uuidSuffix string) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO devices (uuid, user_id, name, platform, last_seen) VALUES (?, ?, ?, ?, ?)`,
"dev-"+uuidSuffix, userID, "Device "+uuidSuffix, "ios", time.Now().UTC()); err != nil {
t.Fatalf("insert device: %v", err)
}
}
// --------------------------------------------------------------------------
// ResolvePlan: override applied at the single funnel (both the free fallback
// and the subscription path flow through it).
// --------------------------------------------------------------------------
// TestResolvePlan_FreeUserNoOverride: a free user with no override set gets
// the plan's default cap (free = 1, per migration 000007 seed).
func TestResolvePlan_FreeUserNoOverride(t *testing.T) {
db := openOverrideTestDB(t)
svc := NewService(NewStore(db), nil)
userID := insertOverrideTestUser(t, db, "free-none@example.com", sql.NullInt64{})
plan, apiErr := svc.ResolvePlan(context.Background(), userID)
if apiErr != nil {
t.Fatalf("ResolvePlan: %v", apiErr)
}
if plan.PlanCode != "free" {
t.Errorf("PlanCode = %q, want free", plan.PlanCode)
}
if plan.MaxDevices != 1 {
t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", plan.MaxDevices)
}
}
// TestResolvePlan_FreeUserWithOverride: same free user, override=6 → the
// free-fallback path (GetFreePlan) must be overridden too.
func TestResolvePlan_FreeUserWithOverride(t *testing.T) {
db := openOverrideTestDB(t)
svc := NewService(NewStore(db), nil)
userID := insertOverrideTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true})
plan, apiErr := svc.ResolvePlan(context.Background(), userID)
if apiErr != nil {
t.Fatalf("ResolvePlan: %v", apiErr)
}
if plan.PlanCode != "free" {
t.Errorf("PlanCode = %q, want free (override must not change plan identity)", plan.PlanCode)
}
if plan.MaxDevices != 6 {
t.Errorf("MaxDevices = %d, want 6 (override)", plan.MaxDevices)
}
}
// TestResolvePlan_ProSubscriptionOverrideWins: a pro-subscription user
// (plan default max_devices=3) with override=2 — the subscription path
// (resolveEffectivePlan's "best" branch) must also be overridden, even
// though the override is *smaller* than the plan's own cap.
func TestResolvePlan_ProSubscriptionOverrideWins(t *testing.T) {
db := openOverrideTestDB(t)
svc := NewService(NewStore(db), nil)
userID := insertOverrideTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true})
giveOverrideTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour))
plan, apiErr := svc.ResolvePlan(context.Background(), userID)
if apiErr != nil {
t.Fatalf("ResolvePlan: %v", apiErr)
}
if plan.PlanCode != "pro" {
t.Errorf("PlanCode = %q, want pro", plan.PlanCode)
}
if plan.MaxDevices != 2 {
t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", plan.MaxDevices)
}
}
// TestResolvePlan_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean
// "no override" — the plan-derived cap must stand.
func TestResolvePlan_NullOrZeroOverrideDoesNotApply(t *testing.T) {
db := openOverrideTestDB(t)
svc := NewService(NewStore(db), nil)
nullUser := insertOverrideTestUser(t, db, "null-override@example.com", sql.NullInt64{})
zeroUser := insertOverrideTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true})
for _, tc := range []struct {
name string
userID int64
}{
{"NULL", nullUser},
{"zero", zeroUser},
} {
t.Run(tc.name, func(t *testing.T) {
plan, apiErr := svc.ResolvePlan(context.Background(), tc.userID)
if apiErr != nil {
t.Fatalf("ResolvePlan: %v", apiErr)
}
if plan.MaxDevices != 1 {
t.Errorf("MaxDevices = %d, want 1 (no override should apply)", plan.MaxDevices)
}
})
}
}
// --------------------------------------------------------------------------
// CheckDeviceLimit: behavioral assertion using the existing rig (it calls
// ResolvePlan internally) — override raises the effective cap so a device
// count that would be rejected under the free default is no longer flagged.
// --------------------------------------------------------------------------
func TestCheckDeviceLimit_OverrideAllowsMoreDevicesThanFreeCap(t *testing.T) {
db := openOverrideTestDB(t)
svc := NewService(NewStore(db), nil)
userID := insertOverrideTestUser(t, db, "backstop@example.com", sql.NullInt64{Int64: 6, Valid: true})
// Free plan cap is 1; without the override 3 active devices would be
// rejected (Over=true). The override raises the cap to 6.
for i := 0; i < 3; i++ {
insertOverrideTestDevice(t, db, userID, fmt.Sprintf("%d", i))
}
status, apiErr := svc.CheckDeviceLimit(context.Background(), userID)
if apiErr != nil {
t.Fatalf("CheckDeviceLimit: %v", apiErr)
}
if status.MaxDevices != 6 {
t.Errorf("MaxDevices = %d, want 6", status.MaxDevices)
}
if status.Over {
t.Errorf("Over = true, want false (override=6 covers 3 active devices; free default of 1 would reject them)")
}
}
+15 -1
View File
@@ -450,7 +450,21 @@ func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apier
if err != nil {
return Plan{}, apierr.ErrInternal
}
return resolveEffectivePlan(time.Now().UTC(), status, subs, free)
plan, apiErr := resolveEffectivePlan(time.Now().UTC(), status, subs, free)
if apiErr != nil {
return Plan{}, apiErr
}
// Per-user device-cap override (migration 000025, users.max_devices_override):
// NULL/0 = no override, keep the plan-derived cap resolved above. Applied
// once here — the single funnel both the subscription path and the free
// fallback flow through — so every consumer (subscription middleware,
// /v1/me, CheckDeviceLimit) sees it uniformly.
if override, ok, err := svc.store.GetMaxDevicesOverride(ctx, userID); err != nil {
return Plan{}, apierr.ErrInternal
} else if ok {
plan.MaxDevices = override
}
return plan, nil
}
// CheckDeviceLimit prunes stale devices (best-effort churn cleanup), resolves the
+20
View File
@@ -299,6 +299,26 @@ func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) {
return p, nil
}
// GetMaxDevicesOverride returns the per-user device-cap override set by
// migration 000025 (users.max_devices_override). NULL or <=0 means "no
// override" (ok=false) — the caller should fall back to the plan-derived cap.
// A positive value always wins, even if it is *smaller* than the plan's cap,
// so operators can also tighten a specific account.
func (s *Store) GetMaxDevicesOverride(ctx context.Context, userID int64) (int, bool, error) {
var override sql.NullInt64
row := s.db.QueryRowContext(ctx, `SELECT max_devices_override FROM users WHERE id=?`, userID)
if err := row.Scan(&override); err != nil {
if err == sql.ErrNoRows {
return 0, false, nil
}
return 0, false, fmt.Errorf("store.GetMaxDevicesOverride: %w", err)
}
if !override.Valid || override.Int64 <= 0 {
return 0, false, nil
}
return int(override.Int64), true, nil
}
// --------------------------------------------------------------------------
// Audit log
// --------------------------------------------------------------------------
@@ -104,10 +104,14 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) {
// the reviewer's repro hit: the lib's `codes` table collides with the
// name 000022's down script renames legacy_codes back to.
m := newSQLiteStepper(t, db)
// 000024 (invite_rewards) and 000023 (pay_purchases/source-enum) now sit
// on top of 000022 (codes_lib_legacy_rename) and are unrelated to this
// collision — step them back down first so we land exactly on the
// 000022 boundary the test targets.
// 000025 (user_device_limit_override), 000024 (invite_rewards) and 000023
// (pay_purchases/source-enum) now sit on top of 000022
// (codes_lib_legacy_rename) and are unrelated to this collision — step
// them back down first so we land exactly on the 000022 boundary the
// test targets.
if err := m.Steps(-1); err != nil {
t.Fatalf("step 000025 down: %v", err)
}
if err := m.Steps(-1); err != nil {
t.Fatalf("step 000024 down: %v", err)
}
+2 -2
View File
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
if dirty {
t.Fatalf("schema dirty after MigrateUp")
}
if v != 24 {
t.Errorf("version = %d, want 24", v)
if v != 25 {
t.Errorf("version = %d, want 25", v)
}
// 2. Core tables exist.
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN max_devices_override;
@@ -0,0 +1,2 @@
-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。
ALTER TABLE users ADD COLUMN max_devices_override INT NULL;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN max_devices_override;
@@ -0,0 +1,2 @@
-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。
ALTER TABLE users ADD COLUMN max_devices_override INTEGER;