接入填码原生剪贴板读取

邀请码填写和兑换码弹窗在宿主声明 clipboard.readText 时显示粘贴入口

粘贴动作只读取宿主纯文本并填入现有受控输入框,不自动提交或伪造兑换成功

补充填码粘贴回归测试和 profile 剪贴板 helper

更新 Expo/Tauri 宿主壳方案、HostBridge 协议和共享决策记录
This commit is contained in:
2026-06-18 06:48:46 +08:00
parent a0497cb39d
commit e8c2e8d532
8 changed files with 176 additions and 5 deletions
@@ -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() : '';
}