From e8c2e8d532c91fcb8ecdd9ce9263178aa9bc95ca Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 06:48:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=A1=AB=E7=A0=81=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=89=AA=E8=B4=B4=E6=9D=BF=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 邀请码填写和兑换码弹窗在宿主声明 clipboard.readText 时显示粘贴入口 粘贴动作只读取宿主纯文本并填入现有受控输入框,不自动提交或伪造兑换成功 补充填码粘贴回归测试和 profile 剪贴板 helper 更新 Expo/Tauri 宿主壳方案、HostBridge 协议和共享决策记录 --- .../shared-memory/decision-log.md | 7 +++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 ++ ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- .../PlatformProfileReferralModal.test.tsx | 50 ++++++++++++++++++- .../PlatformProfileReferralModal.tsx | 30 ++++++++++- ...tformProfileRewardCodeRedeemModal.test.tsx | 46 ++++++++++++++++- .../PlatformProfileRewardCodeRedeemModal.tsx | 29 +++++++++++ .../platformProfileHostClipboard.ts | 13 +++++ 8 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 src/components/platform-entry/platformProfileHostClipboard.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 818eb0fa9..16a2d6d4a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2326,3 +2326,10 @@ - 决策:`RpgEntryHomeView` 的头像上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有头像读取、类型校验、5 MiB 大小限制、方形裁剪和 `updateAuthProfile({ avatarDataUrl })` 链路。宿主只负责受控图片选择,不暴露设备 URI、本机绝对路径或通用文件系统,也不新增 React Native / Tauri 专属头像编辑页;普通浏览器、小程序和未声明能力的裁剪壳继续使用原 ``。 - 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。 - 验证方式:`npm run check:native-shells`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile avatar upload"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 邀请码和兑换码填入接入原生壳剪贴板读取 + +- 背景:Expo / Tauri 壳已经具备真实 `clipboard.readText` 纯文本读取能力,但 H5 个人中心的邀请码填写和兑换码弹窗仍只能手输;用户从聊天、短信或活动页复制代码后在原生壳内缺少受控粘贴入口。 +- 决策:个人中心的邀请码填写弹窗和兑换码弹窗在 `native_app` 且宿主声明 `clipboard.readText` 时显示“粘贴”动作,通过 H5 facade 读取宿主返回的纯文本并填入现有受控输入框。该动作不自动提交,不代表兑换成功,不把剪贴板读取扩展成图片、HTML、文件或监听事件,也不绕过既有 `redeemRpgProfileReferralInviteCode` / `redeemRpgProfileRewardCode` 后端接口。 +- 影响范围:`src/components/platform-entry/PlatformProfileReferralModal.tsx`、`src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`、`src/components/platform-entry/platformProfileHostClipboard.ts`、Expo / Tauri HostBridge 文档。 +- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 42edfed8e..4647db71b 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -260,6 +260,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。 +2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。 + ### Phase 3:Tauri 桌面壳 MVP - 新增 `apps/desktop-shell/`。 @@ -273,6 +275,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳声明并实现 `file.importText`,通过系统文件选择框读取用户选择的文本文件,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露本机绝对路径,也不开放通用文件系统。H5 创作 Agent 工作台复用同一 HostBridge 文本导入入口和后端解析接口;普通浏览器、小程序和未声明该能力的裁剪壳继续保留原 `` 路径。 +2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。 + ### Phase 4:宿主能力扩展 - 移动端接入系统分享、推送、原生登录和渠道支付。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 57df11d03..2f941dd65 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -49,7 +49,7 @@ AI H5 sandbox - `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板,宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。 - `openHostShareGrid()`:微信小程序九宫格切图页。 - `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。 -- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。 +- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。 - `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo Haptics;H5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`。 - `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。 - `setHostAppTitle()`:原生 App 宿主的受控窗口标题入口。H5 主站会按当前平台阶段先同步 `document.title`,再通过 `app.setTitle` 请求宿主窗口标题同步;Tauri 桌面壳支持该能力,Expo 移动壳不声明时静默忽略。 diff --git a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx index b1c48754c..d674c2b88 100644 --- a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx +++ b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx @@ -2,11 +2,25 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime'; +import { + canPasteFromNativeHostClipboard, + readNativeHostClipboardText, +} from './platformProfileHostClipboard'; import { PlatformProfileReferralModal } from './PlatformProfileReferralModal'; +vi.mock('./platformProfileHostClipboard', () => ({ + canPasteFromNativeHostClipboard: vi.fn(() => false), + readNativeHostClipboardText: vi.fn(), +})); + +const canPasteFromNativeHostClipboardMock = vi.mocked( + canPasteFromNativeHostClipboard, +); +const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText); + function buildCenter( overrides: Partial = {}, ): ProfileReferralInviteCenterResponse { @@ -35,6 +49,11 @@ function buildCenter( } describe('PlatformProfileReferralModal', () => { + afterEach(() => { + vi.clearAllMocks(); + canPasteFromNativeHostClipboardMock.mockReturnValue(false); + }); + test('renders invite panel with shared profile content', () => { render( { expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1); }); + test('redeem panel can paste invite code from native host clipboard', async () => { + const user = userEvent.setup(); + const onRedeemCodeChange = vi.fn(); + canPasteFromNativeHostClipboardMock.mockReturnValue(true); + readNativeHostClipboardTextMock.mockResolvedValue('spring-2026'); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '粘贴' })); + + expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1); + expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026'); + }); + test('renders community QR panels', () => { render( { + void readNativeHostClipboardText().then((text) => { + if (text) { + onRedeemCodeChange(text); + } + }); + }; let content: ReactNode; @@ -178,6 +190,20 @@ export function PlatformProfileReferralModal({ autoComplete="off" autoFocus /> + {canPasteRedeemCode ? ( + + + 粘贴 + + ) : null} ({ + canPasteFromNativeHostClipboard: vi.fn(() => false), + readNativeHostClipboardText: vi.fn(), +})); + +const canPasteFromNativeHostClipboardMock = vi.mocked( + canPasteFromNativeHostClipboard, +); +const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText); + describe('PlatformProfileRewardCodeRedeemModal', () => { + afterEach(() => { + vi.clearAllMocks(); + canPasteFromNativeHostClipboardMock.mockReturnValue(false); + }); + test('submits on button click and enter key', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); @@ -50,6 +69,31 @@ describe('PlatformProfileRewardCodeRedeemModal', () => { expect( screen.getByRole('button', { name: '兑换' }).hasAttribute('disabled'), ).toBe(true); + expect(screen.queryByRole('button', { name: '粘贴' })).toBeNull(); + }); + + test('can paste reward code from native host clipboard', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + canPasteFromNativeHostClipboardMock.mockReturnValue(true); + readNativeHostClipboardTextMock.mockResolvedValue('reward-2026'); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '粘贴' })); + + expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('reward-2026'); }); test('reuses the shared profile modal footer chrome for submit action', () => { diff --git a/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx b/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx index a232f7a43..51994c250 100644 --- a/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx +++ b/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx @@ -1,6 +1,12 @@ +import { ClipboardPaste } from 'lucide-react'; + import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; +import { + canPasteFromNativeHostClipboard, + readNativeHostClipboardText, +} from './platformProfileHostClipboard'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; export type PlatformProfileRewardCodeRedeemModalProps = { @@ -26,6 +32,15 @@ export function PlatformProfileRewardCodeRedeemModal({ onSubmit, onClose, }: PlatformProfileRewardCodeRedeemModalProps) { + const canPasteRewardCode = canPasteFromNativeHostClipboard(); + const pasteRewardCode = () => { + void readNativeHostClipboardText().then((text) => { + if (text) { + onChange(text); + } + }); + }; + return ( + {canPasteRewardCode ? ( + + + 粘贴 + + ) : null} {error ? (