统一 H5 宿主壳能力协议
新增 HostBridge 通用宿主能力服务与测试 迁移登录支付分享订阅入口到通用 HostBridge API 保留微信小程序旧接口兼容包装 补充宿主壳协议文档与项目记忆
This commit is contained in:
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
||||||
|
|
||||||
|
微信小程序壳、未来原生 App 壳、固定内置玩法与 AI H5 沙箱之间的宿主能力边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./【前端架构】宿主壳能力统一协议-2026-06-17.md)。
|
||||||
|
|
||||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||||
|
|
||||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-17 H5 宿主壳能力统一走 HostBridge
|
||||||
|
|
||||||
|
- 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。
|
||||||
|
- 决策:前端宿主运行态识别、微信小程序 JS SDK 加载、原生页跳转、支付跳转、登录跳转、九宫切图和 `postMessage` 统一收口到 `src/services/host-bridge/hostBridge.ts`,业务层优先调用 `getHostRuntime`、`requestHostLogin`、`requestHostPayment`、`navigateHostNativePage`、`setHostShareTarget` 和 `openHostShareGrid`。`authService`、分享服务、订阅授权和个人中心充值可保留兼容导出或业务编排,但不再自行加载微信 JS SDK 或直接判断 `wx.miniProgram`。固定内置玩法不走代码包下载流程;AI 生成 H5 沙箱后续单独定义受限 `GameBridge`,不得直接暴露完整 `HostBridge`。
|
||||||
|
- 影响范围:`src/services/host-bridge/`、`src/services/authService.ts`、`src/services/payment/paymentPlatform.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/usePlatformProfileCenterController.ts`、微信小程序壳和未来原生 App 壳接入。
|
||||||
|
- 验证方式:微信小程序首点登录仍打开原生登录页;小程序支付仍跳转 `/pages/wechat-pay/index` 并保留 hash 回灌确认;订阅授权仍跳转 `/pages/subscribe-message/index` 且返回不阻断生成;普通浏览器分享、H5 支付和 Native 二维码支付不受影响。前端验证运行 HostBridge、auth、payment、分享、订阅和个人中心充值相关定向测试,并执行 `npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||||
|
|
||||||
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
|
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
|
||||||
|
|
||||||
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。
|
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。
|
||||||
|
|||||||
68
docs/【前端架构】宿主壳能力统一协议-2026-06-17.md
Normal file
68
docs/【前端架构】宿主壳能力统一协议-2026-06-17.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# 宿主壳能力统一协议
|
||||||
|
|
||||||
|
更新时间:`2026-06-17`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前主站已经同时运行在普通浏览器、微信小程序 `web-view` 和后续可能出现的原生 App WebView 中。登录、支付、分享、订阅授权、运行态分享目标同步等能力散落在业务组件和服务文件里,后续如果新增原生 App 壳,容易出现同一业务按宿主重复分叉。
|
||||||
|
|
||||||
|
本方案先建立 `HostBridge` 宿主壳协议,把浏览器、微信小程序壳和未来原生 App 壳统一成能力 adapter。固定玩法运行态仍作为平台内置 runtime,AI 生成 H5 仍走独立 sandbox 和受限 GameBridge;二者都不能直接拿完整宿主壳能力。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. H5 业务层只判断宿主能力,不直接散落判断 `wx.miniProgram`、`MicroMessenger`、`clientRuntime`。
|
||||||
|
2. 微信小程序壳先作为 `wechat_mini_program` adapter 接入,保留现有登录、支付、分享、订阅授权行为。
|
||||||
|
3. 未来原生 App 壳只新增 `native_app` adapter,不重写 H5 业务。
|
||||||
|
4. 固定玩法继续读取作品数据、素材、运行态 snapshot 和后端裁决结果,不走代码包下载流程。
|
||||||
|
5. AI H5 sandbox 只能通过受限 GameBridge 请求资产、上报事件和提交候选结果,不暴露登录、支付、token、完整用户资料。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不重写 React 主站和现有玩法 runtime。
|
||||||
|
- 不把固定玩法迁成远程代码包。
|
||||||
|
- 不在本阶段实现 React Native / Expo 壳。
|
||||||
|
- 不改变支付到账、任务、排行榜、发布、统计等后端裁决口径。
|
||||||
|
|
||||||
|
## 分层
|
||||||
|
|
||||||
|
```text
|
||||||
|
H5 业务层
|
||||||
|
-> HostBridge 能力接口
|
||||||
|
-> browserHostBridge
|
||||||
|
-> wechatMiniProgramHostBridge
|
||||||
|
-> nativeAppHostBridge(预留)
|
||||||
|
|
||||||
|
AI H5 sandbox
|
||||||
|
-> GameBridge 受限协议
|
||||||
|
-> parent HostBridge adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首批能力
|
||||||
|
|
||||||
|
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`。
|
||||||
|
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
|
||||||
|
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
|
||||||
|
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
|
||||||
|
- `openHostShareGrid()`:微信小程序九宫格切图页。
|
||||||
|
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。
|
||||||
|
|
||||||
|
## 迁移顺序
|
||||||
|
|
||||||
|
1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。
|
||||||
|
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
|
||||||
|
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
|
||||||
|
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 微信小程序首点登录仍能打开原生登录页。
|
||||||
|
- 小程序分享链接仍生成 `/pages/web-view/index?targetPath=/works/detail&work=...`。
|
||||||
|
- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。
|
||||||
|
- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。
|
||||||
|
- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。
|
||||||
|
|
||||||
|
## 后续
|
||||||
|
|
||||||
|
- 设计 `native_app` 的 `postMessage` 消息格式和回包超时策略。
|
||||||
|
- 为 AI H5 sandbox 单独定义 GameBridge,禁止直接依赖 HostBridge。
|
||||||
|
- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。
|
||||||
@@ -26,8 +26,6 @@ const authMocks = vi.hoisted(() => ({
|
|||||||
getAuthAuditLogs: vi.fn(),
|
getAuthAuditLogs: vi.fn(),
|
||||||
getAuthRiskBlocks: vi.fn(),
|
getAuthRiskBlocks: vi.fn(),
|
||||||
getAuthSessions: vi.fn(),
|
getAuthSessions: vi.fn(),
|
||||||
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
|
|
||||||
requestWechatMiniProgramPhoneLogin: vi.fn(),
|
|
||||||
revokeAuthSessions: vi.fn(),
|
revokeAuthSessions: vi.fn(),
|
||||||
sendPhoneLoginCode: vi.fn(),
|
sendPhoneLoginCode: vi.fn(),
|
||||||
startWechatLogin: vi.fn(),
|
startWechatLogin: vi.fn(),
|
||||||
@@ -62,14 +60,10 @@ vi.mock('../../services/authService', () => ({
|
|||||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||||
getAuthSessions: authMocks.getAuthSessions,
|
getAuthSessions: authMocks.getAuthSessions,
|
||||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||||
isWechatMiniProgramWebViewRuntime:
|
|
||||||
authMocks.isWechatMiniProgramWebViewRuntime,
|
|
||||||
liftAuthRiskBlock: vi.fn(),
|
liftAuthRiskBlock: vi.fn(),
|
||||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||||
logoutAuthUser: authMocks.logoutAuthUser,
|
logoutAuthUser: authMocks.logoutAuthUser,
|
||||||
requestWechatMiniProgramPhoneLogin:
|
|
||||||
authMocks.requestWechatMiniProgramPhoneLogin,
|
|
||||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||||
resetPassword: authMocks.resetPassword,
|
resetPassword: authMocks.resetPassword,
|
||||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||||
@@ -78,6 +72,21 @@ vi.mock('../../services/authService', () => ({
|
|||||||
startWechatLogin: authMocks.startWechatLogin,
|
startWechatLogin: authMocks.startWechatLogin,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const hostBridgeMocks = vi.hoisted(() => ({
|
||||||
|
getHostRuntime: vi.fn(() => ({
|
||||||
|
kind: 'browser',
|
||||||
|
clientType: null as string | null,
|
||||||
|
clientRuntime: null as string | null,
|
||||||
|
miniProgramEnv: null as string | null,
|
||||||
|
})),
|
||||||
|
requestHostLogin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||||
|
getHostRuntime: hostBridgeMocks.getHostRuntime,
|
||||||
|
requestHostLogin: hostBridgeMocks.requestHostLogin,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../hooks/useGameSettings', () => ({
|
vi.mock('../../hooks/useGameSettings', () => ({
|
||||||
useGameSettings: () => ({
|
useGameSettings: () => ({
|
||||||
musicVolume: 0.42,
|
musicVolume: 0.42,
|
||||||
@@ -165,8 +174,13 @@ beforeEach(() => {
|
|||||||
expiresInSeconds: 300,
|
expiresInSeconds: 300,
|
||||||
});
|
});
|
||||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
|
hostBridgeMocks.getHostRuntime.mockReturnValue({
|
||||||
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
|
kind: 'browser',
|
||||||
|
clientType: null,
|
||||||
|
clientRuntime: null,
|
||||||
|
miniProgramEnv: null,
|
||||||
|
});
|
||||||
|
hostBridgeMocks.requestHostLogin.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -445,7 +459,12 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
|||||||
|
|
||||||
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
|
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
|
hostBridgeMocks.getHostRuntime.mockReturnValue({
|
||||||
|
kind: 'wechat_mini_program',
|
||||||
|
clientType: null,
|
||||||
|
clientRuntime: 'wechat_mini_program',
|
||||||
|
miniProgramEnv: null,
|
||||||
|
});
|
||||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||||
availableLoginMethods: ['phone', 'wechat'],
|
availableLoginMethods: ['phone', 'wechat'],
|
||||||
});
|
});
|
||||||
@@ -459,13 +478,11 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
|
|||||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(
|
expect(hostBridgeMocks.requestHostLogin).toHaveBeenCalledTimes(1);
|
||||||
1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
|
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||||
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
|
expect(hostBridgeMocks.getHostRuntime).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login modal requires first-time legal consent before sms login', async () => {
|
test('login modal requires first-time legal consent before sms login', async () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -32,19 +34,21 @@ import {
|
|||||||
getAuthSessions,
|
getAuthSessions,
|
||||||
getCaptchaChallengeFromError,
|
getCaptchaChallengeFromError,
|
||||||
getCurrentAuthUser,
|
getCurrentAuthUser,
|
||||||
isWechatMiniProgramWebViewRuntime,
|
|
||||||
liftAuthRiskBlock,
|
liftAuthRiskBlock,
|
||||||
loginWithPhoneCode,
|
loginWithPhoneCode,
|
||||||
logoutAllAuthSessions,
|
logoutAllAuthSessions,
|
||||||
logoutAuthUser,
|
logoutAuthUser,
|
||||||
redeemRegistrationInviteCode,
|
redeemRegistrationInviteCode,
|
||||||
requestWechatMiniProgramPhoneLogin,
|
|
||||||
resetPassword,
|
resetPassword,
|
||||||
revokeAuthSessions,
|
revokeAuthSessions,
|
||||||
sendPhoneLoginCode,
|
sendPhoneLoginCode,
|
||||||
setStoredLastLoginPhone,
|
setStoredLastLoginPhone,
|
||||||
startWechatLogin,
|
startWechatLogin,
|
||||||
} from '../../services/authService';
|
} from '../../services/authService';
|
||||||
|
import {
|
||||||
|
getHostRuntime,
|
||||||
|
requestHostLogin,
|
||||||
|
} from '../../services/host-bridge/hostBridge';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
import { AccountModal } from './AccountModal';
|
import { AccountModal } from './AccountModal';
|
||||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||||
@@ -328,7 +332,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const requestMiniProgramLogin = useCallback(() => {
|
const requestMiniProgramLogin = useCallback(() => {
|
||||||
setWechatLoading(true);
|
setWechatLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
void requestWechatMiniProgramPhoneLogin()
|
void requestHostLogin()
|
||||||
.catch((miniProgramError) => {
|
.catch((miniProgramError) => {
|
||||||
setError(
|
setError(
|
||||||
miniProgramError instanceof Error
|
miniProgramError instanceof Error
|
||||||
@@ -349,7 +353,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||||
if (isWechatMiniProgramWebViewRuntime()) {
|
if (getHostRuntime().kind === 'wechat_mini_program') {
|
||||||
setShowLoginModal(false);
|
setShowLoginModal(false);
|
||||||
requestMiniProgramLogin();
|
requestMiniProgramLogin();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
AuthLoginMethod,
|
AuthLoginMethod,
|
||||||
} from '../../services/authService';
|
} from '../../services/authService';
|
||||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||||
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
import { getHostRuntime } from '../../services/host-bridge/hostBridge';
|
||||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||||
import {
|
import {
|
||||||
getLegalDocument,
|
getLegalDocument,
|
||||||
@@ -96,7 +96,7 @@ export function LoginScreen({
|
|||||||
const passwordLoginEnabled = true;
|
const passwordLoginEnabled = true;
|
||||||
const phoneLoginEnabled = true;
|
const phoneLoginEnabled = true;
|
||||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||||
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
|
const miniProgramRuntime = getHostRuntime().kind === 'wechat_mini_program';
|
||||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import * as clipboardService from '../../services/clipboard';
|
import * as clipboardService from '../../services/clipboard';
|
||||||
import * as shareGridService from '../../services/wechatMiniProgramShareGrid';
|
|
||||||
import { PublishShareModal } from './PublishShareModal';
|
import { PublishShareModal } from './PublishShareModal';
|
||||||
import {
|
import {
|
||||||
buildMiniProgramPublishSharePath,
|
buildMiniProgramPublishSharePath,
|
||||||
@@ -24,10 +23,6 @@ import {
|
|||||||
vi.mock('../../services/clipboard', () => ({
|
vi.mock('../../services/clipboard', () => ({
|
||||||
copyTextToClipboard: vi.fn(),
|
copyTextToClipboard: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock('../../services/wechatMiniProgramShareGrid', () => ({
|
|
||||||
canUseWechatMiniProgramShareGrid: vi.fn(() => false),
|
|
||||||
openWechatMiniProgramShareGridPage: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const payload: PublishShareModalPayload = {
|
const payload: PublishShareModalPayload = {
|
||||||
title: '暖灯猫街',
|
title: '暖灯猫街',
|
||||||
@@ -39,10 +34,8 @@ const payload: PublishShareModalPayload = {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
|
window.wx = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PublishShareModal', () => {
|
describe('PublishShareModal', () => {
|
||||||
@@ -141,8 +134,10 @@ describe('PublishShareModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows the mini program grid action only inside mini program runtime', () => {
|
test('shows the mini program grid action only inside mini program runtime', () => {
|
||||||
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
|
window.history.replaceState(
|
||||||
true,
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -2,20 +2,20 @@ import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react';
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||||
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import {
|
import {
|
||||||
canUseWechatMiniProgramShareGrid,
|
canUseHostShareGrid,
|
||||||
openWechatMiniProgramShareGridPage,
|
getHostRuntime,
|
||||||
} from '../../services/wechatMiniProgramShareGrid';
|
openHostShareGrid,
|
||||||
|
} from '../../services/host-bridge/hostBridge';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
||||||
import { downloadPublishShareCardImage } from './publishShareCardImage';
|
import { downloadPublishShareCardImage } from './publishShareCardImage';
|
||||||
import {
|
import {
|
||||||
buildPublishShareCopyUrl,
|
buildPublishShareCopyUrl,
|
||||||
type PublishShareModalPayload,
|
type PublishShareModalPayload,
|
||||||
} from './publishShareModalModel';
|
} from './publishShareModalModel';
|
||||||
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
|
|
||||||
|
|
||||||
type PublishShareModalProps = {
|
type PublishShareModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -59,7 +59,8 @@ export function PublishShareModal({
|
|||||||
() =>
|
() =>
|
||||||
payload
|
payload
|
||||||
? buildPublishShareCopyUrl(payload, {
|
? buildPublishShareCopyUrl(payload, {
|
||||||
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
|
miniProgramRuntime:
|
||||||
|
getHostRuntime().kind === 'wechat_mini_program',
|
||||||
})
|
})
|
||||||
: '',
|
: '',
|
||||||
[payload],
|
[payload],
|
||||||
@@ -68,7 +69,7 @@ export function PublishShareModal({
|
|||||||
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
|
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
|
||||||
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
|
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
|
||||||
const showMiniProgramGridButton =
|
const showMiniProgramGridButton =
|
||||||
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
|
canUseHostShareGrid() && Boolean(coverImageSrc);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -151,7 +152,7 @@ export function PublishShareModal({
|
|||||||
setGridState('idle');
|
setGridState('idle');
|
||||||
void resolveMiniProgramGridCover()
|
void resolveMiniProgramGridCover()
|
||||||
.then((resolvedCoverImageSrc) =>
|
.then((resolvedCoverImageSrc) =>
|
||||||
openWechatMiniProgramShareGridPage({
|
openHostShareGrid({
|
||||||
imageUrl: resolvedCoverImageSrc,
|
imageUrl: resolvedCoverImageSrc,
|
||||||
title,
|
title,
|
||||||
publicWorkCode: payload.publicWorkCode,
|
publicWorkCode: payload.publicWorkCode,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { AuthUser } from '../../services/authService';
|
import {
|
||||||
|
type ConfirmWechatProfileRechargeOrderResponse,
|
||||||
|
type ProfileRechargeCenterResponse,
|
||||||
|
type ProfileRechargeProduct,
|
||||||
|
type ProfileReferralInviteCenterResponse,
|
||||||
|
type ProfileTaskCenterResponse,
|
||||||
|
type ProfileWalletLedgerResponse,
|
||||||
|
type RedeemProfileRewardCodeResponse,
|
||||||
|
type WechatNativePayment,
|
||||||
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { refreshStoredAccessToken } from '../../services/apiClient';
|
import { refreshStoredAccessToken } from '../../services/apiClient';
|
||||||
|
import type { AuthUser } from '../../services/authService';
|
||||||
|
import { requestHostPayment } from '../../services/host-bridge/hostBridge';
|
||||||
import {
|
import {
|
||||||
resolveProfileRechargeProductPaymentChannel,
|
resolveProfileRechargeProductPaymentChannel,
|
||||||
WECHAT_H5_PAYMENT_CHANNEL,
|
WECHAT_H5_PAYMENT_CHANNEL,
|
||||||
@@ -21,18 +32,6 @@ import {
|
|||||||
redeemRpgProfileRewardCode,
|
redeemRpgProfileRewardCode,
|
||||||
watchWechatRpgProfileRechargeOrder,
|
watchWechatRpgProfileRechargeOrder,
|
||||||
} from '../../services/rpg-entry/rpgProfileClient';
|
} from '../../services/rpg-entry/rpgProfileClient';
|
||||||
import {
|
|
||||||
type ConfirmWechatProfileRechargeOrderResponse,
|
|
||||||
type ProfileRechargeCenterResponse,
|
|
||||||
type ProfileRechargeProduct,
|
|
||||||
type ProfileReferralInviteCenterResponse,
|
|
||||||
type ProfileTaskCenterResponse,
|
|
||||||
type ProfileWalletLedgerResponse,
|
|
||||||
type RedeemProfileRewardCodeResponse,
|
|
||||||
type WechatMiniProgramPayParams,
|
|
||||||
type WechatMiniProgramVirtualPayParams,
|
|
||||||
type WechatNativePayment,
|
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
|
||||||
import {
|
import {
|
||||||
type CopyFeedbackState,
|
type CopyFeedbackState,
|
||||||
useCopyFeedback,
|
useCopyFeedback,
|
||||||
@@ -42,7 +41,6 @@ const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000;
|
|||||||
const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||||
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
|
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
|
||||||
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
|
||||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||||
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
|
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
|
||||||
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
|
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
|
||||||
@@ -167,83 +165,6 @@ function readWechatPayResultFromHash(): WechatPayResult | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadWechatJsSdk() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
|
||||||
}
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
return Promise.resolve(window.wx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
|
||||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
|
||||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
|
||||||
);
|
|
||||||
const complete = () => {
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
resolve(window.wx);
|
|
||||||
} else {
|
|
||||||
reject(new Error('请在微信小程序内完成支付'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingScript) {
|
|
||||||
existingScript.addEventListener('load', complete, { once: true });
|
|
||||||
existingScript.addEventListener(
|
|
||||||
'error',
|
|
||||||
() => reject(new Error('请在微信小程序内完成支付')),
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = WECHAT_JS_SDK_URL;
|
|
||||||
script.async = true;
|
|
||||||
script.onload = complete;
|
|
||||||
script.onerror = () => reject(new Error('请在微信小程序内完成支付'));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestWechatMiniProgramPayment(
|
|
||||||
payload:
|
|
||||||
| WechatMiniProgramPayParams
|
|
||||||
| WechatMiniProgramVirtualPayParams
|
|
||||||
| null
|
|
||||||
| undefined,
|
|
||||||
orderId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!payload) {
|
|
||||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
|
||||||
}
|
|
||||||
const wxBridge = await loadWechatJsSdk();
|
|
||||||
const miniProgram = wxBridge.miniProgram;
|
|
||||||
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
|
|
||||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
|
||||||
}
|
|
||||||
const navigateTo = miniProgram.navigateTo;
|
|
||||||
|
|
||||||
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
navigateTo({
|
|
||||||
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
|
|
||||||
success() {
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
fail(error) {
|
|
||||||
console.error('[wechat-pay] navigateTo failed', error);
|
|
||||||
reject(
|
|
||||||
error instanceof Error
|
|
||||||
? error
|
|
||||||
: new Error('请在微信小程序内完成支付'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitWechatPayConfirmDelay(delayMs: number) {
|
function waitWechatPayConfirmDelay(delayMs: number) {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
window.setTimeout(resolve, delayMs);
|
window.setTimeout(resolve, delayMs);
|
||||||
@@ -601,10 +522,13 @@ export function usePlatformProfileCenterController({
|
|||||||
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
|
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
|
||||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||||
setRechargeCenter(response.center);
|
setRechargeCenter(response.center);
|
||||||
await requestWechatMiniProgramPayment(
|
const paymentHandled = await requestHostPayment({
|
||||||
response.wechatMiniProgramPayParams,
|
payload: response.wechatMiniProgramPayParams,
|
||||||
response.order.orderId,
|
orderId: response.order.orderId,
|
||||||
);
|
});
|
||||||
|
if (!paymentHandled) {
|
||||||
|
throw new Error('请在微信小程序内完成支付');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
|
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock';
|
|||||||
import {resolveAppRoute} from './routing/appRoutes';
|
import {resolveAppRoute} from './routing/appRoutes';
|
||||||
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
||||||
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
||||||
import {isWechatMiniProgramWebViewRuntime} from './services/authService';
|
import {getHostRuntime} from './services/host-bridge/hostBridge';
|
||||||
|
|
||||||
type AppRoot = ReturnType<typeof createRoot>;
|
type AppRoot = ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ if (!rootElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markWechatMiniProgramRuntime() {
|
function markWechatMiniProgramRuntime() {
|
||||||
if (isWechatMiniProgramWebViewRuntime()) {
|
if (getHostRuntime().kind === 'wechat_mini_program') {
|
||||||
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ import {
|
|||||||
|
|
||||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||||
|
export {
|
||||||
|
isWechatMiniProgramWebViewRuntime,
|
||||||
|
requestHostLogin as requestWechatMiniProgramPhoneLogin,
|
||||||
|
} from './host-bridge/hostBridge';
|
||||||
|
|
||||||
export type AuthSessionSnapshot = {
|
export type AuthSessionSnapshot = {
|
||||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||||
@@ -55,10 +59,6 @@ export type ConsumedAuthCallback = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
|
||||||
const MINI_PROGRAM_AUTH_PAGE_URL =
|
|
||||||
'/pages/web-view/index?authAction=login&returnTo=previous';
|
|
||||||
|
|
||||||
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
||||||
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
||||||
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||||
@@ -84,98 +84,6 @@ export function clearRuntimeGuestTokenCache() {
|
|||||||
runtimeGuestTokenCache.value = null;
|
runtimeGuestTokenCache.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWechatMiniProgramWebViewRuntime() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search || '');
|
|
||||||
const userAgent =
|
|
||||||
typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
|
|
||||||
const normalizedUserAgent = userAgent.toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
|
||||||
params.get('clientType') === 'mini_program' ||
|
|
||||||
(normalizedUserAgent.includes('micromessenger') &&
|
|
||||||
normalizedUserAgent.includes('miniprogram')) ||
|
|
||||||
Boolean(window.wx?.miniProgram?.postMessage)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadWechatMiniProgramBridge() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return Promise.reject(new Error('请在微信小程序内完成登录'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
return Promise.resolve(window.wx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
|
||||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
|
||||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
|
||||||
);
|
|
||||||
const complete = () => {
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
resolve(window.wx);
|
|
||||||
} else {
|
|
||||||
reject(new Error('请在微信小程序内完成登录'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingScript) {
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
existingScript.addEventListener('load', complete, { once: true });
|
|
||||||
existingScript.addEventListener(
|
|
||||||
'error',
|
|
||||||
() => reject(new Error('请在微信小程序内完成登录')),
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = WECHAT_JS_SDK_URL;
|
|
||||||
script.async = true;
|
|
||||||
script.onload = complete;
|
|
||||||
script.onerror = () => reject(new Error('请在微信小程序内完成登录'));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestWechatMiniProgramPhoneLogin() {
|
|
||||||
if (!isWechatMiniProgramWebViewRuntime()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wxBridge = await loadWechatMiniProgramBridge();
|
|
||||||
const miniProgram = wxBridge.miniProgram;
|
|
||||||
const navigateTo = miniProgram?.navigateTo;
|
|
||||||
if (typeof navigateTo !== 'function') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
navigateTo({
|
|
||||||
url: MINI_PROGRAM_AUTH_PAGE_URL,
|
|
||||||
success() {
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
fail(error) {
|
|
||||||
reject(
|
|
||||||
new Error(error?.errMsg || '请在微信小程序内完成登录'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureRuntimeGuestToken() {
|
export async function ensureRuntimeGuestToken() {
|
||||||
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
|
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
|
||||||
return runtimeGuestTokenCache.value!;
|
return runtimeGuestTokenCache.value!;
|
||||||
|
|||||||
230
src/services/host-bridge/hostBridge.test.ts
Normal file
230
src/services/host-bridge/hostBridge.test.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canUseHostShareGrid,
|
||||||
|
getHostRuntime,
|
||||||
|
isWechatMiniProgramWebViewRuntime,
|
||||||
|
navigateHostNativePage,
|
||||||
|
openHostShareGrid,
|
||||||
|
openWechatMiniProgramShareGridPage,
|
||||||
|
postWechatMiniProgramMessage,
|
||||||
|
requestHostLogin,
|
||||||
|
requestHostPayment,
|
||||||
|
requestWechatMiniProgramPayment,
|
||||||
|
requestWechatMiniProgramPhoneLogin,
|
||||||
|
resolveHostRuntime,
|
||||||
|
setHostShareTarget,
|
||||||
|
} from './hostBridge';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
window.wx = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hostBridge', () => {
|
||||||
|
test('识别微信小程序、原生 App 和普通浏览器宿主', () => {
|
||||||
|
expect(
|
||||||
|
getHostRuntime({
|
||||||
|
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||||
|
}).kind,
|
||||||
|
).toBe('wechat_mini_program');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveHostRuntime({
|
||||||
|
navigator: {
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram',
|
||||||
|
},
|
||||||
|
}).kind,
|
||||||
|
).toBe('wechat_mini_program');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveHostRuntime({
|
||||||
|
location: { search: '?clientRuntime=native_app' },
|
||||||
|
}).kind,
|
||||||
|
).toBe('native_app');
|
||||||
|
|
||||||
|
expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe(
|
||||||
|
'browser',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('通过微信小程序原生页请求登录', async () => {
|
||||||
|
const navigateTo = vi.fn((options) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
|
);
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requested = await requestHostLogin();
|
||||||
|
|
||||||
|
expect(requested).toBe(true);
|
||||||
|
expect(navigateTo).toHaveBeenCalledWith({
|
||||||
|
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||||||
|
success: expect.any(Function),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(requestWechatMiniProgramPhoneLogin()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('通过微信小程序原生页请求支付', async () => {
|
||||||
|
const navigateTo = vi.fn((options) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
|
);
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handled = await requestHostPayment({
|
||||||
|
payload: {
|
||||||
|
timeStamp: '1',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=1',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'sign',
|
||||||
|
},
|
||||||
|
orderId: 'order-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
const url = navigateTo.mock.calls[0]?.[0].url;
|
||||||
|
expect(url).toContain('/pages/wechat-pay/index?');
|
||||||
|
const parsed = new URL(url, 'https://mini.test');
|
||||||
|
expect(parsed.searchParams.get('requestId')).toBe(
|
||||||
|
'wechat_pay_order-1_123456',
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get('orderId')).toBe('order-1');
|
||||||
|
expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestWechatMiniProgramPayment(
|
||||||
|
{
|
||||||
|
timeStamp: '1',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=1',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'sign',
|
||||||
|
},
|
||||||
|
'order-1',
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => {
|
||||||
|
await expect(requestHostLogin()).resolves.toBe(false);
|
||||||
|
await expect(
|
||||||
|
requestHostPayment({
|
||||||
|
payload: {
|
||||||
|
timeStamp: '1',
|
||||||
|
nonceStr: 'nonce',
|
||||||
|
package: 'prepay_id=1',
|
||||||
|
signType: 'RSA',
|
||||||
|
paySign: 'sign',
|
||||||
|
},
|
||||||
|
orderId: 'order-1',
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
requestHostPayment({
|
||||||
|
payload: null,
|
||||||
|
orderId: 'order-1',
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => {
|
||||||
|
const navigateTo = vi.fn((options) => {
|
||||||
|
options.success?.();
|
||||||
|
});
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
|
);
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
navigateTo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canUseHostShareGrid()).toBe(true);
|
||||||
|
const opened = await openHostShareGrid({
|
||||||
|
imageUrl: '/cover.png',
|
||||||
|
title: '暖灯猫街',
|
||||||
|
publicWorkCode: 'PZ-00000001',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(opened).toBe(true);
|
||||||
|
const url = navigateTo.mock.calls[0]?.[0].url;
|
||||||
|
const parsed = new URL(url, 'https://mini.test');
|
||||||
|
expect(parsed.pathname).toBe('/pages/share-grid/index');
|
||||||
|
expect(parsed.searchParams.get('imageUrl')).toBe(
|
||||||
|
`${window.location.origin}/cover.png`,
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get('title')).toBe('暖灯猫街');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
openWechatMiniProgramShareGridPage({
|
||||||
|
imageUrl: '/cover.png',
|
||||||
|
title: '暖灯猫街',
|
||||||
|
publicWorkCode: 'PZ-00000001',
|
||||||
|
}),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('向微信小程序宿主同步消息', () => {
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=wechat_mini_program',
|
||||||
|
);
|
||||||
|
window.wx = {
|
||||||
|
miniProgram: {
|
||||||
|
postMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
setHostShareTarget({
|
||||||
|
type: 'test',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
type: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('普通浏览器不开放微信小程序能力', () => {
|
||||||
|
expect(isWechatMiniProgramWebViewRuntime()).toBe(false);
|
||||||
|
expect(canUseHostShareGrid()).toBe(false);
|
||||||
|
expect(setHostShareTarget({ type: 'test' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
340
src/services/host-bridge/hostBridge.ts
Normal file
340
src/services/host-bridge/hostBridge.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import type {
|
||||||
|
WechatMiniProgramPayParams,
|
||||||
|
WechatMiniProgramVirtualPayParams,
|
||||||
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
|
||||||
|
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
|
const MINI_PROGRAM_AUTH_PAGE_URL =
|
||||||
|
'/pages/web-view/index?authAction=login&returnTo=previous';
|
||||||
|
const MINI_PROGRAM_PAY_PAGE_URL = '/pages/wechat-pay/index';
|
||||||
|
const MINI_PROGRAM_SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
|
||||||
|
|
||||||
|
export type HostRuntimeKind =
|
||||||
|
| 'browser'
|
||||||
|
| 'wechat_mini_program'
|
||||||
|
| 'native_app';
|
||||||
|
|
||||||
|
export type HostRuntimeSnapshot = {
|
||||||
|
kind: HostRuntimeKind;
|
||||||
|
clientType: string | null;
|
||||||
|
clientRuntime: string | null;
|
||||||
|
miniProgramEnv: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HostRuntimeContext = {
|
||||||
|
location?: Pick<Location, 'search'> | null;
|
||||||
|
navigator?: Partial<Pick<Navigator, 'userAgent'>> | null;
|
||||||
|
wx?: Window['wx'] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HostNativePageNavigationOptions = {
|
||||||
|
errorMessage?: string;
|
||||||
|
beforeNavigate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HostPaymentRequest = {
|
||||||
|
payload:
|
||||||
|
| WechatMiniProgramPayParams
|
||||||
|
| WechatMiniProgramVirtualPayParams
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
orderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HostShareGridRequest = {
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
publicWorkCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLocation(context: HostRuntimeContext) {
|
||||||
|
return (
|
||||||
|
context.location ?? (typeof window !== 'undefined' ? window.location : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNavigator(context: HostRuntimeContext) {
|
||||||
|
return (
|
||||||
|
context.navigator ??
|
||||||
|
(typeof navigator !== 'undefined' ? navigator : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWxBridge(context: HostRuntimeContext) {
|
||||||
|
return context.wx ?? (typeof window !== 'undefined' ? window.wx : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWechatMiniProgramBridge(wxBridge: Window['wx'] | null | undefined) {
|
||||||
|
return Boolean(
|
||||||
|
wxBridge?.miniProgram?.postMessage || wxBridge?.miniProgram?.navigateTo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWechatMiniProgramUserAgent(userAgent: string) {
|
||||||
|
const normalizedUserAgent = userAgent.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalizedUserAgent.includes('micromessenger') &&
|
||||||
|
normalizedUserAgent.includes('miniprogram')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHostRuntime(
|
||||||
|
context: HostRuntimeContext = {},
|
||||||
|
): HostRuntimeSnapshot {
|
||||||
|
const location = resolveLocation(context);
|
||||||
|
const params = new URLSearchParams(location?.search ?? '');
|
||||||
|
const clientType = params.get('clientType');
|
||||||
|
const clientRuntime = params.get('clientRuntime');
|
||||||
|
const miniProgramEnv = params.get('miniProgramEnv');
|
||||||
|
const navigatorLike = resolveNavigator(context);
|
||||||
|
const wxBridge = resolveWxBridge(context);
|
||||||
|
|
||||||
|
if (
|
||||||
|
clientRuntime === 'wechat_mini_program' ||
|
||||||
|
clientType === 'mini_program' ||
|
||||||
|
isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') ||
|
||||||
|
hasWechatMiniProgramBridge(wxBridge)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'wechat_mini_program',
|
||||||
|
clientType,
|
||||||
|
clientRuntime,
|
||||||
|
miniProgramEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientRuntime === 'native_app' || clientType === 'native_app') {
|
||||||
|
return {
|
||||||
|
kind: 'native_app',
|
||||||
|
clientType,
|
||||||
|
clientRuntime,
|
||||||
|
miniProgramEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'browser',
|
||||||
|
clientType,
|
||||||
|
clientRuntime,
|
||||||
|
miniProgramEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostRuntime(context: HostRuntimeContext = {}) {
|
||||||
|
return resolveHostRuntime(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWechatMiniProgramWebViewRuntime(
|
||||||
|
context: HostRuntimeContext = {},
|
||||||
|
) {
|
||||||
|
return resolveHostRuntime(context).kind === 'wechat_mini_program';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNativeAppRuntime(context: HostRuntimeContext = {}) {
|
||||||
|
return resolveHostRuntime(context).kind === 'native_app';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWechatMiniProgramBridge(
|
||||||
|
errorMessage = '请在微信小程序内完成操作',
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!isWechatMiniProgramWebViewRuntime()
|
||||||
|
) {
|
||||||
|
return Promise.reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.wx?.miniProgram?.navigateTo) {
|
||||||
|
return Promise.resolve(window.wx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||||
|
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||||
|
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||||
|
);
|
||||||
|
const complete = () => {
|
||||||
|
if (window.wx?.miniProgram?.navigateTo) {
|
||||||
|
resolve(window.wx);
|
||||||
|
} else {
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingScript) {
|
||||||
|
if (window.wx?.miniProgram?.navigateTo) {
|
||||||
|
complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingScript.addEventListener('load', complete, { once: true });
|
||||||
|
existingScript.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error(errorMessage)),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = WECHAT_JS_SDK_URL;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = complete;
|
||||||
|
script.onerror = () => reject(new Error(errorMessage));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateWechatMiniProgramPage(
|
||||||
|
url: string,
|
||||||
|
errorMessage = '请在微信小程序内完成操作',
|
||||||
|
options: Pick<HostNativePageNavigationOptions, 'beforeNavigate'> = {},
|
||||||
|
) {
|
||||||
|
const wxBridge = await loadWechatMiniProgramBridge(errorMessage);
|
||||||
|
const navigateTo = wxBridge.miniProgram?.navigateTo;
|
||||||
|
if (typeof navigateTo !== 'function') {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
options.beforeNavigate?.();
|
||||||
|
navigateTo({
|
||||||
|
url,
|
||||||
|
success() {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error?.errMsg || errorMessage));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateHostNativePage(
|
||||||
|
url: string,
|
||||||
|
options: HostNativePageNavigationOptions = {},
|
||||||
|
) {
|
||||||
|
const runtime = getHostRuntime();
|
||||||
|
if (runtime.kind !== 'wechat_mini_program') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateWechatMiniProgramPage(url, options.errorMessage, {
|
||||||
|
beforeNavigate: options.beforeNavigate,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestHostLogin() {
|
||||||
|
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
|
||||||
|
errorMessage: '请在微信小程序内完成登录',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestWechatMiniProgramPhoneLogin() {
|
||||||
|
return await requestHostLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestHostPayment({
|
||||||
|
payload,
|
||||||
|
orderId,
|
||||||
|
}: HostPaymentRequest) {
|
||||||
|
if (getHostRuntime().kind !== 'wechat_mini_program') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('请在微信小程序内完成支付');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
requestId,
|
||||||
|
orderId,
|
||||||
|
payParams: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigateWechatMiniProgramPage(
|
||||||
|
`${MINI_PROGRAM_PAY_PAGE_URL}?${searchParams.toString()}`,
|
||||||
|
'请在微信小程序内完成支付',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestWechatMiniProgramPayment(
|
||||||
|
payload:
|
||||||
|
| WechatMiniProgramPayParams
|
||||||
|
| WechatMiniProgramVirtualPayParams
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
orderId: string,
|
||||||
|
) {
|
||||||
|
const handled = await requestHostPayment({ payload, orderId });
|
||||||
|
if (!handled) {
|
||||||
|
throw new Error('请在微信小程序内完成支付');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAbsoluteUrl(value: string) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(value, window.location.origin).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseHostShareGrid(context: HostRuntimeContext = {}) {
|
||||||
|
return getHostRuntime(context).kind === 'wechat_mini_program';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseWechatMiniProgramShareGrid() {
|
||||||
|
return canUseHostShareGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openHostShareGrid(params: HostShareGridRequest) {
|
||||||
|
const imageUrl = params.imageUrl.trim();
|
||||||
|
if (!imageUrl || !canUseHostShareGrid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
imageUrl: buildAbsoluteUrl(imageUrl),
|
||||||
|
title: params.title.trim() || '我的作品',
|
||||||
|
publicWorkCode: params.publicWorkCode.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await navigateHostNativePage(
|
||||||
|
`${MINI_PROGRAM_SHARE_GRID_PAGE_URL}?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
errorMessage: 'wechat_js_sdk_unavailable',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWechatMiniProgramShareGridPage(
|
||||||
|
params: HostShareGridRequest,
|
||||||
|
) {
|
||||||
|
return await openHostShareGrid(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHostShareTarget(message: unknown) {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
getHostRuntime().kind !== 'wechat_mini_program' ||
|
||||||
|
typeof window.wx?.miniProgram?.postMessage !== 'function'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.wx.miniProgram.postMessage({
|
||||||
|
data: message,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postWechatMiniProgramMessage(message: unknown) {
|
||||||
|
return setHostShareTarget(message);
|
||||||
|
}
|
||||||
@@ -43,6 +43,18 @@ describe('resolveProfileRechargePaymentChannel', () => {
|
|||||||
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('小程序桥未就绪但 UA 已带 miniProgram 时选择 wechat_mp_virtual', () => {
|
||||||
|
expect(
|
||||||
|
resolveProfileRechargePaymentChannel({
|
||||||
|
location: { search: '' },
|
||||||
|
navigator: {
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 iPhone MicroMessenger/8.0.49 miniProgram',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||||
|
});
|
||||||
|
|
||||||
test('移动网页选择 wechat_h5', () => {
|
test('移动网页选择 wechat_h5', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveProfileRechargePaymentChannel({
|
resolveProfileRechargePaymentChannel({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getHostRuntime } from '../host-bridge/hostBridge';
|
||||||
|
|
||||||
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
||||||
export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual';
|
export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual';
|
||||||
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
|
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
|
||||||
@@ -31,7 +33,7 @@ export function shouldShowRechargeEntry(
|
|||||||
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
|
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isWechatMiniProgramRuntime(location) ||
|
isWechatMiniProgramRuntime(location, navigatorLike) ||
|
||||||
isWechatBrowserRuntime(navigatorLike)
|
isWechatBrowserRuntime(navigatorLike)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,7 +52,7 @@ export function resolveProfileRechargePaymentChannel(
|
|||||||
? window.matchMedia.bind(window)
|
? window.matchMedia.bind(window)
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
if (isWechatMiniProgramRuntime(location)) {
|
if (isWechatMiniProgramRuntime(location, navigatorLike)) {
|
||||||
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
|
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +77,13 @@ export function isManualMockPaymentChannel(paymentChannel: string) {
|
|||||||
export function isWechatMiniProgramRuntime(
|
export function isWechatMiniProgramRuntime(
|
||||||
location: Pick<Location, 'search'> | null | undefined =
|
location: Pick<Location, 'search'> | null | undefined =
|
||||||
typeof window !== 'undefined' ? window.location : null,
|
typeof window !== 'undefined' ? window.location : null,
|
||||||
|
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined =
|
||||||
|
typeof navigator !== 'undefined' ? navigator : null,
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams(location?.search ?? '');
|
return getHostRuntime({
|
||||||
return (
|
location,
|
||||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
navigator: navigatorLike,
|
||||||
params.get('clientType') === 'mini_program'
|
}).kind === 'wechat_mini_program';
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWechatBrowserRuntime(
|
function isWechatBrowserRuntime(
|
||||||
|
|||||||
@@ -1,96 +1,4 @@
|
|||||||
import { isWechatMiniProgramWebViewRuntime } from './authService';
|
export {
|
||||||
|
canUseHostShareGrid as canUseWechatMiniProgramShareGrid,
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
openHostShareGrid as openWechatMiniProgramShareGridPage,
|
||||||
const SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
|
} from './host-bridge/hostBridge';
|
||||||
|
|
||||||
function loadWechatMiniProgramBridge() {
|
|
||||||
if (
|
|
||||||
typeof window === 'undefined' ||
|
|
||||||
!isWechatMiniProgramWebViewRuntime()
|
|
||||||
) {
|
|
||||||
return Promise.reject(new Error('not_mini_program'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
return Promise.resolve(window.wx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
|
||||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
|
||||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
|
||||||
);
|
|
||||||
const complete = () => {
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
resolve(window.wx);
|
|
||||||
} else {
|
|
||||||
reject(new Error('wechat_js_sdk_unavailable'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingScript) {
|
|
||||||
existingScript.addEventListener('load', complete, { once: true });
|
|
||||||
existingScript.addEventListener(
|
|
||||||
'error',
|
|
||||||
() => reject(new Error('wechat_js_sdk_load_failed')),
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = WECHAT_JS_SDK_URL;
|
|
||||||
script.async = true;
|
|
||||||
script.onload = complete;
|
|
||||||
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAbsoluteUrl(value: string) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new URL(value, window.location.origin).href;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canUseWechatMiniProgramShareGrid() {
|
|
||||||
return isWechatMiniProgramWebViewRuntime();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openWechatMiniProgramShareGridPage(params: {
|
|
||||||
imageUrl: string;
|
|
||||||
title: string;
|
|
||||||
publicWorkCode: string;
|
|
||||||
}) {
|
|
||||||
const imageUrl = params.imageUrl.trim();
|
|
||||||
if (!imageUrl) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wxBridge = await loadWechatMiniProgramBridge();
|
|
||||||
const miniProgram = wxBridge.miniProgram;
|
|
||||||
if (!miniProgram?.navigateTo) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
imageUrl: buildAbsoluteUrl(imageUrl),
|
|
||||||
title: params.title.trim() || '我的作品',
|
|
||||||
publicWorkCode: params.publicWorkCode.trim(),
|
|
||||||
});
|
|
||||||
const url = `${SHARE_GRID_PAGE_URL}?${searchParams.toString()}`;
|
|
||||||
|
|
||||||
return await new Promise<boolean>((resolve) => {
|
|
||||||
miniProgram.navigateTo?.({
|
|
||||||
url,
|
|
||||||
success() {
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
fail() {
|
|
||||||
resolve(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isWechatMiniProgramWebViewRuntime } from './authService';
|
import { setHostShareTarget } from './host-bridge/hostBridge';
|
||||||
|
|
||||||
const MESSAGE_TYPE = 'genarrative:share-target';
|
const MESSAGE_TYPE = 'genarrative:share-target';
|
||||||
|
|
||||||
@@ -38,14 +38,6 @@ export function buildWechatMiniProgramShareTargetMessage(
|
|||||||
export function postWechatMiniProgramShareTarget(
|
export function postWechatMiniProgramShareTarget(
|
||||||
target: WechatMiniProgramShareTarget | null | undefined,
|
target: WechatMiniProgramShareTarget | null | undefined,
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
typeof window === 'undefined' ||
|
|
||||||
!isWechatMiniProgramWebViewRuntime() ||
|
|
||||||
typeof window.wx?.miniProgram?.postMessage !== 'function'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = buildWechatMiniProgramShareTargetMessage(target);
|
const message = buildWechatMiniProgramShareTargetMessage(target);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return false;
|
return false;
|
||||||
@@ -53,8 +45,5 @@ export function postWechatMiniProgramShareTarget(
|
|||||||
|
|
||||||
// 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页,
|
// 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页,
|
||||||
// 小程序页据此把右上角系统分享指向当前推荐作品。
|
// 小程序页据此把右上角系统分享指向当前推荐作品。
|
||||||
window.wx.miniProgram.postMessage({
|
return setHostShareTarget(message);
|
||||||
data: message,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,18 @@ describe('wechatMiniProgramSubscribe', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('skips permission request outside mini program web-view', async () => {
|
test('skips permission request outside mini program web-view', async () => {
|
||||||
const navigateTo = vi.fn();
|
const requested = await requestGenerationResultSubscribePermission();
|
||||||
|
|
||||||
|
expect(requested).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses an already available mini program bridge even without query marker', async () => {
|
||||||
|
const navigateTo = vi.fn((options) => {
|
||||||
|
options.success?.();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event('focus'));
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
window.wx = {
|
window.wx = {
|
||||||
miniProgram: {
|
miniProgram: {
|
||||||
navigateTo,
|
navigateTo,
|
||||||
@@ -98,7 +109,11 @@ describe('wechatMiniProgramSubscribe', () => {
|
|||||||
|
|
||||||
const requested = await requestGenerationResultSubscribePermission();
|
const requested = await requestGenerationResultSubscribePermission();
|
||||||
|
|
||||||
expect(requested).toBe(false);
|
expect(requested).toBe(true);
|
||||||
expect(navigateTo).not.toHaveBeenCalled();
|
expect(navigateTo).toHaveBeenCalledWith({
|
||||||
|
url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u),
|
||||||
|
success: expect.any(Function),
|
||||||
|
fail: expect.any(Function),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { isWechatMiniProgramRuntime } from './payment/paymentPlatform';
|
import {
|
||||||
|
getHostRuntime,
|
||||||
|
navigateHostNativePage,
|
||||||
|
} from './host-bridge/hostBridge';
|
||||||
|
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
|
||||||
const SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result';
|
const SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result';
|
||||||
const SUBSCRIBE_RESULT_TIMEOUT_MS = 2_500;
|
const SUBSCRIBE_RESULT_TIMEOUT_MS = 2_500;
|
||||||
const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800;
|
const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800;
|
||||||
@@ -96,80 +98,33 @@ function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadWechatJsSdk() {
|
export async function requestGenerationResultSubscribePermission() {
|
||||||
if (
|
if (
|
||||||
!isWechatMiniProgramRuntime() ||
|
getHostRuntime().kind !== 'wechat_mini_program' ||
|
||||||
typeof window === 'undefined'
|
typeof window === 'undefined'
|
||||||
) {
|
) {
|
||||||
return Promise.reject(new Error('not_mini_program'));
|
|
||||||
}
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
return Promise.resolve(window.wx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
|
||||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
|
||||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
|
||||||
);
|
|
||||||
const complete = () => {
|
|
||||||
if (window.wx?.miniProgram?.navigateTo) {
|
|
||||||
resolve(window.wx);
|
|
||||||
} else {
|
|
||||||
reject(new Error('wechat_js_sdk_unavailable'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingScript) {
|
|
||||||
existingScript.addEventListener('load', complete, { once: true });
|
|
||||||
existingScript.addEventListener('error', () => reject(new Error('wechat_js_sdk_load_failed')), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = WECHAT_JS_SDK_URL;
|
|
||||||
script.async = true;
|
|
||||||
script.onload = complete;
|
|
||||||
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestGenerationResultSubscribePermission() {
|
|
||||||
if (!isWechatMiniProgramRuntime() || typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let wxBridge: NonNullable<Window['wx']>;
|
|
||||||
try {
|
|
||||||
wxBridge = await loadWechatJsSdk();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const miniProgram = wxBridge.miniProgram;
|
|
||||||
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = `subscribe_generation_result_${Date.now()}`;
|
const requestId = `subscribe_generation_result_${Date.now()}`;
|
||||||
const resultPromise = waitSubscribeResultFromHash();
|
let resultPromise: Promise<string | null> | null = null;
|
||||||
const navigated = await new Promise<boolean>((resolve) => {
|
try {
|
||||||
miniProgram.navigateTo?.({
|
const navigated = await navigateHostNativePage(
|
||||||
url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
|
`/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
|
||||||
success() {
|
{
|
||||||
resolve(true);
|
errorMessage: 'wechat_js_sdk_unavailable',
|
||||||
|
beforeNavigate() {
|
||||||
|
resultPromise = waitSubscribeResultFromHash();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fail() {
|
);
|
||||||
resolve(false);
|
if (!navigated) {
|
||||||
},
|
return false;
|
||||||
});
|
}
|
||||||
});
|
} catch {
|
||||||
if (!navigated) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const result = await resultPromise;
|
|
||||||
|
const result = await (resultPromise ?? Promise.resolve(null));
|
||||||
return Boolean(result);
|
return Boolean(result);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user