接入填码原生剪贴板读取
邀请码填写和兑换码弹窗在宿主声明 clipboard.readText 时显示粘贴入口 粘贴动作只读取宿主纯文本并填入现有受控输入框,不自动提交或伪造兑换成功 补充填码粘贴回归测试和 profile 剪贴板 helper 更新 Expo/Tauri 宿主壳方案、HostBridge 协议和共享决策记录
This commit is contained in:
@@ -2326,3 +2326,10 @@
|
||||
- 决策:`RpgEntryHomeView` 的头像上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有头像读取、类型校验、5 MiB 大小限制、方形裁剪和 `updateAuthProfile({ avatarDataUrl })` 链路。宿主只负责受控图片选择,不暴露设备 URI、本机绝对路径或通用文件系统,也不新增 React Native / Tauri 专属头像编辑页;普通浏览器、小程序和未声明能力的裁剪壳继续使用原 `<input type="file">`。
|
||||
- 影响范围:`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`。
|
||||
|
||||
@@ -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 文本导入入口和后端解析接口;普通浏览器、小程序和未声明该能力的裁剪壳继续保留原 `<input type="file">` 路径。
|
||||
|
||||
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。
|
||||
|
||||
### Phase 4:宿主能力扩展
|
||||
|
||||
- 移动端接入系统分享、推送、原生登录和渠道支付。
|
||||
|
||||
@@ -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 移动壳不声明时静默忽略。
|
||||
|
||||
@@ -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> = {},
|
||||
): ProfileReferralInviteCenterResponse {
|
||||
@@ -35,6 +49,11 @@ function buildCenter(
|
||||
}
|
||||
|
||||
describe('PlatformProfileReferralModal', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canPasteFromNativeHostClipboardMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('renders invite panel with shared profile content', () => {
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
@@ -96,6 +115,35 @@ describe('PlatformProfileReferralModal', () => {
|
||||
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(
|
||||
<PlatformProfileReferralModal
|
||||
panel="redeem"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={onRedeemCodeChange}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '粘贴' }));
|
||||
|
||||
expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026');
|
||||
});
|
||||
|
||||
test('renders community QR panels', () => {
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { ClipboardPaste, Copy } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
@@ -15,6 +15,10 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type { CopyFeedbackState } from '../common/useCopyFeedback';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||
import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
|
||||
|
||||
@@ -105,6 +109,14 @@ export function PlatformProfileReferralModal({
|
||||
.trim()
|
||||
.replace(/[^0-9a-z]/gi, '')
|
||||
.toUpperCase();
|
||||
const canPasteRedeemCode = canPasteFromNativeHostClipboard();
|
||||
const pasteRedeemCode = () => {
|
||||
void readNativeHostClipboardText().then((text) => {
|
||||
if (text) {
|
||||
onRedeemCodeChange(text);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
|
||||
@@ -178,6 +190,20 @@ export function PlatformProfileReferralModal({
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{canPasteRedeemCode ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
surface="profile"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="w-full"
|
||||
onClick={pasteRedeemCode}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
粘贴
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
surface="profile"
|
||||
|
||||
@@ -2,11 +2,30 @@
|
||||
|
||||
import { render, screen } 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 {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
|
||||
|
||||
vi.mock('./platformProfileHostClipboard', () => ({
|
||||
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(
|
||||
<PlatformProfileRewardCodeRedeemModal
|
||||
value=""
|
||||
isSubmitting={false}
|
||||
error={null}
|
||||
success={null}
|
||||
onChange={onChange}
|
||||
onSubmit={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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 (
|
||||
<PlatformProfileModalShell
|
||||
title="兑换码"
|
||||
@@ -62,6 +77,20 @@ export function PlatformProfileRewardCodeRedeemModal({
|
||||
aria-label="兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
{canPasteRewardCode ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
surface="profile"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
fullWidth
|
||||
onClick={pasteRewardCode}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
粘贴
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
{error ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
readHostClipboardText,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
|
||||
export function canPasteFromNativeHostClipboard() {
|
||||
return canUseNativeHostCapability('clipboard.readText');
|
||||
}
|
||||
|
||||
export async function readNativeHostClipboardText() {
|
||||
const result = await readHostClipboardText();
|
||||
return result ? result.text.trim() : '';
|
||||
}
|
||||
Reference in New Issue
Block a user