|
|
@@ -45,6 +45,15 @@ interface SubscriptionStatus {
|
|
|
seats: number | null;
|
|
|
usedSeats: number;
|
|
|
balance?: number;
|
|
|
+ /**
|
|
|
+ * `available` and `unavailable` are returned for Feima sessions. Older
|
|
|
+ * deployed Qiwei API modules did not include this field, so a numeric
|
|
|
+ * `balance` remains a supported legacy response while those modules roll
|
|
|
+ * forward. `not_applicable` is used by NewAPI credentials.
|
|
|
+ */
|
|
|
+ balanceState?: 'available' | 'unavailable' | 'not_applicable';
|
|
|
+ pendingCreditAmount?: number;
|
|
|
+ pendingCreditCount?: number;
|
|
|
lastIdempotencyKey?: string | null;
|
|
|
quote: Quote;
|
|
|
}
|
|
|
@@ -229,9 +238,18 @@ interface PendingOrder {
|
|
|
}
|
|
|
</dl>
|
|
|
|
|
|
- @if (subscription()?.balance !== undefined) {
|
|
|
+ @if (hasDisplayableBalance()) {
|
|
|
<div class="balance-line"><span>飞马可用余额</span><strong>¥{{ formatMoney(subscription()!.balance || 0) }}</strong></div>
|
|
|
}
|
|
|
+ @if (subscription()?.balanceState === 'unavailable') {
|
|
|
+ <div class="balance-line balance-unavailable"><span>飞马余额暂时无法读取</span><strong>请刷新后重试</strong></div>
|
|
|
+ }
|
|
|
+ @if (subscription()?.balanceState === 'not_applicable') {
|
|
|
+ <div class="balance-line balance-unavailable"><span>当前授权不支持飞马余额支付</span><strong>请使用微信支付</strong></div>
|
|
|
+ }
|
|
|
+ @if ((subscription()?.pendingCreditCount || 0) > 0) {
|
|
|
+ <div class="balance-line balance-pending"><span>支付到账待核销({{ subscription()!.pendingCreditCount }} 笔)</span><strong>¥{{ formatMoney(subscription()!.pendingCreditAmount || 0) }}</strong></div>
|
|
|
+ }
|
|
|
|
|
|
@if (errorMessage()) { <div class="flow-error">{{ errorMessage() }}</div> }
|
|
|
@if (quote()!.allowed === false) { <div class="flow-error">{{ quote()!.reason || '当前订阅暂不支持该席位变更' }}</div> }
|
|
|
@@ -329,7 +347,7 @@ interface PendingOrder {
|
|
|
.duration-control{margin-top:14px;padding:3px;display:grid;grid-template-columns:repeat(4,1fr);gap:3px;border:1px solid #313a40;border-radius:6px;background:#101416}.duration-control button{height:39px;border:0;border-radius:4px;background:transparent;color:#879398;font-size:12px}.duration-control button:disabled{opacity:.45;cursor:not-allowed}.duration-control button.selected{background:#25302c;color:#f1f7f4}
|
|
|
.service-points{padding-top:20px;display:flex;flex-wrap:wrap;gap:13px 22px;border-top:1px solid #2a3338;color:#9ca8ac;font-size:12px}.service-points span{display:flex;align-items:center;gap:7px}.service-points svg{width:15px;color:#55d6a8}.pending-notice{margin-top:16px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:14px;border-left:3px solid #55d6a8;background:#10211b;color:#9fc8b9;font-size:11px;line-height:1.55}.pending-notice span{min-width:0}.pending-notice button{flex:0 0 auto;padding:0;border:0;background:transparent;color:#6de0b1;font:inherit;font-weight:750;text-decoration:underline;text-underline-offset:3px;cursor:pointer}
|
|
|
.order-summary{min-height:500px;padding:28px;border:1px solid #303a40;border-radius:8px;background:#12171a;box-shadow:0 28px 80px rgba(0,0,0,.32)}.summary-heading{display:flex;align-items:center;justify-content:space-between;color:#a4afb2;font-size:12px;font-weight:700}.summary-heading b{padding:3px 7px;border-radius:4px;background:#173426;color:#69dda9;font-size:11px}.loading-state{min-height:360px;display:flex;align-items:center;justify-content:center;gap:9px;color:#829094;font-size:13px}
|
|
|
- .price-main{margin-top:24px;color:#fff;font-size:44px;line-height:1;font-weight:780}.price-main small{margin-right:4px;color:#809095;font-size:16px}.price-caption{margin-top:8px;color:#7f8c90;font-size:12px}.summary-lines{margin:26px 0 0;border-top:1px solid #2a3338}.summary-lines>div{padding:12px 0;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #252e33;font-size:12px}.summary-lines dt{color:#849196}.summary-lines dd{margin:0;color:#dbe3e1;font-weight:650}.summary-lines .saving dd{color:#55d6a8}.balance-line{margin-top:17px;padding:11px 12px;display:flex;align-items:center;justify-content:space-between;border:1px solid #2c493f;border-radius:6px;background:#10211b;color:#8fb3a6;font-size:12px}.balance-line strong{color:#dff8ee}
|
|
|
+ .price-main{margin-top:24px;color:#fff;font-size:44px;line-height:1;font-weight:780}.price-main small{margin-right:4px;color:#809095;font-size:16px}.price-caption{margin-top:8px;color:#7f8c90;font-size:12px}.summary-lines{margin:26px 0 0;border-top:1px solid #2a3338}.summary-lines>div{padding:12px 0;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #252e33;font-size:12px}.summary-lines dt{color:#849196}.summary-lines dd{margin:0;color:#dbe3e1;font-weight:650}.summary-lines .saving dd{color:#55d6a8}.balance-line{margin-top:17px;padding:11px 12px;display:flex;align-items:center;justify-content:space-between;border:1px solid #2c493f;border-radius:6px;background:#10211b;color:#8fb3a6;font-size:12px}.balance-line strong{color:#dff8ee}.balance-unavailable{border-color:#60482f;background:#231a12;color:#d7b17d}.balance-unavailable strong{color:#f0c98f}.balance-pending{border-color:#33515e;background:#11212a;color:#9fc3d1}.balance-pending strong{color:#c9e8f4}
|
|
|
.primary-button,.copy-button{width:100%;min-height:46px;margin-top:18px;border:1px solid #25b983;border-radius:6px;background:#25b983;color:#06140f;font-size:13px;font-weight:800;display:flex;align-items:center;justify-content:center;gap:8px}.primary-button:hover,.copy-button:hover{background:#35ca94}.primary-button:disabled{opacity:.55;cursor:not-allowed}.secondary-button{min-height:40px;margin-top:10px;padding:0 14px;border:1px solid #39444a;border-radius:6px;background:#171d20;color:#c4cdcf;font-size:12px}.secondary-button.full{width:100%}.billing-note{margin:14px 0 0;color:#6f7b7f;font-size:10px;line-height:1.6;text-align:center}.flow-error,.payment-message{margin-top:14px;padding:10px 11px;border-left:3px solid #ef7359;background:#291713;color:#ffbaa9;font-size:12px;line-height:1.5}
|
|
|
.spinner{width:17px;height:17px;display:inline-block;border:2px solid #3c484d;border-top-color:#55d6a8;border-radius:50%;animation:spin .75s linear infinite}.spinner.light{border-color:rgba(0,0,0,.25);border-top-color:#06140f}.spinner.dark{border-color:#dce3e1;border-top-color:#25b983}@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
.modal-backdrop{position:fixed;inset:0;z-index:1000;padding:20px;display:grid;place-items:center;background:rgba(3,5,6,.82);backdrop-filter:blur(8px)}.payment-dialog,.prompt-dialog{position:relative;width:min(460px,100%);padding:28px;border:1px solid #344047;border-radius:8px;background:#12171a;box-shadow:0 35px 110px rgba(0,0,0,.58)}.dialog-close{position:absolute;right:16px;top:16px}.payment-dialog h2{margin:0;text-align:center;font-size:19px}.dialog-amount{margin:8px 0 18px;text-align:center;color:#fff;font-size:32px;font-weight:760}.qr-frame{width:248px;height:248px;margin:0 auto;display:grid;place-items:center;border:1px solid #414d52;border-radius:7px;background:#fff}.qr-frame img{display:block}.dialog-hint{margin:15px 0;color:#9aa6aa;text-align:center;font-size:12px}.cancel-payment-note{margin:9px 0 0;color:#778488;text-align:center;font-size:10px;line-height:1.55}.processing-symbol{width:70px;height:70px;margin:30px auto 14px;display:grid;place-items:center;border-radius:50%;background:#10251d}.processing-symbol .spinner{width:28px;height:28px}
|
|
|
@@ -415,7 +433,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
if (this.pendingOrder()) return `继续原订单 ¥${this.formatMoney(this.pendingOrder()!.amount)}`;
|
|
|
const amount = this.quote()?.amount || 0;
|
|
|
const balance = this.subscription()?.balance;
|
|
|
- if (balance !== undefined && balance >= amount) return `使用余额开通 ¥${this.formatMoney(amount)}`;
|
|
|
+ if (this.canUseBalance() && balance !== undefined && balance >= amount) return `使用余额开通 ¥${this.formatMoney(amount)}`;
|
|
|
return `微信支付 ¥${this.formatMoney(amount)}`;
|
|
|
}
|
|
|
|
|
|
@@ -442,7 +460,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
return;
|
|
|
}
|
|
|
const balance = this.subscription()?.balance;
|
|
|
- if (balance !== undefined && balance >= this.quote()!.amount) {
|
|
|
+ if (this.canUseBalance() && balance !== undefined && balance >= this.quote()!.amount) {
|
|
|
const key = this.createTradeNo('QWB');
|
|
|
const pending: PendingOrder = {
|
|
|
userId: this.userId,
|
|
|
@@ -461,6 +479,22 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
await this.startPayment();
|
|
|
}
|
|
|
|
|
|
+ private canUseBalance(): boolean {
|
|
|
+ const status = this.subscription();
|
|
|
+ return Boolean(
|
|
|
+ status?.balanceState === 'available' &&
|
|
|
+ Number.isFinite(status.balance)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ hasDisplayableBalance(): boolean {
|
|
|
+ const status = this.subscription();
|
|
|
+ return Boolean(
|
|
|
+ status?.balanceState === 'available' &&
|
|
|
+ Number.isFinite(status.balance)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
async resumeActivation(): Promise<void> {
|
|
|
const pending = this.pendingOrder() || this.readPendingOrder();
|
|
|
if (!pending) return;
|
|
|
@@ -498,7 +532,8 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
this.busy.set(true);
|
|
|
this.errorMessage.set('');
|
|
|
try {
|
|
|
- const result = await this.authJson('/api/qiwei/install-prompt', 'POST', {});
|
|
|
+ const runtimePlatform = await this.detectRuntimePlatform();
|
|
|
+ const result = await this.authJson('/api/qiwei/install-prompt', 'POST', { runtimePlatform });
|
|
|
if (result.code !== 200 || !result.data?.prompt) throw new Error(result.mess || '安装提示词生成失败');
|
|
|
this.prompt.set(result.data.prompt);
|
|
|
this.maskedPrompt.set(result.data.maskedPrompt || '专属授权已生成,完整内容仅在复制时出现。');
|
|
|
@@ -511,6 +546,33 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private async detectRuntimePlatform(): Promise<'win-x64' | 'mac-arm64' | 'mac-x64' | 'auto'> {
|
|
|
+ const browserNavigator = navigator as Navigator & {
|
|
|
+ userAgentData?: {
|
|
|
+ platform?: string;
|
|
|
+ getHighEntropyValues?: (hints: string[]) => Promise<{ architecture?: string }>;
|
|
|
+ };
|
|
|
+ };
|
|
|
+ const platform = String(browserNavigator.userAgentData?.platform || browserNavigator.platform || '').toLowerCase();
|
|
|
+ const userAgent = String(browserNavigator.userAgent || '').toLowerCase();
|
|
|
+ let architecture = '';
|
|
|
+ try {
|
|
|
+ const highEntropy = await browserNavigator.userAgentData?.getHighEntropyValues?.(['architecture']);
|
|
|
+ architecture = String(highEntropy?.architecture || '').toLowerCase();
|
|
|
+ } catch {
|
|
|
+ architecture = '';
|
|
|
+ }
|
|
|
+ if (platform.includes('win') || /windows|win32/.test(userAgent)) {
|
|
|
+ return /arm|aarch/.test(architecture) ? 'auto' : 'win-x64';
|
|
|
+ }
|
|
|
+ if (platform.includes('mac') || /macintosh|mac os x|darwin/.test(userAgent)) {
|
|
|
+ if (/arm|aarch/.test(architecture)) return 'mac-arm64';
|
|
|
+ if (/x86|x64|amd64|intel/.test(architecture)) return 'mac-x64';
|
|
|
+ return 'auto';
|
|
|
+ }
|
|
|
+ return 'auto';
|
|
|
+ }
|
|
|
+
|
|
|
async requestTrialCode(): Promise<void> {
|
|
|
if (this.trialBusy()) return;
|
|
|
this.trialBusy.set(true);
|
|
|
@@ -622,14 +684,47 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
|
|
|
try {
|
|
|
const result = await this.authJson(`/api/qiwei/subscribe/status?seats=${this.seats()}&months=${this.months()}`, 'GET');
|
|
|
if (result.code !== 200) throw new Error(result.mess || '订阅状态查询失败');
|
|
|
- this.subscription.set(result.data);
|
|
|
- if (result.data?.quote) this.quote.set(result.data.quote);
|
|
|
- if (result.data?.subscribed && !this.purchaseMode()) this.stage.set('active');
|
|
|
+ const status = result.data as SubscriptionStatus;
|
|
|
+ if (this.isFeimaSession()) {
|
|
|
+ // Keep the payment page aligned with the Fmode Studio balance source.
|
|
|
+ // Older Qiwei API deployments can return a legacy ledger amount here.
|
|
|
+ try {
|
|
|
+ status.balance = await this.loadCanonicalFeimaBalance();
|
|
|
+ status.balanceState = 'available';
|
|
|
+ } catch (error: any) {
|
|
|
+ status.balance = undefined;
|
|
|
+ status.balanceState = 'unavailable';
|
|
|
+ this.errorMessage.set(error?.message || '飞马余额读取失败,请稍后重试。');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.subscription.set(status);
|
|
|
+ if (status.quote) this.quote.set(status.quote);
|
|
|
+ if (status.subscribed && !this.purchaseMode()) this.stage.set('active');
|
|
|
} catch (error: any) {
|
|
|
this.errorMessage.set(error?.message || '订阅状态查询失败,请稍后重试。');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private isFeimaSession(): boolean {
|
|
|
+ return this.sessionToken.trim().startsWith('r:');
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadCanonicalFeimaBalance(): Promise<number> {
|
|
|
+ const response = await fetch(`${API_BASE}/api/fmode/newapi/user`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ cache: 'no-store',
|
|
|
+ body: JSON.stringify({ sessionToken: this.sessionToken })
|
|
|
+ });
|
|
|
+ const result = await response.json().catch(() => ({}));
|
|
|
+ if (!response.ok || result.code !== 200) {
|
|
|
+ throw new Error(result.mess || result.message || '飞马余额读取失败');
|
|
|
+ }
|
|
|
+ const balance = Number(result.data?.user?.balance);
|
|
|
+ if (!Number.isFinite(balance)) throw new Error('飞马余额数据无效');
|
|
|
+ return Number(balance.toFixed(3));
|
|
|
+ }
|
|
|
+
|
|
|
private async loadTrialStatus(): Promise<void> {
|
|
|
try {
|
|
|
const result = await this.authJson('/api/qiwei/trial/status', 'GET');
|