1
This commit is contained in:
65
src/components/common/PublishShareModal.test.tsx
Normal file
65
src/components/common/PublishShareModal.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as clipboardService from '../../services/clipboard';
|
||||
import { PublishShareModal } from './PublishShareModal';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
|
||||
vi.mock('../../services/clipboard', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
const payload: PublishShareModalPayload = {
|
||||
title: '暖灯猫街',
|
||||
publicWorkCode: 'PZ-00000001',
|
||||
stage: 'puzzle-gallery-detail',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PublishShareModal', () => {
|
||||
test('builds the publish share text with title, code and public url', () => {
|
||||
const text = buildPublishShareText(payload);
|
||||
|
||||
expect(text).toContain('邀请你来玩《暖灯猫街》');
|
||||
expect(text).toContain('作品号:PZ-00000001');
|
||||
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
|
||||
});
|
||||
|
||||
test('renders share text and channel icons, then copies from main button', async () => {
|
||||
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
|
||||
|
||||
render(
|
||||
<PublishShareModal open payload={payload} onClose={() => {}} />,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||
expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
|
||||
|
||||
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-00000001'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/components/common/PublishShareModal.tsx
Normal file
145
src/components/common/PublishShareModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import {
|
||||
buildPublishShareText,
|
||||
type PublishShareModalPayload,
|
||||
} from './publishShareModalModel';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type PublishShareModalProps = {
|
||||
open: boolean;
|
||||
payload: PublishShareModalPayload | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const SHARE_CHANNELS = [
|
||||
{
|
||||
id: 'wechat',
|
||||
label: '微信',
|
||||
icon: MessageCircle,
|
||||
className: 'bg-emerald-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
label: 'QQ',
|
||||
icon: MessageCircle,
|
||||
className: 'bg-sky-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'douyin',
|
||||
label: '抖音',
|
||||
icon: Music2,
|
||||
className: 'bg-slate-950 text-white',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 发布完成后的分享弹窗。
|
||||
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK,可以只替换这里的渠道点击逻辑。
|
||||
*/
|
||||
export function PublishShareModal({
|
||||
open,
|
||||
payload,
|
||||
onClose,
|
||||
}: PublishShareModalProps) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const shareText = useMemo(
|
||||
() => (payload ? buildPublishShareText(payload) : ''),
|
||||
[payload],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [payload?.publicWorkCode]);
|
||||
|
||||
const copyShareText = () => {
|
||||
if (!shareText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
resetTimerRef.current = null;
|
||||
setCopyState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open && Boolean(payload)}
|
||||
title="分享给朋友"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
panelClassName="platform-remap-surface"
|
||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{SHARE_CHANNELS.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
|
||||
aria-label={`分享到${channel.label}`}
|
||||
title={channel.label}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span>{channel.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{shareText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
disabled={!shareText}
|
||||
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享'}
|
||||
</button>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
30
src/components/common/publishShareModalModel.ts
Normal file
30
src/components/common/publishShareModalModel.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
|
||||
|
||||
export type PublishShareModalPayload = {
|
||||
title: string;
|
||||
publicWorkCode: string;
|
||||
stage: SelectionStage;
|
||||
};
|
||||
|
||||
function buildShareUrl(payload: PublishShareModalPayload) {
|
||||
const sharePath = buildPublicWorkStagePath(
|
||||
payload.stage,
|
||||
payload.publicWorkCode,
|
||||
);
|
||||
|
||||
return typeof window === 'undefined'
|
||||
? sharePath
|
||||
: new URL(sharePath, window.location.origin).href;
|
||||
}
|
||||
|
||||
export function buildPublishShareText(payload: PublishShareModalPayload) {
|
||||
const publicWorkCode = payload.publicWorkCode.trim();
|
||||
const title = payload.title.trim() || '我的作品';
|
||||
|
||||
return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildShareUrl({
|
||||
...payload,
|
||||
publicWorkCode,
|
||||
title,
|
||||
})}`;
|
||||
}
|
||||
Reference in New Issue
Block a user