fix(client/web): 语言跟随系统 + SSO 落地补 basePath + 移除账户页续费按钮

- 语言(#1):LocaleNotifier 首启无用户选择时读设备 locale(zh/ja/ko/ru/es 命中
  对应语种,余回退英文),原来恒为固定默认 en、从不看系统语言。用户显式切换仍持久
  化优先。
- 用户中心 SSO(#3):usercenter 运行在 basePath=/user,但 /sso 落地页用原生
  window.location.replace('/') 跳转(Next 不补 basePath)→ 落到站点根=官网主页,
  且换票已建立的会话看起来像未登录。改为 withBase() 统一补 /user 前缀,兑票成功
  直达 /user/ 且保持登录态。(服务端 issue/exchange 流程实测均 200,非后端问题)
- 账户页(#4):移除"续费/升级"按钮(免费卡片 + PRO 横幅两处)及其未用组件;购买入口
  仍由"购买套餐"行保留,不影响下单路径。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
This commit is contained in:
wangjia
2026-08-09 09:42:25 +08:00
parent e7e59a786d
commit bb031a04c1
3 changed files with 37 additions and 50 deletions
-41
View File
@@ -60,15 +60,6 @@ class AccountPage extends ConsumerWidget {
isFree: isFree,
email: email,
expiresLabel: expiresLabel,
// Renew/Upgrade 直达购买页(套餐 + 渠道 switch),不再经 PlansScreen。
onUpgrade: () => open(
NavView.purchase,
PurchaseScreen(
t: t,
onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))),
),
),
),
const SizedBox(height: 18),
// 账户信息
@@ -156,13 +147,11 @@ class _PlanBanner extends StatelessWidget {
required this.isFree,
required this.email,
required this.expiresLabel,
required this.onUpgrade,
});
final AppText t;
final bool isFree;
final String email;
final String expiresLabel;
final VoidCallback onUpgrade;
@override
Widget build(BuildContext context) {
@@ -193,7 +182,6 @@ class _PlanBanner extends StatelessWidget {
child: Text(t.freePlanName, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 11)),
),
])),
_UpgradeButton(label: t.upgradeBtn, onTap: onUpgrade),
]),
]),
);
@@ -222,40 +210,11 @@ class _PlanBanner extends StatelessWidget {
Text(expiresLabel.isEmpty ? t.proMember : '${t.proMember} · $expiresLabel',
style: TextStyle(color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 12)),
])),
FilledButton(
onPressed: onUpgrade,
style: FilledButton.styleFrom(
backgroundColor: PangolinColors.white,
foregroundColor: PangolinColors.clay700,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: Text(t.upgradeBtn, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
),
]),
);
}
}
class _UpgradeButton extends StatelessWidget {
const _UpgradeButton({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
backgroundColor: c.accent,
foregroundColor: c.fgOnAccent,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.text});
+21 -5
View File
@@ -1,4 +1,6 @@
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -32,15 +34,29 @@ AppText appTextFor(AppLang lang) {
}
}
/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到
/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化,
/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换
/// 当前语言。首启无用户选择时**跟随系统语言**(设备 locale → 支持的 AppLang,
/// 命不中回退英文);用户在设置/账户页显式切换后持久化到 shared_preferences
/// (key `pg_lang`,存枚举 name),此后一律以用户选择为准、不再看系统
class LocaleNotifier extends StateNotifier<AppLang> {
LocaleNotifier() : super(AppLang.en) {
LocaleNotifier() : super(_systemDefault()) {
_load();
}
static const _key = 'pg_lang';
/// 设备语言 → 支持的 AppLang。取平台首选 locale 的语言码(zh/ja/ko/ru/es 命中
/// 对应语种,其余一律英文)。构造期同步可用,无需 await。
static AppLang _systemDefault() {
try {
final code = ui.PlatformDispatcher.instance.locale.languageCode.toLowerCase();
for (final l in AppLang.values) {
if (l.name == code) return l;
}
} catch (_) {
/* 取系统 locale 失败 → 英文 */
}
return AppLang.en;
}
Future<void> _load() async {
try {
final saved = (await SharedPreferences.getInstance()).getString(_key);
@@ -53,7 +69,7 @@ class LocaleNotifier extends StateNotifier<AppLang> {
}
}
} catch (_) {
/* 读失败保持默认 en */
/* 读失败保持系统默认 */
}
}
+16 -4
View File
@@ -25,6 +25,17 @@ import { makeT } from '../../lib/i18n';
import { getClient } from '../../lib/api/client';
import { bilingual } from '../../lib/api/errors';
/** 站点 basePath(与 next.config.js 一致)。本页用的是 window.location 原生跳转,
* Next 不会自动补 basePath,故绝对路径必须手动加前缀,否则会落到站点根(官网主页)
* 而不是用户中心 —— 这正是「点用户中心却跳到主页、还要求重新登录」的根因。 */
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/user';
/** 把本站相对路径补上 basePath(供 window.location 原生跳转用)。 */
function withBase(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`;
return `${BASE_PATH}${p}`;
}
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
function safeRedirect(raw: string | null): string {
if (!raw) return '/';
@@ -45,14 +56,15 @@ export default function SsoPage() {
const redirect = safeRedirect(params.get('redirect'));
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
// 原生 history/location 不吃 Next 的 basePath,须手动补,否则地址与跳转都会漂到站点根。
try {
window.history.replaceState(null, '', '/sso/');
window.history.replaceState(null, '', withBase('/sso/'));
} catch {
/* ignore */
}
if (!ticket) {
window.location.replace('/');
window.location.replace(withBase('/'));
return;
}
@@ -61,14 +73,14 @@ export default function SsoPage() {
.exchangeWebTicket(ticket)
.then(() => {
if (!alive) return;
window.location.replace(redirect);
window.location.replace(withBase(redirect));
})
.catch((e) => {
if (!alive) return;
setMsg(bilingual(e, lang));
setFailed(true);
setTimeout(() => {
window.location.replace('/');
window.location.replace(withBase('/'));
}, 1500);
});