diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 3ffa1d7..6546d00 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -4,8 +4,8 @@ // `localeProvider` 段控切换。所有界面文案必须经此处取用,禁止在组件内 // 写死中/英字面量。zh / en 两份资源分别在 strings_zh.dart / strings_en.dart。 // -// 红线词约束(设计铁律 §13):资源文件不得出现 VPN / 翻墙 / 科学上网 / -// 突破封锁 / 自由穿越 / Go anywhere。CI `ci/scan-redline.sh` 对本目录做扫描。 +// 红线词约束(设计铁律 §13):资源文件不得出现审查规避/敏感定位词, +// 具体清单见 design/CLAUDE.md §1 铁律 13;CI `ci/scan-redline.sh` 对本目录做扫描。 // // 注意:这是手写资源(非 flutter gen-l10n 代码生成),保证离线可编译; // 接口形态与 .arb 一致,后续可平滑迁移到官方 l10n 工具链。 @@ -502,6 +502,7 @@ abstract class AppText { String get contactIntro; String get contactEmail; String get contactStore; + String get contactWebsite; String get contactHoursTitle; String get contactHours; @@ -564,5 +565,26 @@ abstract class AppText { String get payRetry; // 重试 / Retry String get switchPayMethod; // 换一种支付方式 / Switch payment method String get cancelOrder; // 取消订单 / Cancel order - String get qrNotSupported; // 请复制内容后在支付宝内打开 / Copy and open in Alipay + String get qrNotSupported; + + // ── 支付渠道 switch + 订单 ── + String get payChannel; + String get payChannelUsdt; + String get payChannelCny; + String get perMonthSuffix; + String get ordersTitle; + String get orderDetailTitle; + String get ordersEmpty; + String get orderNoLabel; + String get orderPlanLabel; + String get orderMethodLabel; + String get orderAmountLabel; + String get orderCreatedLabel; + String get orderPaidLabel; + String get orderStatusPaid; + String get orderStatusCanceled; + String get orderContinuePay; + String get orderRefresh; + String get orderRebuy; + String get payMethodNezha; // 请复制内容后在支付宝内打开 / Copy and open in Alipay } diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 6057be8..4ab49ed 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -1,6 +1,6 @@ // strings_en.dart — English copy resources (single-language display) // -// Red-line words forbidden: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere. +// Red-line words forbidden — see design/CLAUDE.md §1 rule 13 (no censorship-circumvention terms). // Positioning: network acceleration / experience optimization + privacy. // Plan numbers follow design/CLAUDE.md §7. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsEn extends AppText { @override String get watchAdMore => 'Watch ad for more time'; @override - String get quotaExhaustedNotice => 'Daily free time used up. Watch an ad to add time or upgrade.'; + String get quotaExhaustedNotice => "You've used today's free time. Upgrade to keep connecting."; @override String get adPlaying => 'Ad playing…'; @override @@ -311,6 +311,8 @@ class StringsEn extends AppText { @override String get contactStore => 'Self-serve store'; @override + String get contactWebsite => 'Website'; + @override String get contactHoursTitle => 'Support hours'; @override String get contactHours => 'Daily 9:00 – 24:00 (GMT+8)'; @@ -427,4 +429,44 @@ class StringsEn extends AppText { String get cancelOrder => 'Cancel order'; @override String get qrNotSupported => 'Copy and open in Alipay'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => 'Payment channel'; + @override + String get payChannelUsdt => 'USDT · Crypto'; + @override + String get payChannelCny => 'CNY · Alipay'; + @override + String get perMonthSuffix => ' /mo'; + @override + String get ordersTitle => 'My orders'; + @override + String get orderDetailTitle => 'Order details'; + @override + String get ordersEmpty => 'No orders yet'; + @override + String get orderNoLabel => 'Order no.'; + @override + String get orderPlanLabel => 'Plan'; + @override + String get orderMethodLabel => 'Payment method'; + @override + String get orderAmountLabel => 'Amount'; + @override + String get orderCreatedLabel => 'Created'; + @override + String get orderPaidLabel => 'Paid at'; + @override + String get orderStatusPaid => 'Activated'; + @override + String get orderStatusCanceled => 'Canceled'; + @override + String get orderContinuePay => 'Continue payment'; + @override + String get orderRefresh => 'Refresh'; + @override + String get orderRebuy => 'Buy again'; + @override + String get payMethodNezha => 'Nazhapay'; } diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 53cf6ff..251add4 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -1,6 +1,6 @@ // strings_es.dart — Recursos de texto en español (visualización monolingüe) // -// Palabras prohibidas: VPN / traspasar muros / red científica / romper el bloqueo / cruce libre / Go anywhere. +// Palabras prohibidas: términos de evasión de censura. Lista en design/CLAUDE.md §1 regla 13. // Posicionamiento: aceleración de red / optimización de la experiencia + privacidad. // La numeración de planes sigue design/CLAUDE.md §7. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsEs extends AppText { @override String get watchAdMore => 'Mira un anuncio para más tiempo'; @override - String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mira un anuncio para añadir tiempo o mejora tu plan.'; + String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mejora tu plan para seguir conectado.'; @override String get adPlaying => 'Reproduciendo anuncio…'; @override @@ -311,6 +311,8 @@ class StringsEs extends AppText { @override String get contactStore => 'Tienda autoservicio'; @override + String get contactWebsite => 'Sitio web'; + @override String get contactHoursTitle => 'Horario de soporte'; @override String get contactHours => 'Todos los días 9:00 – 24:00 (GMT+8)'; @@ -370,4 +372,102 @@ class StringsEs extends AppText { String get ob3Title => 'Privado por diseño'; @override String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => 'Comprar plan'; + @override + String get paymentTitle => 'Pago'; + @override + String get buyNow => 'Comprar ahora'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => 'Elige el método de pago'; + @override + String get proMonthly => 'Pro · Mensual'; + @override + String get proQuarterly => 'Pro · Trimestral'; + @override + String get proYearly => 'Pro · Anual'; + @override + String get perQuarter => '/trimestre'; + @override + String get perYear => '/año'; + @override + String get payAmountLabel => 'Importe a transferir'; + @override + String get payAddressLabel => 'Dirección de pago'; + @override + String get payNetworkLabel => 'Red'; + @override + String get payExactAmountHint => 'Envía el importe exacto; se activa automáticamente'; + @override + String get copied => 'Copiado'; + @override + String get openAlipay => 'Pagar con Alipay'; + @override + String get openAlipayHint => 'Vuelve tras pagar; esta página se actualiza automáticamente'; + @override + String get openAlipayFailed => 'No se pudo abrir Alipay; comprueba que esté instalado o inténtalo de nuevo'; + @override + String get awaitingPayment => 'Esperando el pago'; + @override + String get paySucceeded => 'Activado'; + @override + String get payExpiresAt => 'Válido hasta'; + @override + String get payDone => 'Listo'; + @override + String get payFailed => 'El pago falló'; + @override + String get payRetry => 'Reintentar'; + @override + String get switchPayMethod => 'Cambiar método de pago'; + @override + String get cancelOrder => 'Cancelar pedido'; + @override + String get qrNotSupported => 'Copia el contenido y ábrelo en Alipay'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => 'Canal de pago'; + @override + String get payChannelUsdt => 'USDT · Cripto'; + @override + String get payChannelCny => 'CNY · Alipay'; + @override + String get perMonthSuffix => ' /mes'; + @override + String get ordersTitle => 'Mis pedidos'; + @override + String get orderDetailTitle => 'Detalles del pedido'; + @override + String get ordersEmpty => 'Aún no hay pedidos'; + @override + String get orderNoLabel => 'N.º de pedido'; + @override + String get orderPlanLabel => 'Plan'; + @override + String get orderMethodLabel => 'Método de pago'; + @override + String get orderAmountLabel => 'Importe'; + @override + String get orderCreatedLabel => 'Creado'; + @override + String get orderPaidLabel => 'Pagado'; + @override + String get orderStatusPaid => 'Activado'; + @override + String get orderStatusCanceled => 'Cancelado'; + @override + String get orderContinuePay => 'Continuar pago'; + @override + String get orderRefresh => 'Actualizar'; + @override + String get orderRebuy => 'Comprar de nuevo'; + @override + String get payMethodNezha => 'Nazhapay'; } diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 14268b8..17b7bad 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -1,6 +1,6 @@ // strings_ja.dart — 日本語の文言リソース(単一言語表示) // -// 禁止ワード: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。 +// 禁止ワード: 検閲回避・センシティブな表現。一覧は design/CLAUDE.md §1 ルール13を参照。 // ポジショニング: ネットワーク高速化 / 体験最適化 + プライバシー。 // プラン番号は design/CLAUDE.md §7 に準拠。 import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsJa extends AppText { @override String get watchAdMore => '広告を見て時間を追加'; @override - String get quotaExhaustedNotice => '本日の無料時間を使い切りました。広告を見て時間を追加するか、アップグレードしてください。'; + String get quotaExhaustedNotice => '本日の無料時間を使い切りました。アップグレードすると続けて接続できます。'; @override String get adPlaying => '広告を再生中…'; @override @@ -311,6 +311,8 @@ class StringsJa extends AppText { @override String get contactStore => 'セルフサービスストア'; @override + String get contactWebsite => '公式サイト'; + @override String get contactHoursTitle => 'サポート時間'; @override String get contactHours => '毎日 9:00 – 24:00 (GMT+8)'; @@ -370,4 +372,102 @@ class StringsJa extends AppText { String get ob3Title => '設計段階からプライバシー重視'; @override String get ob3Sub => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => 'プランを購入'; + @override + String get paymentTitle => 'お支払い'; + @override + String get buyNow => '今すぐ購入'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => 'お支払い方法を選択'; + @override + String get proMonthly => 'Pro · 月額'; + @override + String get proQuarterly => 'Pro · 3か月'; + @override + String get proYearly => 'Pro · 年額'; + @override + String get perQuarter => '/3か月'; + @override + String get perYear => '/年'; + @override + String get payAmountLabel => '送金額'; + @override + String get payAddressLabel => '送金先アドレス'; + @override + String get payNetworkLabel => 'ネットワーク'; + @override + String get payExactAmountHint => '正確な金額をお送りください。着金後、自動で有効になります'; + @override + String get copied => 'コピーしました'; + @override + String get openAlipay => 'Alipayで支払う'; + @override + String get openAlipayHint => 'お支払い後に戻ると、このページは自動的に更新されます'; + @override + String get openAlipayFailed => 'Alipayを開けませんでした。インストール済みか確認するか、後でもう一度お試しください'; + @override + String get awaitingPayment => 'お支払い待ち'; + @override + String get paySucceeded => '有効になりました'; + @override + String get payExpiresAt => '有効期限'; + @override + String get payDone => '完了'; + @override + String get payFailed => 'お支払いに失敗しました'; + @override + String get payRetry => '再試行'; + @override + String get switchPayMethod => '別のお支払い方法に変更'; + @override + String get cancelOrder => '注文をキャンセル'; + @override + String get qrNotSupported => '内容をコピーしてAlipayで開いてください'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => '支払いチャネル'; + @override + String get payChannelUsdt => 'USDT · 暗号資産'; + @override + String get payChannelCny => '人民元 · Alipay'; + @override + String get perMonthSuffix => ' /月'; + @override + String get ordersTitle => '注文履歴'; + @override + String get orderDetailTitle => '注文の詳細'; + @override + String get ordersEmpty => '注文はありません'; + @override + String get orderNoLabel => '注文番号'; + @override + String get orderPlanLabel => 'プラン'; + @override + String get orderMethodLabel => '支払い方法'; + @override + String get orderAmountLabel => '金額'; + @override + String get orderCreatedLabel => '作成日時'; + @override + String get orderPaidLabel => '支払日時'; + @override + String get orderStatusPaid => '有効化済み'; + @override + String get orderStatusCanceled => 'キャンセル済み'; + @override + String get orderContinuePay => '支払いを続ける'; + @override + String get orderRefresh => '更新'; + @override + String get orderRebuy => '再購入'; + @override + String get payMethodNezha => 'Nazhapay'; } diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 8b37b5e..910a112 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -1,6 +1,6 @@ // strings_ko.dart — 한국어 문구 리소스 (단일 언어 표시) // -// 금지어: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere. +// 금지어: 검열 우회·민감 표현. 목록은 design/CLAUDE.md §1 규칙 13 참조. // 포지셔닝: 네트워크 가속 / 경험 최적화 + 프라이버시. // 요금제 번호는 design/CLAUDE.md §7 을 따름. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsKo extends AppText { @override String get watchAdMore => '광고 시청으로 시간 추가'; @override - String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 광고를 시청해 시간을 추가하거나 업그레이드하세요.'; + String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 업그레이드하면 계속 연결할 수 있습니다.'; @override String get adPlaying => '광고 재생 중…'; @override @@ -311,6 +311,8 @@ class StringsKo extends AppText { @override String get contactStore => '셀프 스토어'; @override + String get contactWebsite => '공식 웹사이트'; + @override String get contactHoursTitle => '지원 시간'; @override String get contactHours => '매일 9:00 – 24:00 (GMT+8)'; @@ -370,4 +372,102 @@ class StringsKo extends AppText { String get ob3Title => '설계부터 프라이버시'; @override String get ob3Sub => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => '요금제 구매'; + @override + String get paymentTitle => '결제'; + @override + String get buyNow => '지금 구매'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => '결제 수단 선택'; + @override + String get proMonthly => 'Pro · 월간'; + @override + String get proQuarterly => 'Pro · 분기'; + @override + String get proYearly => 'Pro · 연간'; + @override + String get perQuarter => '/분기'; + @override + String get perYear => '/년'; + @override + String get payAmountLabel => '송금액'; + @override + String get payAddressLabel => '입금 주소'; + @override + String get payNetworkLabel => '네트워크'; + @override + String get payExactAmountHint => '정확한 금액을 보내주세요. 입금 확인 후 자동으로 활성화됩니다'; + @override + String get copied => '복사됨'; + @override + String get openAlipay => 'Alipay로 결제'; + @override + String get openAlipayHint => '결제 후 돌아오면 이 페이지가 자동으로 새로고침됩니다'; + @override + String get openAlipayFailed => 'Alipay를 열 수 없습니다. 설치되어 있는지 확인하거나 다시 시도하세요'; + @override + String get awaitingPayment => '결제 대기 중'; + @override + String get paySucceeded => '활성화됨'; + @override + String get payExpiresAt => '유효기간'; + @override + String get payDone => '완료'; + @override + String get payFailed => '결제 실패'; + @override + String get payRetry => '다시 시도'; + @override + String get switchPayMethod => '다른 결제 수단으로 변경'; + @override + String get cancelOrder => '주문 취소'; + @override + String get qrNotSupported => '내용을 복사해 Alipay에서 여세요'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => '결제 채널'; + @override + String get payChannelUsdt => 'USDT · 암호화폐'; + @override + String get payChannelCny => '위안화 · Alipay'; + @override + String get perMonthSuffix => ' /월'; + @override + String get ordersTitle => '내 주문'; + @override + String get orderDetailTitle => '주문 상세'; + @override + String get ordersEmpty => '주문이 없습니다'; + @override + String get orderNoLabel => '주문 번호'; + @override + String get orderPlanLabel => '플랜'; + @override + String get orderMethodLabel => '결제 수단'; + @override + String get orderAmountLabel => '금액'; + @override + String get orderCreatedLabel => '생성 시각'; + @override + String get orderPaidLabel => '결제 시각'; + @override + String get orderStatusPaid => '활성화됨'; + @override + String get orderStatusCanceled => '취소됨'; + @override + String get orderContinuePay => '결제 계속'; + @override + String get orderRefresh => '새로고침'; + @override + String get orderRebuy => '다시 구매'; + @override + String get payMethodNezha => 'Nazhapay'; } diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index 74094d1..ba6bab0 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -1,6 +1,6 @@ // strings_ru.dart — русские текстовые ресурсы (отображение на одном языке) // -// Запрещённые слова: VPN /翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / ВПН. +// Запрещённые слова: термины обхода цензуры. Список см. design/CLAUDE.md §1 правило 13. // Позиционирование: ускорение сети / оптимизация опыта + приватность. // Номера тарифов соответствуют design/CLAUDE.md §7. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsRu extends AppText { @override String get watchAdMore => 'Смотрите рекламу, чтобы добавить время'; @override - String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Посмотрите рекламу, чтобы добавить время, или оформите подписку.'; + String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Оформите подписку, чтобы продолжить подключение.'; @override String get adPlaying => 'Реклама воспроизводится…'; @override @@ -311,6 +311,8 @@ class StringsRu extends AppText { @override String get contactStore => 'Магазин самообслуживания'; @override + String get contactWebsite => 'Веб-сайт'; + @override String get contactHoursTitle => 'Часы поддержки'; @override String get contactHours => 'Ежедневно 9:00 – 24:00 (GMT+8)'; @@ -370,4 +372,102 @@ class StringsRu extends AppText { String get ob3Title => 'Приватность по умолчанию'; @override String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => 'Покупка тарифа'; + @override + String get paymentTitle => 'Оплата'; + @override + String get buyNow => 'Купить'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => 'Выберите способ оплаты'; + @override + String get proMonthly => 'Pro · Месяц'; + @override + String get proQuarterly => 'Pro · Квартал'; + @override + String get proYearly => 'Pro · Год'; + @override + String get perQuarter => '/квартал'; + @override + String get perYear => '/год'; + @override + String get payAmountLabel => 'Сумма перевода'; + @override + String get payAddressLabel => 'Адрес для оплаты'; + @override + String get payNetworkLabel => 'Сеть'; + @override + String get payExactAmountHint => 'Отправьте точную сумму — активация произойдёт автоматически'; + @override + String get copied => 'Скопировано'; + @override + String get openAlipay => 'Оплатить через Alipay'; + @override + String get openAlipayHint => 'Вернитесь после оплаты — страница обновится автоматически'; + @override + String get openAlipayFailed => 'Не удалось открыть Alipay. Проверьте, установлен ли он, или повторите'; + @override + String get awaitingPayment => 'Ожидание оплаты'; + @override + String get paySucceeded => 'Активировано'; + @override + String get payExpiresAt => 'Действует до'; + @override + String get payDone => 'Готово'; + @override + String get payFailed => 'Оплата не удалась'; + @override + String get payRetry => 'Повторить'; + @override + String get switchPayMethod => 'Сменить способ оплаты'; + @override + String get cancelOrder => 'Отменить заказ'; + @override + String get qrNotSupported => 'Скопируйте и откройте в Alipay'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => 'Способ оплаты'; + @override + String get payChannelUsdt => 'USDT · Крипто'; + @override + String get payChannelCny => 'Юань · Alipay'; + @override + String get perMonthSuffix => ' /мес'; + @override + String get ordersTitle => 'Мои заказы'; + @override + String get orderDetailTitle => 'Детали заказа'; + @override + String get ordersEmpty => 'Заказов пока нет'; + @override + String get orderNoLabel => 'Номер заказа'; + @override + String get orderPlanLabel => 'Тариф'; + @override + String get orderMethodLabel => 'Способ оплаты'; + @override + String get orderAmountLabel => 'Сумма'; + @override + String get orderCreatedLabel => 'Создан'; + @override + String get orderPaidLabel => 'Оплачен'; + @override + String get orderStatusPaid => 'Активирован'; + @override + String get orderStatusCanceled => 'Отменён'; + @override + String get orderContinuePay => 'Продолжить оплату'; + @override + String get orderRefresh => 'Обновить'; + @override + String get orderRebuy => 'Купить снова'; + @override + String get payMethodNezha => 'Nazhapay'; } diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 11584ae..11f8ee7 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -1,6 +1,6 @@ // strings_zh.dart — 中文文案资源(单显) // -// 红线词禁用:VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。 +// 红线词禁用:审查规避/敏感定位词,清单见 design/CLAUDE.md §1 铁律 13。 // 脱敏口径:网络加速 / 体验优化 + 隐私保护。套餐数字以 design/CLAUDE.md §7 为准。 import 'app_text.dart'; @@ -64,7 +64,7 @@ class StringsZh extends AppText { @override String get watchAdMore => '看广告加时'; @override - String get quotaExhaustedNotice => '今日免费时长已用完,看广告加时或升级会员'; + String get quotaExhaustedNotice => '今日免费时长已用完,升级会员可继续使用'; @override String get adPlaying => '广告播放中…'; @override @@ -305,6 +305,8 @@ class StringsZh extends AppText { @override String get contactStore => '自助发卡商店'; @override + String get contactWebsite => '官网'; + @override String get contactHoursTitle => '服务时间'; @override String get contactHours => '每日 9:00 – 24:00 (GMT+8)'; @@ -421,4 +423,44 @@ class StringsZh extends AppText { String get cancelOrder => '取消订单'; @override String get qrNotSupported => '请复制内容后在支付宝内打开'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => '支付渠道'; + @override + String get payChannelUsdt => 'USDT · 加密货币'; + @override + String get payChannelCny => '人民币 · 支付宝'; + @override + String get perMonthSuffix => ' /月'; + @override + String get ordersTitle => '我的订单'; + @override + String get orderDetailTitle => '订单详情'; + @override + String get ordersEmpty => '暂无订单'; + @override + String get orderNoLabel => '订单号'; + @override + String get orderPlanLabel => '套餐'; + @override + String get orderMethodLabel => '支付方式'; + @override + String get orderAmountLabel => '金额'; + @override + String get orderCreatedLabel => '创建时间'; + @override + String get orderPaidLabel => '付款时间'; + @override + String get orderStatusPaid => '已开通'; + @override + String get orderStatusCanceled => '已取消'; + @override + String get orderContinuePay => '继续支付'; + @override + String get orderRefresh => '刷新状态'; + @override + String get orderRebuy => '重新购买'; + @override + String get payMethodNezha => '哪吒支付'; } diff --git a/client/lib/models/payment.dart b/client/lib/models/payment.dart index 1591a50..f0a3eeb 100644 --- a/client/lib/models/payment.dart +++ b/client/lib/models/payment.dart @@ -1,6 +1,15 @@ // payment.dart — pay v2 支付领域模型(server 代理端点的响应形状)。 // 金额只读展示:price_minor 为 CNY 分;crypto 精确金额在 session.payload 里。 +/// 支付渠道:结算币种即支付方式(一步定)。usdt→加密货币 / cny→支付宝。 +/// 各币种一口价(不做实时汇率换算);实际扣款以 pay 侧为准。 +enum PayChannel { usdt, cny } + +extension PayChannelX on PayChannel { + String get currency => this == PayChannel.usdt ? 'USDT' : 'CNY'; + String get method => this == PayChannel.usdt ? 'crypto' : 'alipay'; +} + class PayCatalogItem { const PayCatalogItem({ required this.sku, @@ -8,13 +17,15 @@ class PayCatalogItem { required this.days, required this.priceMinor, required this.currency, + this.priceUsdtMicro = 0, }); final String sku; final String plan; final int days; - final int priceMinor; - final String currency; + final int priceMinor; // CNY 分(展示) + final String currency; // CNY + final int priceUsdtMicro; // USDT 微元(展示;各币种一口价) factory PayCatalogItem.fromJson(Map j) => PayCatalogItem( sku: j['sku'] as String, @@ -22,9 +33,70 @@ class PayCatalogItem { days: (j['days'] as num?)?.toInt() ?? 0, priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0, currency: j['currency'] as String? ?? 'CNY', + priceUsdtMicro: (j['price_usdt_micro'] as num?)?.toInt() ?? 0, ); - String priceLabel() => '¥${(priceMinor / 100).toStringAsFixed(2)}'; + /// 约当月数(31→1 / 92→3 / 366→12),用于每月折算展示。 + int get months { + final m = (days / 30.4).round(); + return m < 1 ? 1 : m; + } + + /// 按渠道币种给总价文案(USDT $ / CNY ¥)。 + String priceLabel(PayChannel ch) => ch == PayChannel.usdt + ? '\$${(priceUsdtMicro / 1000000).toStringAsFixed(2)}' + : '¥${(priceMinor / 100).toStringAsFixed(2)}'; + + /// 按渠道币种给每月折算文案(不含 /月 后缀,后缀由 l10n 提供)。 + String perMonthLabel(PayChannel ch) => ch == PayChannel.usdt + ? '\$${(priceUsdtMicro / 1000000 / months).toStringAsFixed(2)}' + : '¥${(priceMinor / 100 / months).toStringAsFixed(2)}'; +} + +/// PayOrderSummary — 订单列表项(server orderView 台账视图)。 +/// 金额为结算币种 minor:CNY 分 / USDT 微元。 +class PayOrderSummary { + const PayOrderSummary({ + required this.orderNo, + required this.sku, + required this.plan, + required this.days, + required this.method, + required this.status, + required this.amountMinor, + required this.currency, + required this.createdAt, + this.paidAt, + }); + + final String orderNo; + final String sku; + final String plan; + final int days; + final String method; // crypto | alipay | nezha … + final String status; // created | paid | canceled(本地台账) + final int amountMinor; + final String currency; // CNY | USDT + final DateTime? createdAt; + final DateTime? paidAt; + + factory PayOrderSummary.fromJson(Map j) => PayOrderSummary( + orderNo: j['order_no'] as String? ?? '', + sku: j['sku'] as String? ?? '', + plan: j['plan'] as String? ?? 'pro', + days: (j['days'] as num?)?.toInt() ?? 0, + method: j['method'] as String? ?? '', + status: j['status'] as String? ?? '', + amountMinor: (j['amount_minor'] as num?)?.toInt() ?? 0, + currency: j['currency'] as String? ?? 'CNY', + createdAt: j['created_at'] == null ? null : DateTime.tryParse(j['created_at'] as String), + paidAt: j['paid_at'] == null ? null : DateTime.tryParse(j['paid_at'] as String), + ); + + /// 结算币种金额文案(USDT 6 位、CNY 2 位)。 + String amountLabel() => currency == 'USDT' + ? '\$${(amountMinor / 1000000).toStringAsFixed(2)}' + : '¥${(amountMinor / 100).toStringAsFixed(2)}'; } class PaySession { @@ -53,23 +125,29 @@ class PayOrder { ); } +/// PayOrderStatus — 查单/详情响应。轮询只用 activated;订单详情页额外用 +/// 台账富字段(server GetOrder 现回带 orderView 全字段)。 class PayOrderStatus { const PayOrderStatus({ required this.orderNo, required this.payStatus, required this.activated, this.expiresAt, + this.summary, }); final String orderNo; final String payStatus; final bool activated; // server 台账已消费 = 权益已开通(轮询成功判据) final DateTime? expiresAt; + final PayOrderSummary? summary; // 台账富字段(sku/plan/method/金额/时间…);详情页用 factory PayOrderStatus.fromJson(Map j) => PayOrderStatus( orderNo: j['order_no'] as String? ?? '', payStatus: j['pay_status'] as String? ?? '', activated: j['activated'] as bool? ?? false, expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String), + // 详情端点回带台账字段(列表端点同形);sku 存在才解析。 + summary: j['sku'] == null ? null : PayOrderSummary.fromJson(j), ); } diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index b656f00..7216f43 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -12,6 +12,7 @@ import '../state/navigation_provider.dart'; import '../widgets/account_screens.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/pangolin_icons.dart'; +import 'orders_page.dart'; import 'payment_page.dart'; import 'purchase_page.dart'; @@ -87,6 +88,8 @@ class AccountPage extends ConsumerWidget { _Card(children: [ _NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))), Divider(height: 1, color: c.border), + _NavRow(icon: PangolinIcons.ticket, title: t.ordersTitle, onTap: () => open(NavView.orders, OrdersScreen(t: t))), + Divider(height: 1, color: c.border), _NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))), Divider(height: 1, color: c.border), _NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => open(NavView.redeem, RedeemScreen(t: t))), diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 657c928..95ff232 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -10,13 +10,10 @@ import '../pangolin_theme.dart'; import '../state/app_providers.dart'; import '../state/connection_provider.dart'; import '../state/nodes_provider.dart'; -import '../state/quota_provider.dart'; -import '../widgets/ad_reward_dialog.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/connect_button.dart'; import '../widgets/country_code.dart'; import '../widgets/pangolin_icons.dart'; -import '../widgets/quota_card.dart'; class ConnectPage extends ConsumerWidget { const ConnectPage({super.key, required this.isWide, required this.onOpenNodes}); @@ -31,8 +28,6 @@ class ConnectPage extends ConsumerWidget { final conn = ref.watch(connectionProvider); final node = ref.watch(effectiveNodeProvider); final smart = ref.watch(isSmartSelectProvider); - final isFree = ref.watch(isFreePlanProvider); - final quota = ref.watch(quotaProvider); final stats = ref.watch(vpnStatsProvider).valueOrNull; // 账户首拉中 → 整页转圈,先拉到 me(决定免费/会员)再渲染, // 避免「先闪免费广告、me 到了再跳会员」。me 是常驻 Notifier,只首启时转一下。 @@ -53,18 +48,11 @@ class ConnectPage extends ConsumerWidget { VpnPhase.on => t.capOn, }; - // 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。 - final isDesktop = context.formFactor == FormFactor.desktop; - final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off); - void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop); - final button = ConnectButton( phase: conn.phase, elapsed: conn.elapsed, offLabel: t.connectNow, secureLabel: t.secure, - enabled: connectEnabled, - onDisabledTap: onQuotaBlockedTap, onTap: () => ref.read(connectionProvider.notifier).toggle(), ); @@ -82,14 +70,6 @@ class ConnectPage extends ConsumerWidget { ]); final infoChildren = [ - if (isFree) - QuotaCard( - quota: quota, - t: t, - countdown: conn.freeCountdown, - isDesktop: isDesktop, - onWatchAd: onQuotaBlockedTap, - ), if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue), _CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes), ]; @@ -114,8 +94,6 @@ class ConnectPage extends ConsumerWidget { offLabel: t.connectNow, secureLabel: t.secure, size: 176, - enabled: connectEnabled, - onDisabledTap: onQuotaBlockedTap, onTap: () => ref.read(connectionProvider.notifier).toggle(), ), const SizedBox(height: 24), @@ -132,19 +110,6 @@ class ConnectPage extends ConsumerWidget { ), ], _NodePill(t: t, node: node, smart: smart, pingLabel: pingLabel), - if (isFree) ...[ - const SizedBox(height: 24), - SizedBox( - width: 340, - child: QuotaCard( - quota: quota, - t: t, - countdown: conn.freeCountdown, - isDesktop: isDesktop, - onWatchAd: onQuotaBlockedTap, - ), - ), - ], if (conn.phase == VpnPhase.on) ...[ const SizedBox(height: 16), _SpeedRow(t: t, down: down, up: up, latency: latencyValue), diff --git a/client/lib/screens/contact_page.dart b/client/lib/screens/contact_page.dart index 0ee0adb..c84e539 100644 --- a/client/lib/screens/contact_page.dart +++ b/client/lib/screens/contact_page.dart @@ -19,10 +19,9 @@ class ContactPage extends ConsumerWidget { final t = ref.watch(appTextProvider); final channels = <({IconData icon, String name, String sub, bool accent})>[ - (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true), - (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), - (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false), - (icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false), + (icon: PangolinIcons.send, name: 'Telegram', sub: '@pangolin_app', accent: true), + (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@yanmeiai.com', accent: false), + (icon: PangolinIcons.globe, name: t.contactWebsite, sub: 'pangolin.yanmeiai.com', accent: false), ]; return SingleChildScrollView( diff --git a/client/lib/screens/orders_page.dart b/client/lib/screens/orders_page.dart new file mode 100644 index 0000000..ea60bf3 --- /dev/null +++ b/client/lib/screens/orders_page.dart @@ -0,0 +1,392 @@ +// orders_page.dart — 我的订单(列表 → 详情两屏,单 widget 内切换)。 +// +// 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。 +// App 内无支付表单——「继续支付 / 重新购买」只引导到购买页(PurchaseScreen)。 +// 规格参考 design/prototype/screens/orders.html。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/responsive/form_factor.dart'; +import '../l10n/app_text.dart'; +import '../models/payment.dart'; +import '../pangolin_theme.dart'; +import '../state/navigation_provider.dart'; +import '../state/orders_provider.dart'; +import '../widgets/pangolin_button.dart'; +import '../widgets/pangolin_icons.dart'; +import '../widgets/status_pill.dart'; +import 'payment_page.dart'; +import 'purchase_page.dart'; + +// ── 文案映射工具(sku→套餐名 / method→支付方式 / status→状态胶囊)── + +String _planName(AppText t, PayOrderSummary o) => switch (o.sku) { + 'pro_month' => t.proMonthly, + 'pro_quarter' => t.proQuarterly, + 'pro_year' => t.proYearly, + _ => o.sku.isNotEmpty ? o.sku : o.plan, + }; + +String _methodName(AppText t, String method) => switch (method) { + 'crypto' => t.payMethodCrypto, + 'alipay' => t.payMethodAlipay, + 'nezha' => t.payMethodNezha, + _ => method, + }; + +/// 订单状态 → (胶囊色, 文案)。created=等待付款 / paid=已开通 / canceled=已取消。 +({PangolinStatus status, String label}) _statusPill(AppText t, String status) => + switch (status) { + 'created' => (status: PangolinStatus.connecting, label: t.awaitingPayment), + 'paid' => (status: PangolinStatus.connected, label: t.orderStatusPaid), + 'canceled' => (status: PangolinStatus.neutral, label: t.orderStatusCanceled), + _ => (status: PangolinStatus.neutral, label: status), + }; + +String _fmtDate(DateTime d) { + final l = d.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${l.year}-${two(l.month)}-${two(l.day)}'; +} + +String _fmtDateTime(DateTime d) { + final l = d.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + 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}); + final AppText t; + final VoidCallback? onBack; + final bool embedded; + + @override + ConsumerState createState() => _OrdersScreenState(); +} + +class _OrdersScreenState extends ConsumerState { + PayOrderSummary? _selected; // 非空 = 展示详情 + + @override + Widget build(BuildContext context) { + if (_selected != null) { + return _OrderDetailView( + t: widget.t, + order: _selected!, + embedded: widget.embedded, + onBack: () => setState(() => _selected = null), + ); + } + return _OrderListView( + t: widget.t, + embedded: widget.embedded, + onBack: widget.onBack, + onOpen: (o) => setState(() => _selected = o), + ); + } +} + +// ── 列表屏 ── +class _OrderListView extends ConsumerWidget { + const _OrderListView({required this.t, required this.embedded, required this.onOpen, this.onBack}); + final AppText t; + final bool embedded; + final ValueChanged onOpen; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final ordersAsync = ref.watch(ordersProvider); + + Widget refreshBtn() => Align( + alignment: Alignment.centerRight, + child: InkWell( + borderRadius: BorderRadius.circular(PangolinRadius.full), + onTap: () => ref.invalidate(ordersProvider), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: c.bgSubtle, + borderRadius: BorderRadius.circular(PangolinRadius.full), + border: Border.all(color: c.border), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.refreshCw, size: 14, color: c.fg2), + const SizedBox(width: 6), + Text(t.orderRefresh, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12.5)), + ]), + ), + ), + ); + + Widget empty() => Padding( + padding: const EdgeInsets.only(top: 60), + child: Column(children: [ + Container( + width: 56, height: 56, + decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle), + child: Icon(PangolinIcons.ticket, size: 26, color: c.fg3), + ), + const SizedBox(height: 14), + Text(t.ordersEmpty, style: PangolinText.sm.copyWith(color: c.fg3)), + ]), + ); + + Widget content() => ordersAsync.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.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), + data: (orders) => ListView(padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [ + refreshBtn(), + const SizedBox(height: 12), + if (orders.isEmpty) + empty() + else + Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + for (var i = 0; i < orders.length; i++) + _row(context, c, orders[i], i < orders.length - 1), + ]), + ), + ]), + ); + + return _SubScaffold(title: t.ordersTitle, onBack: onBack, embedded: embedded, child: content()); + } + + Widget _row(BuildContext context, PangolinScheme c, PayOrderSummary o, bool divider) { + final pill = _statusPill(t, o.status); + final dateStr = o.createdAt != null ? _fmtDate(o.createdAt!) : ''; + final sub = [o.amountLabel(), if (dateStr.isNotEmpty) dateStr].join(' · '); + return InkWell( + onTap: () => onOpen(o), + child: Container( + decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row(children: [ + Container( + width: 38, height: 38, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), + child: Icon(PangolinIcons.ticket, size: 19, color: c.accent), + ), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(_planName(t, o), overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)), + const SizedBox(height: 2), + Text(sub, overflow: TextOverflow.ellipsis, + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + ]), + ), + const SizedBox(width: 8), + StatusPill(label: pill.label, status: pill.status), + const SizedBox(width: 4), + Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), + ]), + ), + ); + } +} + +// ── 详情屏 ── +class _OrderDetailView extends ConsumerWidget { + const _OrderDetailView({required this.t, required this.order, required this.embedded, required this.onBack}); + final AppText t; + final PayOrderSummary order; + final bool embedded; + final VoidCallback onBack; + + // 「继续支付 / 重新购买」→ 购买页(desktop 内容区下钻;mobile/tablet 全屏 push)。 + void _goPurchase(BuildContext context, WidgetRef ref) { + final isDesktop = context.formFactor == FormFactor.desktop; + if (isDesktop) { + ref.read(navViewProvider.notifier).state = NavView.purchase; + } else { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PurchaseScreen( + t: t, + onOrderCreated: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => PaymentScreen(t: t)), + ), + ), + )); + } + } + + Future _copy(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(t.copied), + behavior: SnackBarBehavior.floating, + duration: const Duration(milliseconds: 1400), + )); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + // 富详情(拿新鲜 expiresAt/status);summary 缺省回退列表项。 + final detailAsync = ref.watch(orderDetailProvider(order.orderNo)); + final o = detailAsync.valueOrNull?.summary ?? order; + final expiresAt = detailAsync.valueOrNull?.expiresAt; + final pill = _statusPill(t, o.status); + + // 内容区(mobile 套 _SubScaffold;desktop embedded 前置一条返回条回到列表)。 + Widget summaryCard() => Container( + width: double.infinity, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.xl), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_planName(t, o), style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + Text(o.amountLabel(), style: PangolinText.h1.copyWith(color: c.fg1, fontWeight: FontWeight.w700, height: 1)), + const SizedBox(height: 14), + StatusPill(label: pill.label, status: pill.status), + ]), + ); + + Widget kvCard() { + final rows = [ + _kv(c, t.orderNoLabel, o.orderNo, onCopy: () => _copy(context, o.orderNo)), + _kv(c, t.orderPlanLabel, _planName(t, o)), + _kv(c, t.orderMethodLabel, _methodName(t, o.method)), + _kv(c, t.orderAmountLabel, o.amountLabel()), + if (o.createdAt != null) _kv(c, t.orderCreatedLabel, _fmtDateTime(o.createdAt!)), + if (o.paidAt != null) _kv(c, t.orderPaidLabel, _fmtDateTime(o.paidAt!)), + if (expiresAt != null) _kv(c, t.payExpiresAt, _fmtDate(expiresAt)), + ]; + return Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + for (var i = 0; i < rows.length; i++) ...[ + rows[i], + if (i < rows.length - 1) Divider(height: 1, color: c.border), + ], + ]), + ); + } + + Widget actions() { + final list = []; + switch (o.status) { + case 'created': + list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _goPurchase(context, ref))); + case 'canceled': + list.add(PangolinButton(label: t.orderRebuy, expand: true, onPressed: () => _goPurchase(context, ref))); + case 'paid': + list.add(PangolinButton(label: t.orderRebuy, variant: PangolinButtonVariant.secondary, expand: true, onPressed: () => _goPurchase(context, ref))); + } + if (list.isEmpty) return const SizedBox.shrink(); + return Column(children: [for (final w in list) Padding(padding: const EdgeInsets.only(bottom: 10), child: w)]); + } + + Widget body() => ListView(padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), children: [ + summaryCard(), + const SizedBox(height: 18), + kvCard(), + const SizedBox(height: 20), + actions(), + ]); + + if (embedded) { + // desktop 内容区:shell 顶栏返回 = 回账户;这里再给一条返回条回到订单列表。 + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + InkWell( + onTap: onBack, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.arrowLeft, size: 18, color: c.accent), + const SizedBox(width: 6), + Text(t.orderDetailTitle, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)), + ]), + ), + ), + Expanded(child: body()), + ]); + } + return _SubScaffold(title: t.orderDetailTitle, onBack: onBack, child: body()); + } + + Widget _kv(PangolinScheme c, String label, String value, {VoidCallback? onCopy}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox( + width: 92, + child: Text(label, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w500)), + ), + const SizedBox(width: 12), + Expanded( + child: Text(value, textAlign: TextAlign.right, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500)), + ), + if (onCopy != null) ...[ + const SizedBox(width: 8), + InkWell( + borderRadius: BorderRadius.circular(PangolinRadius.sm), + onTap: onCopy, + child: Padding(padding: const EdgeInsets.all(2), child: Icon(PangolinIcons.copy, size: 15, color: c.fg3)), + ), + ], + ]), + ); + } +} diff --git a/client/lib/screens/payment_page.dart b/client/lib/screens/payment_page.dart index 9d5be3b..8e18669 100644 --- a/client/lib/screens/payment_page.dart +++ b/client/lib/screens/payment_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../l10n/app_text.dart'; +import '../models/payment.dart'; import '../pangolin_theme.dart'; import '../state/payment_provider.dart'; import '../widgets/pangolin_button.dart'; @@ -132,6 +133,48 @@ class _PaymentScreenState extends ConsumerState { ); } + String _planName(String sku) => switch (sku) { + 'pro_month' => t.proMonthly, + 'pro_quarter' => t.proQuarterly, + 'pro_year' => t.proYearly, + _ => sku, + }; + + // 订单信息卡:套餐名 + 支付渠道 + 金额(金额按所选渠道币种显价)。 + Widget _orderCard(BuildContext context, PaymentFlowState s) { + final c = context.pangolin; + final ch = s.method == 'crypto' ? PayChannel.usdt : PayChannel.cny; + final chanLabel = ch == PayChannel.usdt ? t.payChannelUsdt : t.payChannelCny; + final item = s.item; + return _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(t.orderDetailTitle.toUpperCase(), + style: PangolinText.caption + .copyWith(color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)), + const SizedBox(height: 10), + if (item != null) _orderRow(c, t.orderPlanLabel, _planName(item.sku)), + _orderRow(c, t.payChannel, chanLabel), + if (item != null) _orderRow(c, t.orderAmountLabel, item.priceLabel(ch), amount: true), + ])); + } + + Widget _orderRow(PangolinScheme c, String k, String v, {bool amount = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(k, style: PangolinText.sm.copyWith(color: c.fg3)), + Flexible( + child: Text(v, + textAlign: TextAlign.right, + style: (amount ? PangolinText.mono : PangolinText.sm).copyWith( + color: amount ? c.accent : c.fg1, fontWeight: FontWeight.w700)), + ), + ], + ), + ); + Widget _awaiting(BuildContext context, WidgetRef ref, PaymentFlowState s) { final c = context.pangolin; final session = s.order!.session; @@ -171,6 +214,8 @@ class _PaymentScreenState extends ConsumerState { } return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ + _orderCard(context, s), + const SizedBox(height: 14), renderBody, const SizedBox(height: 16), Row(children: [ diff --git a/client/lib/screens/purchase_page.dart b/client/lib/screens/purchase_page.dart index 357af97..aca11f3 100644 --- a/client/lib/screens/purchase_page.dart +++ b/client/lib/screens/purchase_page.dart @@ -1,5 +1,7 @@ // purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。 // 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。 +// 支付渠道即币种即方式(USDT→加密货币 默认 / 人民币→支付宝),顶部 SegSwitch 选定, +// 套餐价随渠道币种刷新;点「立即购买」直接下单进支付页(不再底部弹窗选方式)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,8 +11,9 @@ import '../pangolin_theme.dart'; import '../state/payment_provider.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/plan_card.dart'; +import '../widgets/seg_switch.dart'; -class PurchaseScreen extends ConsumerWidget { +class PurchaseScreen extends ConsumerStatefulWidget { const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false}); final AppText t; @@ -19,6 +22,16 @@ class PurchaseScreen extends ConsumerWidget { final VoidCallback? onOrderCreated; final bool embedded; + @override + ConsumerState createState() => _PurchaseScreenState(); +} + +class _PurchaseScreenState extends ConsumerState { + AppText get t => widget.t; + + // 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。 + PayChannel _channel = PayChannel.usdt; + String _name(String sku) => switch (sku) { 'pro_month' => t.proMonthly, 'pro_quarter' => t.proQuarterly, @@ -26,57 +39,16 @@ class PurchaseScreen extends ConsumerWidget { _ => sku, }; - String _period(String sku) => switch (sku) { - 'pro_month' => t.perMonth, - 'pro_quarter' => t.perQuarter, - 'pro_year' => t.perYear, - _ => '', - }; - - Future _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async { - final c = context.pangolin; - final method = await showModalBottomSheet( - context: context, - backgroundColor: c.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(PangolinRadius.xl)), - ), - builder: (sheetCtx) => SafeArea( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text(t.choosePayMethod, - style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), - ), - ), - ListTile( - leading: Icon(PangolinIcons.creditCard, color: c.fg2), - title: Text(t.payMethodAlipay, style: PangolinText.body.copyWith(color: c.fg1)), - trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), - onTap: () => Navigator.of(sheetCtx).pop('alipay'), - ), - ListTile( - leading: Icon(PangolinIcons.globe, color: c.fg2), - title: Text(t.payMethodCrypto, style: PangolinText.body.copyWith(color: c.fg1)), - trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), - onTap: () => Navigator.of(sheetCtx).pop('crypto'), - ), - const SizedBox(height: 12), - ]), - ), - ); - if (method == null || !context.mounted) return; - await ref.read(paymentFlowProvider.notifier).start(item, method); - if (!context.mounted) return; + Future _buy(PayCatalogItem item) async { + await ref.read(paymentFlowProvider.notifier).start(item, _channel.method); + if (!mounted) return; if (ref.read(paymentFlowProvider).phase == PaymentPhase.awaitingPayment) { - onOrderCreated?.call(); + widget.onOrderCreated?.call(); } } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final c = context.pangolin; final catalog = ref.watch(payCatalogProvider); @@ -93,25 +65,42 @@ class PurchaseScreen extends ConsumerWidget { data: (items) => ListView( padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ + // 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新 + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Text(t.payChannel.toUpperCase(), + style: PangolinText.caption.copyWith( + color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)), + ), + SegSwitch( + options: [ + (icon: PangolinIcons.globe, label: t.payChannelUsdt), + (icon: PangolinIcons.creditCard, label: t.payChannelCny), + ], + selectedIndex: _channel == PayChannel.usdt ? 0 : 1, + onChanged: (i) => setState( + () => _channel = i == 0 ? PayChannel.usdt : PayChannel.cny), + ), + const SizedBox(height: 18), for (final it in items) Padding( padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0), child: PlanCard( name: _name(it.sku), - price: it.priceLabel(), - period: _period(it.sku), + price: it.priceLabel(_channel), + period: '${it.perMonthLabel(_channel)}${t.perMonthSuffix}', features: t.featsPro, ctaLabel: t.buyNow, featured: it.sku == 'pro_year', popularLabel: it.sku == 'pro_year' ? t.mostPopular : null, - onPressed: () => _choose(context, ref, it), + onPressed: () => _buy(it), ), ), ], ), ); - if (embedded) return body; + if (widget.embedded) return body; return Scaffold( backgroundColor: c.bg, body: SafeArea( @@ -120,7 +109,7 @@ class PurchaseScreen extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), child: Row(children: [ IconButton( - onPressed: onBack ?? () => Navigator.of(context).maybePop(), + onPressed: widget.onBack ?? () => Navigator.of(context).maybePop(), icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), ), Text(t.purchaseTitle, diff --git a/client/lib/services/payment_api.dart b/client/lib/services/payment_api.dart index 32a7b93..5fb5249 100644 --- a/client/lib/services/payment_api.dart +++ b/client/lib/services/payment_api.dart @@ -26,6 +26,13 @@ class PaymentApi { Future orderStatus(String orderNo) async => PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo')); + /// 订单列表(倒序;纯本地台账,不逐单回查 pay 上游)。 + Future> listOrders() async { + final body = await _c.getJson('/v1/pay/orders'); + final items = (body['orders'] as List?) ?? const []; + return [for (final it in items) PayOrderSummary.fromJson(it as Map)]; + } + Future retry(String orderNo, {required String method, Map? metadata}) async => PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', { 'method': method, diff --git a/client/lib/shell/desktop_shell.dart b/client/lib/shell/desktop_shell.dart index effbd59..5dafbe6 100644 --- a/client/lib/shell/desktop_shell.dart +++ b/client/lib/shell/desktop_shell.dart @@ -9,6 +9,7 @@ import '../screens/account_page.dart'; import '../screens/connect_page.dart'; import '../screens/contact_page.dart'; import '../screens/nodes_page.dart'; +import '../screens/orders_page.dart'; import '../screens/payment_page.dart'; import '../screens/purchase_page.dart'; import '../screens/settings_page.dart'; @@ -33,7 +34,7 @@ class DesktopShell extends ConsumerWidget { final items = [ (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), - (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats), (icon: PangolinIcons.settings, label: t.settingsTitle, view: NavView.settings), (icon: PangolinIcons.messageCircle, label: t.contactTitle, view: NavView.contact), ]; @@ -50,6 +51,7 @@ class DesktopShell extends ConsumerWidget { NavView.devices: t.myDevices, NavView.purchase: t.purchaseTitle, NavView.payment: t.paymentTitle, + NavView.orders: t.ordersTitle, }; void go(NavView v) => ref.read(navViewProvider.notifier).state = v; @@ -80,6 +82,8 @@ class DesktopShell extends ConsumerWidget { return PurchaseScreen(t: t, embedded: true, onOrderCreated: () => go(NavView.payment)); case NavView.payment: return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account)); + case NavView.orders: + return OrdersScreen(t: t, embedded: true); } } diff --git a/client/lib/shell/tablet_shell.dart b/client/lib/shell/tablet_shell.dart index 921378b..06bffa1 100644 --- a/client/lib/shell/tablet_shell.dart +++ b/client/lib/shell/tablet_shell.dart @@ -31,7 +31,7 @@ class TabletShell extends ConsumerWidget { final items = [ (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), - (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats), (icon: PangolinIcons.settings, label: t.settingsTitle, view: NavView.settings), (icon: PangolinIcons.messageCircle, label: t.contactTitle, view: NavView.contact), ]; diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 07a38f6..09a133b 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -41,7 +41,6 @@ class ConnectionState { required this.phase, this.elapsed = Duration.zero, this.error, - this.freeCountdown, }); final VpnPhase phase; @@ -50,15 +49,10 @@ class ConnectionState { /// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。 final String? error; - /// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。 - /// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。 - final Duration? freeCountdown; - - ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) => + ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) => ConnectionState( phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed, - freeCountdown: freeCountdown ?? this.freeCountdown, ); @override @@ -66,11 +60,10 @@ class ConnectionState { other is ConnectionState && other.phase == phase && other.elapsed == elapsed && - other.error == error && - other.freeCountdown == freeCountdown; + other.error == error; @override - int get hashCode => Object.hash(phase, elapsed, error, freeCountdown); + int get hashCode => Object.hash(phase, elapsed, error); } // ── 连通看门狗 ───────────────────────────────────────────────────── @@ -503,17 +496,14 @@ class ConnectionController extends StateNotifier { if (!mounted || state.phase != VpnPhase.on || at == null) return; final elapsed = _now().difference(at); - Duration? countdown; + // 免费版:到点(锁定额度 − 已用秒 ≤ 0)即自动切断(enforcement,非展示; + // 额度 UI 已移除,不再推算展示用倒计时)。null = 会员/额度不限,不切。 final capSec = _freeRemainingSec; - if (capSec != null) { - final leftSec = capSec - elapsed.inSeconds; - if (leftSec <= 0) { - unawaited(_onFreeQuotaExhausted()); - return; - } - countdown = Duration(seconds: leftSec); + if (capSec != null && capSec - elapsed.inSeconds <= 0) { + unawaited(_onFreeQuotaExhausted()); + return; } - state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown); + state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed); } /// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。 diff --git a/client/lib/state/navigation_provider.dart b/client/lib/state/navigation_provider.dart index 9ea0451..ce9eff0 100644 --- a/client/lib/state/navigation_provider.dart +++ b/client/lib/state/navigation_provider.dart @@ -4,10 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 一级视图 + 账户子页。 /// desktop 侧栏暴露 6 个一级项(connect..settings);mobile/tablet 取前 4 项, /// contact/settings 在 mobile 走账户子页。plans/redeem 是 account 的下钻页。 -enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment } +enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment, orders } /// 账户下钻子页(desktop 在内容区切换、顶栏带返回;mobile/tablet 走全屏 push)。 -const Set kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment}; +const Set kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment, NavView.orders}; /// 当前视图。 final navViewProvider = StateProvider((ref) => NavView.connect); diff --git a/client/lib/state/orders_provider.dart b/client/lib/state/orders_provider.dart new file mode 100644 index 0000000..51a5aee --- /dev/null +++ b/client/lib/state/orders_provider.dart @@ -0,0 +1,19 @@ +// orders_provider.dart — 订单台账数据(列表 + 单条详情)。 +// 纯本地台账读取(server 不逐单回查 pay 上游);autoDispose 让每次进入订单页 +// 拿到新鲜数据,刷新 = ref.invalidate(...)。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/payment.dart'; +import 'payment_provider.dart'; + +/// 订单列表(server 倒序返回)。刷新:`ref.invalidate(ordersProvider)`。 +final ordersProvider = FutureProvider.autoDispose>( + (ref) => ref.watch(paymentApiProvider).listOrders(), +); + +/// 单条订单富详情(含 summary 台账字段 + activated/expiresAt)。 +/// 刷新:`ref.invalidate(orderDetailProvider(orderNo))`。 +final orderDetailProvider = + FutureProvider.autoDispose.family( + (ref, orderNo) => ref.watch(paymentApiProvider).orderStatus(orderNo), +); diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index 35c3672..87b4a70 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -253,7 +253,7 @@ class DevicesScreen extends ConsumerWidget { iconColor: c.fg3, items: [ AdaptiveMenuItem( - icon: PangolinIcons.edit, + icon: PangolinIcons.pencil, label: t.devRename, onSelected: () async { final newName = await _renameDialog(context, c, d); @@ -264,7 +264,7 @@ class DevicesScreen extends ConsumerWidget { ), if (!isLocal) ...[ AdaptiveMenuItem(icon: PangolinIcons.logOut, label: t.devForceLogout, onSelected: () => confirmAction(isClear: false)), - AdaptiveMenuItem(icon: PangolinIcons.trash, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)), + AdaptiveMenuItem(icon: PangolinIcons.trash2, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)), ], ], ); @@ -374,10 +374,9 @@ class _RedeemScreenState extends ConsumerState { final c = context.pangolin; final t = widget.t; final channels = [ - (icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true), - (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false), - (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), - (icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false), + (icon: PangolinIcons.send, name: 'Telegram', sub: '@pangolin_app', accent: true), + (icon: PangolinIcons.mail, name: t.chEmail, sub: 'support@yanmeiai.com', accent: false), + (icon: PangolinIcons.globe, name: t.contactWebsite, sub: 'pangolin.yanmeiai.com', accent: false), ]; return _SubScaffold( title: t.redeemTitle, @@ -459,10 +458,9 @@ class ContactScreen extends StatelessWidget { Widget build(BuildContext context) { final c = context.pangolin; final channels = [ - (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true), - (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), - (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false), - (icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false), + (icon: PangolinIcons.send, name: 'Telegram', sub: '@pangolin_app', accent: true), + (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@yanmeiai.com', accent: false), + (icon: PangolinIcons.globe, name: t.contactWebsite, sub: 'pangolin.yanmeiai.com', accent: false), ]; return _SubScaffold( title: t.contactTitle, diff --git a/client/lib/widgets/bottom_tab_bar.dart b/client/lib/widgets/bottom_tab_bar.dart index 7a80bdc..ebe354a 100644 --- a/client/lib/widgets/bottom_tab_bar.dart +++ b/client/lib/widgets/bottom_tab_bar.dart @@ -20,7 +20,7 @@ class BottomTabBar extends ConsumerWidget { final items = <({IconData icon, String label, NavView view})>[ (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), - (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats), (icon: PangolinIcons.user, label: t.tabMe, view: NavView.account), ]; return Container( diff --git a/client/lib/widgets/connect_button.dart b/client/lib/widgets/connect_button.dart index 20abc9d..468e210 100644 --- a/client/lib/widgets/connect_button.dart +++ b/client/lib/widgets/connect_button.dart @@ -24,19 +24,11 @@ class ConnectButton extends StatefulWidget { required this.secureLabel, this.elapsed = Duration.zero, this.size = 208, - this.enabled = true, - this.onDisabledTap, }); final VpnPhase phase; final VoidCallback onTap; - /// 是否可点。免费额度耗尽时置 false → 灰化不可连,点击走 onDisabledTap。 - final bool enabled; - - /// 灰化态被点击的回调(如弹看广告加时/升级)。enabled=false 时生效。 - final VoidCallback? onDisabledTap; - /// off 态圆内文字(如「点击连接」/「CONNECT」)。 final String offLabel; @@ -63,26 +55,18 @@ class _ConnectButtonState extends State with SingleTickerProvider Widget build(BuildContext context) { final c = context.pangolin; final s = widget.phase; - // 免费额度耗尽:off 态灰化不可连(锁图标 + 柔和阴影),点击走 onDisabledTap。 - final disabled = !widget.enabled && s == VpnPhase.off; - final Color fill = disabled - ? c.bgSubtle - : switch (s) { - VpnPhase.off => c.bgSubtle, - VpnPhase.connecting => c.accent, - VpnPhase.on => c.success, - }; - final Color fg = disabled - ? c.fg3 - : (s == VpnPhase.off ? c.accent : PangolinColors.white); - final IconData icon = disabled - ? PangolinIcons.lock - : switch (s) { - VpnPhase.off => PangolinIcons.power, - VpnPhase.connecting => PangolinIcons.loader, - VpnPhase.on => PangolinIcons.shieldCheck, - }; + final Color fill = switch (s) { + VpnPhase.off => c.bgSubtle, + VpnPhase.connecting => c.accent, + VpnPhase.on => c.success, + }; + final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white; + final IconData icon = switch (s) { + VpnPhase.off => PangolinIcons.power, + VpnPhase.connecting => PangolinIcons.loaderCircle, + VpnPhase.on => PangolinIcons.shieldCheck, + }; // off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。 final List glow = s == VpnPhase.off @@ -100,7 +84,7 @@ class _ConnectButtonState extends State with SingleTickerProvider button: true, label: widget.offLabel, child: GestureDetector( - onTap: disabled ? widget.onDisabledTap : widget.onTap, + onTap: widget.onTap, child: AnimatedContainer( duration: PangolinMotion.slow, curve: PangolinMotion.easeOut, diff --git a/client/lib/widgets/pangolin_icons.dart b/client/lib/widgets/pangolin_icons.dart index b4d70fc..5fcd8ba 100644 --- a/client/lib/widgets/pangolin_icons.dart +++ b/client/lib/widgets/pangolin_icons.dart @@ -1,80 +1,66 @@ -// pangolin_icons.dart — Lucide 图标映射表 -import 'package:flutter/widgets.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +// GENERATED by design/codegen/gen_flutter_icons.mjs —— 勿手改;图标真源=design/prototype/icons.js +// 重新生成:node design/codegen/gen_flutter_icons.mjs +import 'package:lucide_icons_flutter/lucide_icons.dart'; class PangolinIcons { PangolinIcons._(); - // ── 连接 / 状态 ── - static const power = LucideIcons.power; - static const shieldCheck = LucideIcons.shieldCheck; - static const shield = LucideIcons.shield; - static const loader = LucideIcons.loader; - static const zap = LucideIcons.zap; - static const globe = LucideIcons.globe; - static const clock = LucideIcons.clock; - static const playCircle = LucideIcons.playCircle; - static const wifi = LucideIcons.wifi; - - // ── 导航 / 操作 ── - static const settings = LucideIcons.settings; - static const eye = LucideIcons.eye; - static const eyeOff = LucideIcons.eyeOff; - static const user = LucideIcons.user; - static const search = LucideIcons.search; - static const check = LucideIcons.check; - static const checkCircle = LucideIcons.checkCircle; - static const x = LucideIcons.x; - static const chevronRight = LucideIcons.chevronRight; - static const chevronDown = LucideIcons.chevronDown; - static const arrowLeft = LucideIcons.arrowLeft; - static const arrowUp = LucideIcons.arrowUp; - static const arrowDown = LucideIcons.arrowDown; - static const refreshCw = LucideIcons.refreshCw; - static const compass = LucideIcons.compass; - static const barChart = LucideIcons.barChart; - static const mapPin = LucideIcons.mapPin; - - // ── 主题 ── - static const moon = LucideIcons.moon; - static const sun = LucideIcons.sun; - - // ── 账户 / 套餐 / 兑换 ── - static const crown = LucideIcons.crown; - static const ticket = LucideIcons.ticket; - static const creditCard = LucideIcons.creditCard; - static const shoppingBag = LucideIcons.shoppingBag; - static const mail = LucideIcons.mail; - static const lock = LucideIcons.lock; - static const logOut = LucideIcons.logOut; - static const send = LucideIcons.send; - static const messageCircle= LucideIcons.messageCircle; + static const layoutDashboard = LucideIcons.layoutDashboard; + static const link = LucideIcons.link; + static const ticket = LucideIcons.ticket; + static const users = LucideIcons.users; + static const copy = LucideIcons.copy; + static const refreshCw = LucideIcons.refreshCw; + static const qrCode = LucideIcons.qrCode; + static const download = LucideIcons.download; + static const check = LucideIcons.check; + static const checkCircle = LucideIcons.checkCircle; + static const zap = LucideIcons.zap; + static const clock = LucideIcons.clock; + static const shieldCheck = LucideIcons.shieldCheck; + static const shield = LucideIcons.shield; + static const crown = LucideIcons.crown; + static const creditCard = LucideIcons.creditCard; + static const send = LucideIcons.send; + static const messageCircle = LucideIcons.messageCircle; + static const mail = LucideIcons.mail; + static const shoppingBag = LucideIcons.shoppingBag; + static const logOut = LucideIcons.logOut; static const externalLink = LucideIcons.externalLink; - - // ── 设备 ── - static const laptop = LucideIcons.laptop; - static const smartphone = LucideIcons.smartphone; - static const monitorSmartphone = LucideIcons.smartphone; // 回退 + static const gift = LucideIcons.gift; + static const smartphone = LucideIcons.smartphone; + static const lock = LucideIcons.lock; + static const chevronRight = LucideIcons.chevronRight; + static const arrowDown = LucideIcons.arrowDown; + static const settings = LucideIcons.settings; + static const trash2 = LucideIcons.trash2; + static const monitor = LucideIcons.monitor; + static const key = LucideIcons.key; + static const sun = LucideIcons.sun; + static const moon = LucideIcons.moon; + static const alertTriangle = LucideIcons.alertTriangle; + static const x = LucideIcons.x; + static const power = LucideIcons.power; + static const loaderCircle = LucideIcons.loaderCircle; + static const globe = LucideIcons.globe; + static const user = LucideIcons.user; + static const search = LucideIcons.search; + static const chevronDown = LucideIcons.chevronDown; + static const arrowLeft = LucideIcons.arrowLeft; + static const arrowUp = LucideIcons.arrowUp; + static const arrowRight = LucideIcons.arrowRight; + static const compass = LucideIcons.compass; + static const chartNoAxesColumn = LucideIcons.chartNoAxesColumn; + static const home = LucideIcons.home; + static const mapPin = LucideIcons.mapPin; + static const laptop = LucideIcons.laptop; + static const monitorSmartphone = LucideIcons.monitorSmartphone; + static const playCircle = LucideIcons.playCircle; + static const wifi = LucideIcons.wifi; + static const eye = LucideIcons.eye; + static const eyeOff = LucideIcons.eyeOff; static const moreVertical = LucideIcons.moreVertical; - static const trash = LucideIcons.trash2; - static const alertTriangle= LucideIcons.alertTriangle; - static const edit = LucideIcons.pencil; - - // ── 支付 ── - static const copy = LucideIcons.copy; - - static IconData? byName(String name) => _byName[name]; - - static const Map _byName = { - 'power': power, 'shield-check': shieldCheck, 'shield': shield, 'loader-circle': loader, - 'zap': zap, 'globe': globe, 'settings': settings, 'user': user, 'search': search, - 'check': check, 'check-circle': checkCircle, 'x': x, 'chevron-right': chevronRight, - 'chevron-down': chevronDown, 'arrow-left': arrowLeft, 'arrow-up': arrowUp, 'arrow-down': arrowDown, - 'refresh-cw': refreshCw, 'compass': compass, 'chart-no-axes-column': barChart, 'chart': barChart, - 'map-pin': mapPin, 'moon': moon, 'sun': sun, 'crown': crown, 'ticket': ticket, - 'credit-card': creditCard, 'shopping-bag': shoppingBag, 'mail': mail, 'lock': lock, - 'log-out': logOut, 'send': send, 'message-circle': messageCircle, 'external-link': externalLink, - 'laptop': laptop, 'smartphone': smartphone, 'monitor-smartphone': monitorSmartphone, - 'copy': copy, - }; + static const pencil = LucideIcons.pencil; + static const terminal = LucideIcons.terminal; + static const menu = LucideIcons.menu; } diff --git a/client/lib/widgets/seg_switch.dart b/client/lib/widgets/seg_switch.dart new file mode 100644 index 0000000..36924da --- /dev/null +++ b/client/lib/widgets/seg_switch.dart @@ -0,0 +1,80 @@ +// seg_switch.dart — 分段开关(镜像 design/prototype atoms .segswitch)。 +// 一排等宽选项,每项 leading 图标 + 文字;选中态 surface 底 + 柔和阴影 + fg1, +// 未选 fg2;容器 bg-subtle + border + full 圆角。渠道/币种/主题等二选一场景通用。 +import 'package:flutter/material.dart'; +import '../pangolin_theme.dart'; + +/// 单个分段项:图标 + 文案。 +typedef SegOption = ({IconData icon, String label}); + +class SegSwitch extends StatelessWidget { + const SegSwitch({ + super.key, + required this.options, + required this.selectedIndex, + required this.onChanged, + }); + + final List options; + final int selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Container( + // .segswitch:bg-subtle + border + full 圆角 + space-1 内边距/间隙 + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: c.bgSubtle, + border: Border.all(color: c.border), + borderRadius: BorderRadius.circular(PangolinRadius.full), + ), + child: Row( + children: [ + for (var i = 0; i < options.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + Expanded(child: _opt(c, i, options[i])), + ], + ], + ), + ); + } + + Widget _opt(PangolinScheme c, int i, SegOption o) { + final active = i == selectedIndex; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: active ? null : () => onChanged(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + // .segswitch-opt.is-active:surface 底 + shadow-sm;未选透明 + color: active ? c.surface : Colors.transparent, + borderRadius: BorderRadius.circular(PangolinRadius.full), + boxShadow: active ? PangolinShadow.sm : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(o.icon, size: 16, color: active ? c.fg1 : c.fg2), + const SizedBox(width: 8), + Flexible( + child: Text( + o.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith( + color: active ? c.fg1 : c.fg2, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 2bd6c5b..46763aa 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: sdk: flutter flutter_svg: ^2.0.10 # 加载 assets/ 下的矢量 logo country_flags: ^3.1.0 # 节点国旗(SVG,全平台含 Windows;按 region 码渲染) - lucide_icons: ^0.257.0 # Lucide 细线条图标 + lucide_icons_flutter: ^3.1.14 # Lucide 细线条图标(自带字体 assets/lucide.ttf) google_fonts: ^6.2.1 # 运行时加载 Sora / Manrope / Noto Sans SC / JetBrains Mono flutter_riverpod: ^2.5.1 # 状态层(连接状态机 / 免费额度 / 节点选择 / 语言 / 主题) http: ^1.2.1 # 控制面 HTTP 客户端(connect API) @@ -27,7 +27,6 @@ dependencies: launch_at_startup: ^0.5.1 tray_manager: ^0.5.3 window_manager: ^0.5.1 - url_launcher: ^6.3.0 # 支付 redirect(如支付宝)拉起外部浏览器/App dev_dependencies: flutter_test: @@ -44,12 +43,6 @@ flutter_launcher_icons: remove_alpha_ios: true image_path: "assets/app-icon-ios-1024.png" -# lucide_icons-0.257.0 defines LucideIconData extends IconData, which breaks -# when Flutter SDK marks IconData as final. Override with a local patched copy -# that replaces the subclass with direct IconData(...) const constructors. -dependency_overrides: - lucide_icons: - path: packages/lucide_icons_patched flutter: uses-material-design: true diff --git a/client/test/golden/goldens/auth_login_dark.png b/client/test/golden/goldens/auth_login_dark.png index 6e6af25..0bfd829 100644 Binary files a/client/test/golden/goldens/auth_login_dark.png and b/client/test/golden/goldens/auth_login_dark.png differ diff --git a/client/test/golden/goldens/auth_login_light.png b/client/test/golden/goldens/auth_login_light.png index 937e511..f82f4b0 100644 Binary files a/client/test/golden/goldens/auth_login_light.png and b/client/test/golden/goldens/auth_login_light.png differ diff --git a/client/test/golden/goldens/connect_connecting_dark.png b/client/test/golden/goldens/connect_connecting_dark.png index 373b3a9..d6bbcd0 100644 Binary files a/client/test/golden/goldens/connect_connecting_dark.png and b/client/test/golden/goldens/connect_connecting_dark.png differ diff --git a/client/test/golden/goldens/connect_connecting_light.png b/client/test/golden/goldens/connect_connecting_light.png index 05d7730..8cf161f 100644 Binary files a/client/test/golden/goldens/connect_connecting_light.png and b/client/test/golden/goldens/connect_connecting_light.png differ diff --git a/client/test/golden/goldens/connect_off_dark.png b/client/test/golden/goldens/connect_off_dark.png index cdb3043..615300d 100644 Binary files a/client/test/golden/goldens/connect_off_dark.png and b/client/test/golden/goldens/connect_off_dark.png differ diff --git a/client/test/golden/goldens/connect_off_light.png b/client/test/golden/goldens/connect_off_light.png index 6190ca8..cf758aa 100644 Binary files a/client/test/golden/goldens/connect_off_light.png and b/client/test/golden/goldens/connect_off_light.png differ diff --git a/client/test/golden/goldens/connect_on_dark.png b/client/test/golden/goldens/connect_on_dark.png index 942fa71..4db8f77 100644 Binary files a/client/test/golden/goldens/connect_on_dark.png and b/client/test/golden/goldens/connect_on_dark.png differ diff --git a/client/test/golden/goldens/connect_on_light.png b/client/test/golden/goldens/connect_on_light.png index 5035931..b1446d1 100644 Binary files a/client/test/golden/goldens/connect_on_light.png and b/client/test/golden/goldens/connect_on_light.png differ diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png index ba0eca3..87badbc 100644 Binary files a/client/test/golden/goldens/desktop_account.png and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/desktop_contact.png b/client/test/golden/goldens/desktop_contact.png index 1d67bc9..f355fd8 100644 Binary files a/client/test/golden/goldens/desktop_contact.png and b/client/test/golden/goldens/desktop_contact.png differ diff --git a/client/test/golden/goldens/desktop_devices.png b/client/test/golden/goldens/desktop_devices.png index fa38eb0..7675cf5 100644 Binary files a/client/test/golden/goldens/desktop_devices.png and b/client/test/golden/goldens/desktop_devices.png differ diff --git a/client/test/golden/goldens/desktop_plans.png b/client/test/golden/goldens/desktop_plans.png index 4c0a4fa..1f1db65 100644 Binary files a/client/test/golden/goldens/desktop_plans.png and b/client/test/golden/goldens/desktop_plans.png differ diff --git a/client/test/golden/goldens/desktop_redeem.png b/client/test/golden/goldens/desktop_redeem.png index 0f2fe8c..e6596b8 100644 Binary files a/client/test/golden/goldens/desktop_redeem.png and b/client/test/golden/goldens/desktop_redeem.png differ diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png index 68addd3..5543fc3 100644 Binary files a/client/test/golden/goldens/desktop_servers.png and b/client/test/golden/goldens/desktop_servers.png differ diff --git a/client/test/golden/goldens/desktop_settings.png b/client/test/golden/goldens/desktop_settings.png index e3d5c7a..58debed 100644 Binary files a/client/test/golden/goldens/desktop_settings.png and b/client/test/golden/goldens/desktop_settings.png differ diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index 427ec83..ccf0ece 100644 Binary files a/client/test/golden/goldens/desktop_stats.png and b/client/test/golden/goldens/desktop_stats.png differ diff --git a/client/test/golden/goldens/quota_exhausted_dark.png b/client/test/golden/goldens/quota_exhausted_dark.png index 6e082bb..d848ce3 100644 Binary files a/client/test/golden/goldens/quota_exhausted_dark.png and b/client/test/golden/goldens/quota_exhausted_dark.png differ diff --git a/client/test/golden/goldens/quota_exhausted_light.png b/client/test/golden/goldens/quota_exhausted_light.png index d987372..94461c9 100644 Binary files a/client/test/golden/goldens/quota_exhausted_light.png and b/client/test/golden/goldens/quota_exhausted_light.png differ diff --git a/client/test/golden/goldens/quota_low_dark.png b/client/test/golden/goldens/quota_low_dark.png index 0179d15..cce8d2d 100644 Binary files a/client/test/golden/goldens/quota_low_dark.png and b/client/test/golden/goldens/quota_low_dark.png differ diff --git a/client/test/golden/goldens/quota_low_light.png b/client/test/golden/goldens/quota_low_light.png index e5b58c7..1d02939 100644 Binary files a/client/test/golden/goldens/quota_low_light.png and b/client/test/golden/goldens/quota_low_light.png differ diff --git a/client/test/golden/goldens/smart_card_dark.png b/client/test/golden/goldens/smart_card_dark.png index a0ddccb..b6e2ea2 100644 Binary files a/client/test/golden/goldens/smart_card_dark.png and b/client/test/golden/goldens/smart_card_dark.png differ diff --git a/client/test/golden/goldens/smart_card_light.png b/client/test/golden/goldens/smart_card_light.png index 48649cb..62051c3 100644 Binary files a/client/test/golden/goldens/smart_card_light.png and b/client/test/golden/goldens/smart_card_light.png differ diff --git a/client/test/golden/goldens/tablet_account_dark_zh.png b/client/test/golden/goldens/tablet_account_dark_zh.png index 16d848e..0b9f582 100644 Binary files a/client/test/golden/goldens/tablet_account_dark_zh.png and b/client/test/golden/goldens/tablet_account_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_account_light_en.png b/client/test/golden/goldens/tablet_account_light_en.png index d6bf07d..d069aa6 100644 Binary files a/client/test/golden/goldens/tablet_account_light_en.png and b/client/test/golden/goldens/tablet_account_light_en.png differ diff --git a/client/test/golden/goldens/tablet_account_light_zh.png b/client/test/golden/goldens/tablet_account_light_zh.png index c7feea9..0d010b6 100644 Binary files a/client/test/golden/goldens/tablet_account_light_zh.png and b/client/test/golden/goldens/tablet_account_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_dark_zh.png b/client/test/golden/goldens/tablet_connect_dark_zh.png index 31d6cfa..ba1e346 100644 Binary files a/client/test/golden/goldens/tablet_connect_dark_zh.png and b/client/test/golden/goldens/tablet_connect_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_en.png b/client/test/golden/goldens/tablet_connect_light_en.png index a5d56c1..7075043 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_en.png and b/client/test/golden/goldens/tablet_connect_light_en.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_zh.png b/client/test/golden/goldens/tablet_connect_light_zh.png index 9ba545c..2ca7ca8 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_zh.png and b/client/test/golden/goldens/tablet_connect_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_dark_zh.png b/client/test/golden/goldens/tablet_servers_dark_zh.png index 3570bec..6b7f6d3 100644 Binary files a/client/test/golden/goldens/tablet_servers_dark_zh.png and b/client/test/golden/goldens/tablet_servers_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_en.png b/client/test/golden/goldens/tablet_servers_light_en.png index 664c5d9..fbef899 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_en.png and b/client/test/golden/goldens/tablet_servers_light_en.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_zh.png b/client/test/golden/goldens/tablet_servers_light_zh.png index 3317bf1..e77dd61 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_zh.png and b/client/test/golden/goldens/tablet_servers_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_dark_zh.png b/client/test/golden/goldens/tablet_stats_dark_zh.png index 9b2a882..ae5d3a2 100644 Binary files a/client/test/golden/goldens/tablet_stats_dark_zh.png and b/client/test/golden/goldens/tablet_stats_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_en.png b/client/test/golden/goldens/tablet_stats_light_en.png index 0631b9f..e2bdbf0 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_en.png and b/client/test/golden/goldens/tablet_stats_light_en.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_zh.png b/client/test/golden/goldens/tablet_stats_light_zh.png index e584957..894dc32 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_zh.png and b/client/test/golden/goldens/tablet_stats_light_zh.png differ diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index a162c7c..2c89aea 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -330,8 +330,9 @@ void main() { await tester.pump(); }); - // 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。 - testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async { + // 免费版 10 分钟卡控(enforcement):连接期锁定额度,墙上时钟越过额度即自动切断 + + // 本地置耗尽(#21;额度倒计时 UI 已移除,只保留到点即切的强制行为)。 + testWidgets('免费额度到点 → 自动切断 + 置耗尽', (tester) async { final bridge = _FakeBridge(); var fake = DateTime(2026, 7, 1, 12); final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟 @@ -341,7 +342,7 @@ void main() { c.read(nodesProvider); await tester.pump(); - // 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。 + // 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 额度(fake api 抛错不影响锁定)。 expect(c.read(quotaProvider).remainingMinutes, 10); c.read(connectionProvider.notifier).toggle(); await tester.pump(); @@ -349,7 +350,6 @@ void main() { await tester.pump(); await tester.pump(); expect(c.read(connectionProvider).phase, VpnPhase.on); - expect(c.read(connectionProvider).freeCountdown, isNotNull, reason: '连接期应有倒计时'); // 快进墙上时钟越过 10 分钟 → 下一个 1s tick 触发 _refreshElapsed → 切断。 fake = fake.add(const Duration(seconds: 601)); diff --git a/client/test/unit/payment_api_test.dart b/client/test/unit/payment_api_test.dart index 29f9b69..2a0da99 100644 --- a/client/test/unit/payment_api_test.dart +++ b/client/test/unit/payment_api_test.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/models/payment.dart'; import 'package:pangolin_vpn/services/payment_api.dart'; ApiClient _client(MockClient mock) => ApiClient( @@ -25,7 +26,7 @@ void main() { final items = await api.catalog(); expect(items.length, 3); expect(items.first.sku, 'pro_month'); - expect(items.first.priceLabel(), '¥29.99'); + expect(items.first.priceLabel(PayChannel.cny), '¥29.99'); }); test('createOrder 只传 sku+method+metadata,解析 session', () async { diff --git a/client/test/unit/payment_flow_test.dart b/client/test/unit/payment_flow_test.dart index 6ebeb31..b151449 100644 --- a/client/test/unit/payment_flow_test.dart +++ b/client/test/unit/payment_flow_test.dart @@ -15,6 +15,9 @@ class _FakePaymentApi implements PaymentApi { @override Future> catalog() async => const []; + @override + Future> listOrders() async => const []; + @override Future createOrder({required String sku, required String method, Map? metadata}) async { createCalls++; diff --git a/client/test/widget/connect_button_test.dart b/client/test/widget/connect_button_test.dart index 89f27d7..306a1d7 100644 --- a/client/test/widget/connect_button_test.dart +++ b/client/test/widget/connect_button_test.dart @@ -30,7 +30,7 @@ void main() { testWidgets('connecting 态:loader 图标', (tester) async { await tester.pumpWidget(button(VpnPhase.connecting)); await tester.pump(const Duration(milliseconds: 100)); - expect(find.byIcon(PangolinIcons.loader), findsOneWidget); + expect(find.byIcon(PangolinIcons.loaderCircle), findsOneWidget); }); testWidgets('on 态:盾勾 + 计时 + 已加密', (tester) async { diff --git a/client/test/widget/orders_page_test.dart b/client/test/widget/orders_page_test.dart new file mode 100644 index 0000000..f9a036a --- /dev/null +++ b/client/test/widget/orders_page_test.dart @@ -0,0 +1,85 @@ +// orders_page_test.dart — 订单列表/详情页行为测试(不打网络,provider 注入)。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/models/payment.dart'; +import 'package:pangolin_vpn/screens/orders_page.dart'; +import 'package:pangolin_vpn/state/orders_provider.dart'; +import 'package:pangolin_vpn/widgets/status_pill.dart'; + +import '../helpers/harness.dart'; + +final t = StringsZh(); + +final _orders = [ + PayOrderSummary( + orderNo: 'o-year', sku: 'pro_year', plan: 'pro', days: 366, method: 'crypto', + status: 'paid', amountMinor: 34990000, currency: 'USDT', + createdAt: DateTime.utc(2026, 7, 10), paidAt: DateTime.utc(2026, 7, 10)), + PayOrderSummary( + orderNo: 'o-month', sku: 'pro_month', plan: 'pro', days: 31, method: 'alipay', + status: 'created', amountMinor: 2999, currency: 'CNY', + createdAt: DateTime.utc(2026, 7, 11)), + PayOrderSummary( + orderNo: 'o-q', sku: 'pro_quarter', plan: 'pro', days: 92, method: 'nezha', + status: 'canceled', amountMinor: 6888, currency: 'CNY', + createdAt: DateTime.utc(2026, 7, 9)), +]; + +void main() { + setUpAll(disableGoogleFontsFetching); + + testWidgets('订单列表:三单渲染 + 结算币种金额 + 状态 pill', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrapThemed( + OrdersScreen(t: t, embedded: true), + overrides: [ordersProvider.overrideWith((ref) async => _orders)], + )); + await tester.pumpAndSettle(); + + // 套餐名(逐档) + expect(find.text(t.proYearly), findsOneWidget); + expect(find.text(t.proMonthly), findsOneWidget); + expect(find.text(t.proQuarterly), findsOneWidget); + // 金额按结算币种(USDT $ / CNY ¥),在「金额 · 日期」子行里 + expect(find.textContaining(r'$34.99'), findsOneWidget); + expect(find.textContaining('¥29.99'), findsOneWidget); + // 状态 pill(created→等待付款 / paid→已开通 / canceled→已取消) + expect(find.byType(StatusPill), findsNWidgets(3)); + expect(find.text(t.awaitingPayment), findsOneWidget); + expect(find.text(t.orderStatusPaid), findsOneWidget); + expect(find.text(t.orderStatusCanceled), findsOneWidget); + }); + + testWidgets('订单空态', (tester) async { + await tester.pumpWidget(wrapThemed( + OrdersScreen(t: t, embedded: true), + overrides: [ordersProvider.overrideWith((ref) async => const [])], + )); + await tester.pumpAndSettle(); + expect(find.text(t.ordersEmpty), findsOneWidget); + }); + + testWidgets('点 created 单进详情:显订单号 + 继续支付', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrapThemed( + OrdersScreen(t: t, embedded: true), + overrides: [ + ordersProvider.overrideWith((ref) async => _orders), + orderDetailProvider('o-month').overrideWith((ref) async => PayOrderStatus( + orderNo: 'o-month', payStatus: 'pending', activated: false, summary: _orders[1])), + ], + )); + await tester.pumpAndSettle(); + // 点 created 那单(月付)进详情 + await tester.tap(find.text(t.proMonthly)); + await tester.pumpAndSettle(); + // 详情字段行 + created 态动作「继续支付」 + expect(find.text(t.orderNoLabel), findsOneWidget); + expect(find.text(t.orderContinuePay), findsWidgets); + }); +} diff --git a/client/test/widget/payment_pages_test.dart b/client/test/widget/payment_pages_test.dart index aec726e..edabbe3 100644 --- a/client/test/widget/payment_pages_test.dart +++ b/client/test/widget/payment_pages_test.dart @@ -13,9 +13,9 @@ import 'package:pangolin_vpn/widgets/plan_card.dart'; import '../helpers/harness.dart'; const _items = [ - PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'), - PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'), - PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'), + PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY', priceUsdtMicro: 4990000), + PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY', priceUsdtMicro: 12990000), + PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000), ]; /// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。 @@ -26,6 +26,9 @@ class _PollingFakePaymentApi implements PaymentApi { @override Future> catalog() async => const []; + @override + Future> listOrders() async => const []; + @override Future createOrder({required String sku, required String method, Map? metadata}) async => const PayOrder( @@ -66,9 +69,15 @@ void main() { )); await tester.pumpAndSettle(); expect(find.byType(PlanCard), findsNWidgets(3)); + expect(find.text(t.proQuarterly), findsOneWidget); + // 默认支付渠道 = USDT → 套餐价显 USDT + expect(find.text('\$4.99'), findsOneWidget); + expect(find.text('\$34.99'), findsOneWidget); + // 切到人民币渠道 → 套餐价改显 ¥ + await tester.tap(find.text(t.payChannelCny)); + await tester.pumpAndSettle(); expect(find.text('¥29.99'), findsOneWidget); expect(find.text('¥199.99'), findsOneWidget); - expect(find.text(t.proQuarterly), findsOneWidget); }); testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {