feat(分享): 新增分享模板风格选择与访客反馈功能
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good

新增分享模板风格选择功能,支持编辑、电影和剪贴簿三种风格
添加访客反馈系统,包括查看次数统计和留言功能
优化分享状态管理,区分公开、草稿和预览状态
扩展分享配置数据模型,支持模板风格和反馈统计
重构分享页面样式,根据模板风格应用不同主题
This commit is contained in:
2026-03-18 14:05:07 +08:00
parent e616ea375c
commit f8ab9966d4
15 changed files with 1198 additions and 135 deletions

View File

@@ -21,6 +21,12 @@ import {
type TimelineArchiveMoment,
type TimelineArchiveSummary,
} from '@/pages/story/service';
import {
getStoryPublicShareId,
getStoryShareActionLabel,
getStoryShareState,
getStoryShareStatusText,
} from '@/pages/story/utils/shareState';
import './index.less';
type ArchiveBucketType = 'location' | 'tag';
@@ -164,6 +170,8 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
const moments: TimelineArchiveMoment[] = [];
itemResponses.forEach(({ story, items }) => {
const publicShareId = getStoryPublicShareId(story);
const shareState = getStoryShareState(story);
items.forEach((item, index) => {
const preview = getPreviewMedia(item);
const tags = inferTags(item);
@@ -171,7 +179,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
const moment: TimelineArchiveMoment = {
key: item.instanceId || `${story.instanceId || 'story'}-${index}`,
storyInstanceId: story.instanceId,
storyShareId: story.shareId,
storyShareId: publicShareId,
shareConfigured: shareState !== 'preview',
sharePublished: shareState === 'public',
storyTitle: story.title || 'Untitled story',
storyTime: typeof story.storyTime === 'string' ? story.storyTime : undefined,
itemInstanceId: item.instanceId,
@@ -197,7 +207,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
count: 0,
subtitle: story.title || 'Untitled story',
storyInstanceId: story.instanceId,
storyShareId: story.shareId,
storyShareId: publicShareId,
shareConfigured: shareState !== 'preview',
sharePublished: shareState === 'public',
coverInstanceId: preview.coverInstanceId,
coverSrc: preview.coverSrc,
sampleMomentTitle: moment.itemTitle,
@@ -207,7 +219,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle;
current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId;
current.coverSrc = current.coverSrc || preview.coverSrc;
current.storyShareId = current.storyShareId || story.shareId;
current.storyShareId = current.storyShareId || publicShareId;
current.shareConfigured = current.shareConfigured || shareState !== 'preview';
current.sharePublished = current.sharePublished || shareState === 'public';
locationMap.set(item.location, current);
}
@@ -218,7 +232,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
count: 0,
subtitle: story.title || 'Untitled story',
storyInstanceId: story.instanceId,
storyShareId: story.shareId,
storyShareId: publicShareId,
shareConfigured: shareState !== 'preview',
sharePublished: shareState === 'public',
coverInstanceId: preview.coverInstanceId,
coverSrc: preview.coverSrc,
sampleMomentTitle: moment.itemTitle,
@@ -228,7 +244,9 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle;
current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId;
current.coverSrc = current.coverSrc || preview.coverSrc;
current.storyShareId = current.storyShareId || story.shareId;
current.storyShareId = current.storyShareId || publicShareId;
current.shareConfigured = current.shareConfigured || shareState !== 'preview';
current.sharePublished = current.sharePublished || shareState === 'public';
tagMap.set(tag, current);
});
});
@@ -240,7 +258,7 @@ const buildLocalArchiveSummary = async (): Promise<ArchivePageSummary> => {
locations: Array.from(locationMap.values()).sort((a, b) => b.count - a.count).slice(0, 8),
tags: Array.from(tagMap.values()).sort((a, b) => b.count - a.count).slice(0, 12),
moments: moments.sort((a, b) => b.sortValue - a.sortValue),
shareableStories: stories.filter((story) => Boolean(story.shareId)).length,
shareableStories: stories.filter((story) => getStoryShareState(story) !== 'preview').length,
videoMoments: moments.filter((moment) => moment.hasVideo).length,
};
};
@@ -469,7 +487,7 @@ const ArchivePage: React.FC = () => {
<p>
This cluster currently surfaces <strong>{matchedMoments.length}</strong> moments from
<strong> {data.storiesIndexed}</strong> indexed stories. <strong>{data.shareableStories}</strong>
{` `}stories are already close to public-share quality.
{` `}stories already have a saved share draft or a live public page.
</p>
<span>{activeBucket.subtitle}</span>
</div>
@@ -479,10 +497,19 @@ const ArchivePage: React.FC = () => {
) : matchedMoments.length > 0 ? (
<div className="smart-archive__moment-list">
{matchedMoments.map((moment) => {
const shareMeta = {
instanceId: moment.storyInstanceId,
publicShareId: moment.storyShareId,
shareConfigured: moment.shareConfigured,
sharePublished: moment.sharePublished,
};
const previewPath = moment.storyInstanceId
? `/share/preview/${moment.storyInstanceId}`
: '/story';
const shareState = getStoryShareState(shareMeta);
const sharePath = moment.storyShareId ? `/share/${moment.storyShareId}` : previewPath;
const shareActionLabel =
shareState === 'public' ? 'Open public share' : getStoryShareActionLabel(shareMeta);
return (
<article className="smart-archive__moment-card" key={moment.key}>
@@ -517,6 +544,7 @@ const ArchivePage: React.FC = () => {
<div className="smart-archive__moment-foot">
<span>{moment.itemTime}</span>
<span>{moment.mediaCount} media items</span>
<span>{getStoryShareStatusText(shareMeta)}</span>
</div>
<div className="smart-archive__moment-actions">
<Button
@@ -528,7 +556,7 @@ const ArchivePage: React.FC = () => {
Open story
</Button>
<Button onClick={() => history.push(sharePath)}>
<ShareAltOutlined /> {moment.storyShareId ? 'Open public share' : 'Open preview'}
<ShareAltOutlined /> {shareActionLabel}
</Button>
</div>
</div>
@@ -549,7 +577,7 @@ const ArchivePage: React.FC = () => {
<section className="smart-archive__footer-panel">
<CompassOutlined />
<div>
<strong>{data.shareableStories} stories are already close to being worth sharing outside the app</strong>
<strong>{data.shareableStories} stories already have a saved share draft or a live public page</strong>
<p>
Add stronger cover moments and clearer descriptions, then shape the share page. That is where
this product starts to feel memorable instead of merely functional.

View File

@@ -1,4 +1,4 @@
import type { ImageItem } from '@/pages/gallery/typings';
import type { ImageItem } from '@/pages/gallery/typings';
import type { StoryType } from '@/pages/story/data.d';
import { queryCurrent } from '@/pages/account/center/service';
import { queryTimelineList } from '@/pages/story/service';
@@ -375,4 +375,3 @@ const Home: React.FC = () => {
export default Home;

View File

@@ -4,6 +4,12 @@ import { Button, Empty, Skeleton, Tag } from 'antd';
import React from 'react';
import type { StoryType } from '@/pages/story/data.d';
import { queryTimelineList, queryTimelineYearlyReport } from '@/pages/story/service';
import {
getStoryShareActionLabel,
getStorySharePath,
getStoryShareState,
getStoryShareStatusText,
} from '@/pages/story/utils/shareState';
import './index.less';
type ReviewSummary = {
@@ -11,7 +17,7 @@ type ReviewSummary = {
stories: StoryType[];
totalStories: number;
totalMoments: number;
sharedStories: number;
shareReadyStories: number;
busiestMonth: { label: string; count: number };
monthlyCounts: Array<{ label: string; count: number }>;
featuredStory?: StoryType;
@@ -114,7 +120,7 @@ const YearlyReviewPage: React.FC = () => {
typeof report?.totalMoments === 'number'
? report.totalMoments
: inYear.reduce((sum, story) => sum + Number(story.itemCount || 0), 0),
sharedStories: inYear.filter((story) => Boolean(story.shareId)).length,
shareReadyStories: inYear.filter((story) => getStoryShareState(story) !== 'preview').length,
busiestMonth,
monthlyCounts,
featuredStory,
@@ -138,11 +144,8 @@ const YearlyReviewPage: React.FC = () => {
const maxCount = Math.max(...data.monthlyCounts.map((item) => item.count), 1);
const featuredStoryId = data.featuredStory?.instanceId;
const featuredSharePath = data.featuredStory?.shareId
? `/share/${data.featuredStory.shareId}`
: featuredStoryId
? `/share/preview/${featuredStoryId}`
: '/story';
const featuredSharePath = getStorySharePath(data.featuredStory) || (featuredStoryId ? `/share/preview/${featuredStoryId}` : '/story');
const featuredShareState = getStoryShareState(data.featuredStory);
return (
<div className="yearly-review">
@@ -155,7 +158,7 @@ const YearlyReviewPage: React.FC = () => {
<p>
You captured {data.totalStories} storylines and {data.totalMoments} moments this year.
{` `}
{data.sharedStories} of them are already close to public-share quality.
{data.shareReadyStories} of them already have a public share or a saved share draft.
{data.topLocation && data.topLocation !== '-' ? ` Top place: ${data.topLocation}.` : ''}
{data.topTag && data.topTag !== '-' ? ` Signature theme: ${data.topTag}.` : ''}
</p>
@@ -187,7 +190,7 @@ const YearlyReviewPage: React.FC = () => {
</article>
<article>
<span>Share-ready</span>
<strong>{data.sharedStories}</strong>
<strong>{data.shareReadyStories}</strong>
</article>
</section>
@@ -228,12 +231,12 @@ const YearlyReviewPage: React.FC = () => {
<div className="yearly-review__feature-card">
<div className="yearly-review__feature-meta">
<Tag bordered={false}>{data.featuredStory.ownerName || 'My story'}</Tag>
{data.featuredStory.shareId ? (
{featuredShareState === 'public' ? (
<span>
<ShareAltOutlined /> Public share is available
</span>
) : (
<span>Preview is ready even before the public endpoint is finished</span>
<span>{getStoryShareStatusText(data.featuredStory)}</span>
)}
</div>
<h3>{data.featuredStory.title || 'Untitled story'}</h3>
@@ -257,7 +260,7 @@ const YearlyReviewPage: React.FC = () => {
Keep editing
</Button>
<Button onClick={() => history.push(featuredSharePath)}>
<ShareAltOutlined /> {data.featuredStory.shareId ? 'Open public share' : 'Open preview'}
<ShareAltOutlined /> {featuredShareState === 'public' ? 'Open public share' : getStoryShareActionLabel(data.featuredStory)}
</Button>
</div>
</div>
@@ -271,4 +274,4 @@ const YearlyReviewPage: React.FC = () => {
);
};
export default YearlyReviewPage;
export default YearlyReviewPage;

View File

@@ -1,4 +1,5 @@
import TimelineImage from '@/components/TimelineImage';
import classNames from 'classnames';
import {
CalendarOutlined,
CameraOutlined,
@@ -32,6 +33,12 @@ const resolveVideoSrc = (videoInstanceId?: string) => {
const StoryShowcase: React.FC<StoryShowcaseProps> = ({ data, actions, embedded = false }) => {
const { styles } = useStyles();
const heroVideoSrc = resolveVideoSrc(data.hero?.videoInstanceId);
const themeClass =
data.templateStyle === 'cinematic'
? styles.themeCinematic
: data.templateStyle === 'scrapbook'
? styles.themeScrapbook
: styles.themeEditorial;
const showcaseCard = (
<div className={embedded ? styles.embeddedShell : styles.shell}>
@@ -234,10 +241,14 @@ const StoryShowcase: React.FC<StoryShowcaseProps> = ({ data, actions, embedded =
);
if (embedded) {
return <div className={styles.embeddedPage}>{showcaseCard}</div>;
return <div className={classNames(styles.embeddedPage, styles.themeBase, themeClass)}>{showcaseCard}</div>;
}
return <PageContainer ghost className={styles.sharePage}>{showcaseCard}</PageContainer>;
return (
<PageContainer ghost className={classNames(styles.sharePage, styles.themeBase, themeClass)}>
{showcaseCard}
</PageContainer>
);
};
export default StoryShowcase;
export default StoryShowcase;

View File

@@ -1,9 +1,17 @@
import { ArrowLeftOutlined, CopyOutlined, HomeOutlined } from '@ant-design/icons';
import { ArrowLeftOutlined, CopyOutlined, HomeOutlined, MessageOutlined, RiseOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, request, useParams, useRequest } from '@umijs/max';
import { Button, Result, Skeleton, message } from 'antd';
import React from 'react';
import { Avatar, Button, Input, Result, Skeleton, message } from 'antd';
import classNames from 'classnames';
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import StoryShowcase from './StoryShowcase';
import {
createPublicShareComment,
queryPublicShareFeedback,
recordPublicShareView,
type StoryShareFeedback,
} from './feedback';
import { normalizePublicShowcase } from './showcase-utils';
import useStyles from './style.style';
@@ -28,11 +36,50 @@ interface PublicStoryItem {
shareTitle?: string;
shareDescription?: string;
shareQuote?: string;
templateStyle?: 'editorial' | 'cinematic' | 'scrapbook';
heroMomentId?: string;
featuredMomentIds?: string[];
featuredMoments?: PublicStoryItem[];
}
const resolveMetaImageUrl = (
hero: {
imageInstanceId?: string;
src?: string;
thumbnailInstanceId?: string;
} | undefined,
) => {
if (!hero) return undefined;
const origin = typeof window !== 'undefined' ? window.location.origin : '';
if (hero.imageInstanceId) {
return origin ? `${origin}/api/file/image/${hero.imageInstanceId}` : `/api/file/image/${hero.imageInstanceId}`;
}
if (hero.thumbnailInstanceId) {
return origin
? `${origin}/api/file/image/${hero.thumbnailInstanceId}`
: `/api/file/image/${hero.thumbnailInstanceId}`;
}
if (!hero.src) {
return undefined;
}
if (hero.src.startsWith('http://') || hero.src.startsWith('https://')) {
return hero.src;
}
return origin ? `${origin}${hero.src.startsWith('/') ? hero.src : `/${hero.src}`}` : hero.src;
};
const getThemeColor = (templateStyle?: PublicStoryItem['templateStyle']) => {
switch (templateStyle) {
case 'cinematic':
return '#111827';
case 'scrapbook':
return '#fff3e0';
default:
return '#eef3f7';
}
};
const fetchShareStory = async (shareId: string) => {
const candidates = [
`/api/story/item/public/story/item/${shareId}`,
@@ -57,20 +104,91 @@ const fetchShareStory = async (shareId: string) => {
return undefined;
};
const normalizeFeedback = (response: unknown): StoryShareFeedback | undefined => {
if (!response || typeof response !== 'object') return undefined;
if ('shareId' in response) {
return response as StoryShareFeedback;
}
const firstLayer = (response as { data?: unknown }).data;
if (!firstLayer || typeof firstLayer !== 'object') {
return undefined;
}
if ('shareId' in firstLayer) {
return firstLayer as StoryShareFeedback;
}
const secondLayer = (firstLayer as { data?: unknown }).data;
if (secondLayer && typeof secondLayer === 'object' && 'shareId' in secondLayer) {
return secondLayer as StoryShareFeedback;
}
return undefined;
};
const GUEST_NAME_STORAGE_KEY = 'timeline_public_share_guest_name';
const VIEW_SESSION_PREFIX = 'timeline_public_share_viewed:';
const getStoredGuestName = () => {
if (typeof window === 'undefined') return '';
return window.localStorage.getItem(GUEST_NAME_STORAGE_KEY) || '';
};
const saveGuestName = (value: string) => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(GUEST_NAME_STORAGE_KEY, value);
};
const hasRecordedView = (shareId: string) => {
if (typeof window === 'undefined') return false;
return window.sessionStorage.getItem(`${VIEW_SESSION_PREFIX}${shareId}`) === '1';
};
const markRecordedView = (shareId: string) => {
if (typeof window === 'undefined') return;
window.sessionStorage.setItem(`${VIEW_SESSION_PREFIX}${shareId}`, '1');
};
const SharePage: React.FC = () => {
const { styles } = useStyles();
const { shareId } = useParams<{ shareId: string }>();
const [guestName, setGuestName] = useState(getStoredGuestName);
const [commentContent, setCommentContent] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const shareRequest = useRequest(
async () => {
if (!shareId) return undefined;
return fetchShareStory(shareId);
const shouldRecordView = !hasRecordedView(shareId);
const [story, feedbackResponse] = await Promise.all([
fetchShareStory(shareId),
shouldRecordView
? recordPublicShareView(shareId).then((response) => {
markRecordedView(shareId);
return response;
})
: queryPublicShareFeedback(shareId),
]);
return {
story,
feedback: normalizeFeedback(feedbackResponse),
};
},
{
refreshDeps: [shareId],
},
);
const storyItem = shareRequest.data as PublicStoryItem | undefined;
const payload = shareRequest.data as
| {
story?: PublicStoryItem;
feedback?: StoryShareFeedback;
}
| undefined;
const storyItem = payload?.story;
const feedback = payload?.feedback;
const { loading } = shareRequest;
if (loading) {
@@ -96,44 +214,199 @@ const SharePage: React.FC = () => {
);
}
const showcase = normalizePublicShowcase(storyItem, shareId);
const baseShowcase = normalizePublicShowcase(storyItem, shareId);
const showcase = feedback
? {
...baseShowcase,
stats: [
{ label: 'Views', value: feedback.viewCount || 0 },
{ label: 'Guest notes', value: feedback.commentCount || 0 },
...baseShowcase.stats,
],
}
: baseShowcase;
const pageUrl = typeof window !== 'undefined' ? window.location.href : `/share/${shareId}`;
const metaImageUrl = resolveMetaImageUrl(showcase.hero);
const themeColor = getThemeColor(showcase.templateStyle);
const themeClass =
showcase.templateStyle === 'cinematic'
? styles.themeCinematic
: showcase.templateStyle === 'scrapbook'
? styles.themeScrapbook
: styles.themeEditorial;
const comments = feedback?.comments || [];
const submitComment = async () => {
if (!shareId) return;
const content = commentContent.trim();
if (!content) {
message.warning('Write a short note before posting.');
return;
}
setCommentSubmitting(true);
try {
const response = await createPublicShareComment(shareId, {
visitorName: guestName.trim() || undefined,
content,
});
const nextFeedback = normalizeFeedback(response);
if (!nextFeedback) {
throw new Error('Missing feedback payload');
}
shareRequest.mutate({
story: storyItem,
feedback: nextFeedback,
});
saveGuestName(guestName.trim());
setCommentContent('');
message.success('Your note is now part of the share page.');
} catch (error) {
console.error('Failed to create public share comment', error);
message.error('Failed to post your note right now.');
} finally {
setCommentSubmitting(false);
}
};
return (
<>
<Helmet>
<title>{showcase.title}</title>
<meta name="description" content={showcase.description} />
<meta name="theme-color" content={themeColor} />
<meta property="og:title" content={showcase.title} />
<meta property="og:description" content={showcase.description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={pageUrl} />
{metaImageUrl && <meta property="og:image" content={metaImageUrl} />}
{metaImageUrl && <meta property="og:image:alt" content={showcase.title} />}
<meta name="twitter:card" content={metaImageUrl ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={showcase.title} />
<meta name="twitter:description" content={showcase.description} />
{metaImageUrl && <meta name="twitter:image" content={metaImageUrl} />}
</Helmet>
<StoryShowcase
data={showcase}
actions={[
{
key: 'home',
label: 'Open Timeline',
primary: true,
icon: <HomeOutlined />,
onClick: () => history.push('/home'),
},
{
key: 'copy',
label: 'Copy link',
icon: <CopyOutlined />,
onClick: async () => {
await navigator.clipboard.writeText(pageUrl);
message.success('Public share link copied');
<PageContainer ghost className={classNames(styles.sharePage, styles.themeBase, themeClass)}>
<StoryShowcase
data={showcase}
embedded
actions={[
{
key: 'home',
label: 'Open Timeline',
primary: true,
icon: <HomeOutlined />,
onClick: () => history.push('/home'),
},
},
{
key: 'back',
label: 'Back home',
icon: <ArrowLeftOutlined />,
onClick: () => history.push('/home'),
},
]}
/>
{
key: 'copy',
label: 'Copy link',
icon: <CopyOutlined />,
onClick: async () => {
await navigator.clipboard.writeText(pageUrl);
message.success('Public share link copied');
},
},
{
key: 'back',
label: 'Back home',
icon: <ArrowLeftOutlined />,
onClick: () => history.push('/home'),
},
]}
/>
<section className={styles.feedbackSection}>
<div className={styles.feedbackPanel}>
<div className={styles.feedbackHeader}>
<div>
<span className={styles.sectionEyebrow}>Audience Feedback</span>
<h2>Let the share page collect lightweight reactions</h2>
<p>
Public pages work better when they can gather a bit of proof of life. Views show reach,
and guest notes make the story feel seen.
</p>
</div>
<div className={styles.feedbackSummary}>
<article className={styles.feedbackSummaryItem}>
<span>Views</span>
<strong>{feedback?.viewCount || 0}</strong>
</article>
<article className={styles.feedbackSummaryItem}>
<span>Guest notes</span>
<strong>{feedback?.commentCount || 0}</strong>
</article>
</div>
</div>
<div className={styles.feedbackGrid}>
<div className={styles.feedbackForm}>
<p className={styles.feedbackHint}>
Leave a short message for the story owner. Names are optional, and this keeps the public
page lightweight instead of turning into a full social feed.
</p>
<label className={styles.feedbackLabel}>
<span>Your name</span>
<Input
maxLength={40}
placeholder="Guest"
value={guestName}
onChange={(event) => setGuestName(event.target.value)}
/>
</label>
<label className={styles.feedbackLabel}>
<span>Your note</span>
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 7 }}
maxLength={280}
placeholder="Share what stood out, what this memory reminded you of, or why this page felt worth opening."
value={commentContent}
onChange={(event) => setCommentContent(event.target.value)}
/>
</label>
<Button
type="primary"
onClick={() => void submitComment()}
loading={commentSubmitting}
icon={<MessageOutlined />}
>
Post note
</Button>
</div>
<div className={styles.feedbackList}>
{comments.length > 0 ? (
comments.map((comment) => (
<article className={styles.feedbackCard} key={comment.instanceId}>
<Avatar size={42}>{(comment.visitorName || 'G').slice(0, 1).toUpperCase()}</Avatar>
<div>
<div className={styles.feedbackMeta}>
<strong className={styles.feedbackName}>{comment.visitorName || 'Guest'}</strong>
<span className={styles.feedbackTime}>
{new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(comment.createTime))}
</span>
</div>
<p className={styles.feedbackContent}>{comment.content}</p>
</div>
</article>
))
) : (
<div className={styles.feedbackEmpty}>
<RiseOutlined /> This page has not received a guest note yet. The first thoughtful reaction
often makes sharing feel real.
</div>
)}
</div>
</div>
</div>
</section>
</PageContainer>
</>
);
};

View File

@@ -0,0 +1,61 @@
import { request } from '@umijs/max';
export type StoryShareGuestComment = {
instanceId: string;
shareId: string;
visitorName: string;
content: string;
createTime: string;
};
export type StoryShareFeedback = {
shareId: string;
viewCount: number;
commentCount: number;
comments: StoryShareGuestComment[];
};
export type StoryShareCommentPayload = {
visitorName?: string;
content: string;
};
const requestPublicFeedback = async <T>(
shareId: string,
path: string,
options?: Record<string, unknown>,
) => {
const candidates = [`/api/public/story/${shareId}${path}`, `/public/story/${shareId}${path}`];
for (const url of candidates) {
try {
return await request<T>(url, {
skipErrorHandler: true,
...options,
});
} catch (error) {
console.warn('Failed to request public share feedback from', url, error);
}
}
return undefined;
};
export const queryPublicShareFeedback = async (shareId: string, limit = 8) => {
return requestPublicFeedback(shareId, `/feedback?limit=${limit}`, { method: 'GET' });
};
export const recordPublicShareView = async (shareId: string, limit = 8) => {
return requestPublicFeedback(shareId, `/feedback/view?limit=${limit}`, { method: 'POST' });
};
export const createPublicShareComment = async (
shareId: string,
payload: StoryShareCommentPayload,
limit = 8,
) => {
return requestPublicFeedback(shareId, `/feedback/comment?limit=${limit}`, {
method: 'POST',
data: payload,
});
};

View File

@@ -4,10 +4,15 @@ import { Button, Result, Skeleton, message } from 'antd';
import React from 'react';
import { Helmet } from 'react-helmet';
import type { StoryItem, StoryType } from '@/pages/story/data.d';
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
import {
queryStoryDetail,
queryStoryItem,
queryStoryShareConfig,
type StoryShareConfig,
} from '@/pages/story/service';
import StoryShowcase from '../StoryShowcase';
import { buildPreviewShowcase } from '../showcase-utils';
import { loadShareDraft } from '../shareDraft';
import { buildShareDraftSeed, getPublicShareId, resolveShareDraft } from '../shareDraft';
import useStyles from '../style.style';
type StoryItemsResponse = {
@@ -17,25 +22,41 @@ type StoryItemsResponse = {
list?: StoryItem[];
};
type ShareConfigResponse = {
data?: StoryShareConfig;
};
const normalizeItems = (response: StoryItemsResponse | undefined) => {
if (Array.isArray(response?.data?.list)) return response.data.list;
if (Array.isArray(response?.list)) return response.list;
return [] as StoryItem[];
};
const normalizeShareConfig = (
response: ShareConfigResponse | StoryShareConfig | undefined,
): StoryShareConfig | undefined => {
if (!response) return undefined;
if ('storyId' in response) return response;
return response.data;
};
const PreviewSharePage: React.FC = () => {
const { styles } = useStyles();
const { storyId } = useParams<{ storyId: string }>();
const previewRequest = useRequest(
async () => {
if (!storyId) return undefined;
const [storyResponse, itemsResponse] = await Promise.all([
const [storyResponse, itemsResponse, shareConfigResponse] = await Promise.all([
queryStoryDetail(storyId),
queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 60 }),
queryStoryShareConfig(storyId).catch(() => undefined),
]);
return {
story: storyResponse.data as StoryType,
items: normalizeItems(itemsResponse as StoryItemsResponse),
shareConfig: normalizeShareConfig(
shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined,
),
};
},
{
@@ -43,7 +64,9 @@ const PreviewSharePage: React.FC = () => {
},
);
const payload = previewRequest.data as { story: StoryType; items: StoryItem[] } | undefined;
const payload = previewRequest.data as
| { story: StoryType; items: StoryItem[]; shareConfig?: StoryShareConfig }
| undefined;
const { loading } = previewRequest;
if (loading) {
@@ -69,10 +92,19 @@ const PreviewSharePage: React.FC = () => {
);
}
const draft = loadShareDraft(storyId);
const showcase = buildPreviewShowcase(payload.story, payload.items, draft);
const shareConfig = payload.shareConfig;
const draft = resolveShareDraft(storyId, buildShareDraftSeed(shareConfig));
const publicShareId = getPublicShareId(shareConfig, payload.story.shareId);
const showcase = buildPreviewShowcase(
{
...payload.story,
shareId: publicShareId,
},
payload.items,
draft,
);
const previewUrl = typeof window !== 'undefined' ? window.location.href : `/share/preview/${storyId}`;
const publicUrl = payload.story.shareId ? `/share/${payload.story.shareId}` : undefined;
const publicUrl = publicShareId ? `/share/${publicShareId}` : undefined;
const studioUrl = `/share/studio/${storyId}`;
return (
@@ -129,4 +161,4 @@ const PreviewSharePage: React.FC = () => {
);
};
export default PreviewSharePage;
export default PreviewSharePage;

View File

@@ -1,13 +1,18 @@
import type { StoryShareConfig } from '@/pages/story/service';
export type ShareDraft = {
storyId: string;
title?: string;
description?: string;
quote?: string;
templateStyle?: 'editorial' | 'cinematic' | 'scrapbook';
heroMomentId?: string;
featuredMomentIds?: string[];
updatedAt?: string;
};
export type ShareDraftSeed = Omit<ShareDraft, 'storyId'>;
const STORAGE_KEY = 'timeline_share_drafts_v1';
type DraftMap = Record<string, ShareDraft>;
@@ -57,3 +62,70 @@ export const clearShareDraft = (storyId: string) => {
delete draftMap[storyId];
writeDraftMap(draftMap);
};
export const buildShareDraftSeed = (
shareConfig?: StoryShareConfig,
): ShareDraftSeed | undefined => {
if (!shareConfig) return undefined;
return {
title: shareConfig.title,
description: shareConfig.description,
quote: shareConfig.quote,
templateStyle: shareConfig.templateStyle,
heroMomentId: shareConfig.heroMomentId,
featuredMomentIds: shareConfig.featuredMomentIds || [],
updatedAt: shareConfig.updatedAt,
};
};
export const getPublicShareId = (
shareConfig?: Pick<StoryShareConfig, 'publicShareId' | 'published' | 'shareId'>,
fallbackShareId?: string,
) => {
if (shareConfig?.publicShareId) {
return shareConfig.publicShareId;
}
if (shareConfig?.published && shareConfig.shareId) {
return shareConfig.shareId;
}
return fallbackShareId;
};
const getUpdatedAtValue = (updatedAt?: string) => {
if (!updatedAt) return 0;
const timestamp = new Date(updatedAt).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
};
export const resolveShareDraft = (
storyId: string,
remoteDraft?: ShareDraftSeed,
): ShareDraft | undefined => {
const localDraft = loadShareDraft(storyId);
if (!remoteDraft) {
return localDraft;
}
const normalizedRemoteDraft: ShareDraft = {
storyId,
title: remoteDraft.title,
description: remoteDraft.description,
quote: remoteDraft.quote,
templateStyle: remoteDraft.templateStyle,
heroMomentId: remoteDraft.heroMomentId,
featuredMomentIds: remoteDraft.featuredMomentIds || [],
updatedAt: remoteDraft.updatedAt,
};
if (!localDraft) {
return normalizedRemoteDraft;
}
return getUpdatedAtValue(localDraft.updatedAt) >= getUpdatedAtValue(remoteDraft.updatedAt)
? localDraft
: normalizedRemoteDraft;
};

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import type { StoryItem, StoryType } from '@/pages/story/data.d';
import type { ShareTemplateStyle } from '@/pages/story/service';
import type { ShareDraft } from './shareDraft';
export type ShareAction = {
@@ -37,6 +38,7 @@ export type ShareMoment = {
};
export type StoryShowcaseData = {
templateStyle: ShareTemplateStyle;
badge: string;
title: string;
description: string;
@@ -73,11 +75,14 @@ type PublicStoryItem = {
shareTitle?: string;
shareDescription?: string;
shareQuote?: string;
templateStyle?: ShareTemplateStyle;
heroMomentId?: string;
featuredMomentIds?: string[];
featuredMoments?: PublicStoryItem[];
};
export const DEFAULT_SHARE_TEMPLATE: ShareTemplateStyle = 'editorial';
const pad = (value: number) => String(value).padStart(2, '0');
export const formatStoryTime = (value?: string | number[]) => {
@@ -235,6 +240,7 @@ export const buildPreviewShowcase = (
'Shape one strong story page and the whole product starts to feel more shareable.';
return {
templateStyle: draft?.templateStyle || DEFAULT_SHARE_TEMPLATE,
badge: story.shareId ? 'Share Preview' : 'Story Preview',
title: draftTitle || story.title || 'Untitled story',
description:
@@ -294,6 +300,7 @@ export const normalizePublicShowcase = (
const locationLabel = buildLocationLabel(featuredMoments, storyItem.location);
return {
templateStyle: storyItem.templateStyle || DEFAULT_SHARE_TEMPLATE,
badge: 'Public Share',
title: storyItem.shareTitle || storyItem.title || 'Memory share',
description,

View File

@@ -5,6 +5,7 @@ import {
DeleteOutlined,
EyeOutlined,
PlayCircleOutlined,
RiseOutlined,
SaveOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
@@ -22,8 +23,19 @@ import {
unpublishStoryShare,
} from '@/pages/story/service';
import StoryShowcase from '../StoryShowcase';
import { buildPreviewShowcase, formatStoryTime } from '../showcase-utils';
import { clearShareDraft, loadShareDraft, saveShareDraft, type ShareDraft } from '../shareDraft';
import {
buildPreviewShowcase,
DEFAULT_SHARE_TEMPLATE,
formatStoryTime,
} from '../showcase-utils';
import {
buildShareDraftSeed,
clearShareDraft,
getPublicShareId,
resolveShareDraft,
saveShareDraft,
type ShareDraft,
} from '../shareDraft';
import useStyles from '../style.style';
const { TextArea } = Input;
@@ -48,10 +60,33 @@ type DraftFormState = {
title: string;
description: string;
quote: string;
templateStyle: 'editorial' | 'cinematic' | 'scrapbook';
heroMomentId?: string;
featuredMomentIds: string[];
};
const TEMPLATE_OPTIONS: Array<{
value: DraftFormState['templateStyle'];
title: string;
description: string;
}> = [
{
value: 'editorial',
title: 'Editorial',
description: 'Clean, magazine-like, and balanced for most stories.',
},
{
value: 'cinematic',
title: 'Cinematic',
description: 'Darker, moodier, and more dramatic for standout visual stories.',
},
{
value: 'scrapbook',
title: 'Scrapbook',
description: 'Warmer, softer, and more personal for intimate memory pages.',
},
];
const normalizeItems = (response: StoryItemsResponse | undefined) => {
if (Array.isArray(response?.data?.list)) return response.data.list;
if (Array.isArray(response?.list)) return response.list;
@@ -66,8 +101,8 @@ const normalizeStory = (response: StoryDetailResponse | undefined) => {
const normalizeShareConfig = (response: { data?: StoryShareConfig } | StoryShareConfig | undefined) => {
if (!response) return undefined;
if ('data' in response) return response.data;
return response;
if ('storyId' in response) return response;
return response.data;
};
const getMomentPreview = (item: StoryItem) => {
@@ -98,13 +133,25 @@ const getDefaultFeaturedMomentIds = (items: StoryItem[], heroMomentId?: string)
return result;
};
const formatFeedbackTime = (value?: string) => {
if (!value) return 'Recently';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const buildInitialDraft = (
storyId: string,
story: StoryType,
items: StoryItem[],
shareConfig?: StoryShareConfig,
) => {
const saved = loadShareDraft(storyId);
const saved = resolveShareDraft(storyId, buildShareDraftSeed(shareConfig));
const configFeaturedMomentIds = shareConfig?.featuredMomentIds?.filter(Boolean) || [];
const defaultHero = shareConfig?.heroMomentId || items.find(hasPreviewMedia)?.instanceId;
const featuredMomentIds =
@@ -116,6 +163,7 @@ const buildInitialDraft = (
title: saved?.title || shareConfig?.title || story.title || '',
description: saved?.description || shareConfig?.description || story.description || '',
quote: saved?.quote || shareConfig?.quote || story.description || '',
templateStyle: saved?.templateStyle || shareConfig?.templateStyle || DEFAULT_SHARE_TEMPLATE,
heroMomentId: saved?.heroMomentId || shareConfig?.heroMomentId || defaultHero,
featuredMomentIds,
} satisfies DraftFormState;
@@ -128,6 +176,7 @@ const ShareStudioPage: React.FC = () => {
title: '',
description: '',
quote: '',
templateStyle: DEFAULT_SHARE_TEMPLATE,
heroMomentId: undefined,
featuredMomentIds: [],
});
@@ -144,10 +193,13 @@ const ShareStudioPage: React.FC = () => {
queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 80 }),
queryStoryShareConfig(storyId).catch(() => undefined),
]);
const shareConfig = normalizeShareConfig(
shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined,
);
return {
story: normalizeStory(storyResponse as StoryDetailResponse),
items: normalizeItems(itemsResponse as StoryItemsResponse),
shareConfig: normalizeShareConfig(shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined),
shareConfig,
};
},
{
@@ -164,7 +216,7 @@ const ShareStudioPage: React.FC = () => {
if (!storyId || !payload?.story) return;
setPersistedShareConfig(payload.shareConfig);
setDraft(buildInitialDraft(storyId, payload.story, payload.items, payload.shareConfig));
setActiveShareId(payload.story.shareId);
setActiveShareId(getPublicShareId(payload.shareConfig, payload.story.shareId));
}, [payload, storyId]);
const showcaseDraft = useMemo<ShareDraft | undefined>(() => {
@@ -174,9 +226,10 @@ const ShareStudioPage: React.FC = () => {
title: draft.title,
description: draft.description,
quote: draft.quote,
templateStyle: draft.templateStyle,
heroMomentId: draft.heroMomentId,
featuredMomentIds: draft.featuredMomentIds,
updatedAt: loadShareDraft(storyId)?.updatedAt,
updatedAt: resolveShareDraft(storyId, buildShareDraftSeed(payload.shareConfig))?.updatedAt,
};
}, [draft, payload, storyId]);
@@ -208,6 +261,10 @@ const ShareStudioPage: React.FC = () => {
const previewUrl = `/share/preview/${storyId}`;
const coverCandidates = payload.items.filter(hasPreviewMedia).slice(0, 18);
const spotlightCandidates = payload.items.filter((item) => Boolean(item.instanceId)).slice(0, 18);
const feedbackComments = persistedShareConfig?.recentComments || [];
const feedbackViewCount = Number(persistedShareConfig?.viewCount || 0);
const feedbackCommentCount = Number(persistedShareConfig?.commentCount || 0);
const hasFeedbackHistory = feedbackViewCount > 0 || feedbackCommentCount > 0 || feedbackComments.length > 0;
const copyLink = async (relativeUrl: string, successMessage: string) => {
const fullUrl = `${window.location.origin}${relativeUrl}`;
@@ -227,6 +284,7 @@ const ShareStudioPage: React.FC = () => {
title: draft.title.trim(),
description: draft.description.trim(),
quote: draft.quote.trim(),
templateStyle: draft.templateStyle,
heroMomentId: draft.heroMomentId,
featuredMomentIds: draft.featuredMomentIds,
updatedAt: new Date().toISOString(),
@@ -241,6 +299,7 @@ const ShareStudioPage: React.FC = () => {
title: draft.title.trim(),
description: draft.description.trim(),
quote: draft.quote.trim(),
templateStyle: draft.templateStyle,
heroMomentId: draft.heroMomentId,
featuredMomentIds: draft.featuredMomentIds,
updatedAt: new Date().toISOString(),
@@ -292,6 +351,7 @@ const ShareStudioPage: React.FC = () => {
title: nextDraft.title,
description: nextDraft.description,
quote: nextDraft.quote,
templateStyle: nextDraft.templateStyle,
featuredMomentIds: nextDraft.featuredMomentIds,
});
const nextShareId = response?.data?.shareId;
@@ -301,14 +361,19 @@ const ShareStudioPage: React.FC = () => {
setActiveShareId(nextShareId);
setPersistedShareConfig({
shareId: nextShareId,
publicShareId: nextShareId,
storyId,
heroMomentId: draft.heroMomentId,
title: nextDraft.title,
description: nextDraft.description,
quote: nextDraft.quote,
templateStyle: nextDraft.templateStyle,
featuredMomentIds: nextDraft.featuredMomentIds || [],
published: true,
updatedAt: new Date().toISOString(),
viewCount: persistedShareConfig?.viewCount || 0,
commentCount: persistedShareConfig?.commentCount || 0,
recentComments: persistedShareConfig?.recentComments || [],
});
message.success(shareReady ? 'Public share updated.' : 'Public share is now live.');
} catch (error) {
@@ -328,13 +393,19 @@ const ShareStudioPage: React.FC = () => {
current
? {
...current,
shareId: current.shareId || activeShareId,
publicShareId: undefined,
heroMomentId: draft.heroMomentId,
title: draft.title.trim(),
description: draft.description.trim(),
quote: draft.quote.trim(),
templateStyle: draft.templateStyle,
featuredMomentIds: draft.featuredMomentIds,
published: false,
updatedAt: new Date().toISOString(),
viewCount: current.viewCount || 0,
commentCount: current.commentCount || 0,
recentComments: current.recentComments || [],
}
: undefined,
);
@@ -382,7 +453,9 @@ const ShareStudioPage: React.FC = () => {
description={
shareReady
? `This story is now reachable on ${publicUrl}. Republishing keeps the same public link and refreshes the hero moment.`
: 'Draft details still stay in local storage, and publishing now creates a real public link from the selected hero moment.'
: persistedShareConfig?.shareId
? 'The public link is currently turned off, but the saved share draft and any past audience feedback are still preserved here.'
: 'Draft details still stay in local storage, and publishing now creates a real public link from the selected hero moment.'
}
type={shareReady ? 'success' : 'info'}
showIcon
@@ -396,6 +469,49 @@ const ShareStudioPage: React.FC = () => {
showIcon
/>
<div className={styles.studioSectionHead}>
<div>
<span className={styles.sectionEyebrow}>Audience Signals</span>
<h3>See whether the public page is actually landing</h3>
</div>
<span>{shareReady ? 'Live feedback updates on the public route' : 'Feedback history stays attached to the saved share id'}</span>
</div>
<div className={styles.studioInsightGrid}>
<article className={styles.studioInsightCard}>
<span>Public views</span>
<strong>{feedbackViewCount}</strong>
</article>
<article className={styles.studioInsightCard}>
<span>Guest notes</span>
<strong>{feedbackCommentCount}</strong>
</article>
<article className={styles.studioInsightCard}>
<span>Current state</span>
<strong>{shareReady ? 'Live' : persistedShareConfig?.shareId ? 'Paused' : 'Draft'}</strong>
</article>
</div>
{feedbackComments.length > 0 ? (
<div className={styles.studioCommentList}>
{feedbackComments.map((comment) => (
<article className={styles.studioCommentCard} key={comment.instanceId}>
<div className={styles.studioCommentMeta}>
<strong className={styles.studioCommentName}>{comment.visitorName || 'Guest'}</strong>
<span className={styles.studioCommentTime}>{formatFeedbackTime(comment.createTime)}</span>
</div>
<p className={styles.studioCommentBody}>{comment.content}</p>
</article>
))}
</div>
) : (
<p className={styles.studioHint}>
{hasFeedbackHistory
? 'Feedback counters are available, but there are no recent guest notes in the current summary window.'
: 'Once the public page starts getting opened and commented on, the strongest audience signals will show up here.'}
</p>
)}
<div className={styles.studioFields}>
<label className={styles.studioField}>
<span>Preview title</span>
@@ -429,6 +545,31 @@ const ShareStudioPage: React.FC = () => {
</label>
</div>
<div className={styles.studioSectionHead}>
<div>
<span className={styles.sectionEyebrow}>Template</span>
<h3>Choose the page mood</h3>
</div>
<span>{draft.templateStyle}</span>
</div>
<div className={styles.templateGrid}>
{TEMPLATE_OPTIONS.map((option) => {
const active = draft.templateStyle === option.value;
return (
<button
className={`${styles.templateOption} ${active ? styles.templateOptionActive : ''}`}
key={option.value}
onClick={() => setDraft((current) => ({ ...current, templateStyle: option.value }))}
type="button"
>
<strong>{option.title}</strong>
<span>{option.description}</span>
</button>
);
})}
</div>
<div className={styles.studioSectionHead}>
<div>
<span className={styles.sectionEyebrow}>Cover Selection</span>
@@ -555,6 +696,9 @@ const ShareStudioPage: React.FC = () => {
<Button icon={<CopyOutlined />} onClick={() => void copyLink(publicUrl, 'Public link copied')}>
Copy public link
</Button>
<Button icon={<RiseOutlined />} onClick={() => void studioRequest.refresh()}>
Refresh signals
</Button>
<Button danger loading={unpublishing} onClick={() => void handleUnpublish()}>
Turn off public share
</Button>

View File

@@ -1,13 +1,79 @@
import { createStyles } from 'antd-style';
export default createStyles(({ css, token }) => ({
sharePage: css`
min-height: 100vh;
padding: 24px 0 48px;
background:
themeBase: css`
--share-page-bg:
radial-gradient(circle at top left, rgba(17, 78, 121, 0.16), transparent 28%),
radial-gradient(circle at bottom right, rgba(201, 111, 67, 0.14), transparent 24%),
linear-gradient(180deg, #f7fbff 0%, #eef3f7 100%);
--share-card-bg: rgba(255, 255, 255, 0.9);
--share-card-shadow: 0 28px 80px rgba(15, 23, 42, 0.12);
--share-badge-bg: rgba(15, 23, 42, 0.08);
--share-badge-color: ${token.colorText};
--share-text-color: ${token.colorText};
--share-text-secondary: ${token.colorTextSecondary};
--share-text-tertiary: ${token.colorTextTertiary};
--share-border-color: rgba(15, 23, 42, 0.08);
--share-chip-bg: rgba(255, 255, 255, 0.76);
--share-title-font: Georgia, 'Times New Roman', 'Songti SC', serif;
--share-cover-bg: linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.68));
--share-stat-bg: rgba(255, 255, 255, 0.8);
--share-quote-bg: linear-gradient(145deg, rgba(201, 111, 67, 0.12), rgba(17, 78, 121, 0.12));
--share-moment-bg: rgba(255, 255, 255, 0.78);
--share-footer-bg: ${token.colorFillAlter};
`,
themeEditorial: css`
--share-page-bg:
radial-gradient(circle at top left, rgba(17, 78, 121, 0.16), transparent 28%),
radial-gradient(circle at bottom right, rgba(201, 111, 67, 0.14), transparent 24%),
linear-gradient(180deg, #f7fbff 0%, #eef3f7 100%);
`,
themeCinematic: css`
--share-page-bg:
radial-gradient(circle at 20% 0%, rgba(245, 158, 11, 0.16), transparent 24%),
radial-gradient(circle at 100% 100%, rgba(127, 29, 29, 0.2), transparent 30%),
linear-gradient(180deg, #0b1220 0%, #1a2235 100%);
--share-card-bg: rgba(10, 15, 28, 0.84);
--share-card-shadow: 0 30px 90px rgba(2, 6, 23, 0.48);
--share-badge-bg: rgba(245, 158, 11, 0.16);
--share-badge-color: #f8d38b;
--share-text-color: #f8fafc;
--share-text-secondary: rgba(226, 232, 240, 0.78);
--share-text-tertiary: rgba(226, 232, 240, 0.58);
--share-border-color: rgba(148, 163, 184, 0.18);
--share-chip-bg: rgba(15, 23, 42, 0.48);
--share-title-font: Georgia, 'Times New Roman', 'Songti SC', serif;
--share-cover-bg: linear-gradient(135deg, rgba(17, 24, 39, 0.96), rgba(120, 53, 15, 0.76));
--share-stat-bg: rgba(17, 24, 39, 0.72);
--share-quote-bg: linear-gradient(145deg, rgba(120, 53, 15, 0.28), rgba(30, 41, 59, 0.36));
--share-moment-bg: rgba(15, 23, 42, 0.72);
--share-footer-bg: rgba(15, 23, 42, 0.7);
`,
themeScrapbook: css`
--share-page-bg:
radial-gradient(circle at top right, rgba(236, 72, 153, 0.12), transparent 26%),
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.14), transparent 28%),
linear-gradient(180deg, #fff7ef 0%, #f5efe7 100%);
--share-card-bg: rgba(255, 252, 248, 0.92);
--share-card-shadow: 0 22px 70px rgba(120, 53, 15, 0.16);
--share-badge-bg: rgba(236, 72, 153, 0.12);
--share-badge-color: #9d174d;
--share-text-color: #3f2b1e;
--share-text-secondary: rgba(92, 63, 44, 0.78);
--share-text-tertiary: rgba(92, 63, 44, 0.58);
--share-border-color: rgba(120, 53, 15, 0.12);
--share-chip-bg: rgba(255, 250, 244, 0.84);
--share-title-font: 'Trebuchet MS', 'Avenir Next', 'PingFang SC', sans-serif;
--share-cover-bg: linear-gradient(135deg, rgba(252, 211, 77, 0.28), rgba(236, 72, 153, 0.22));
--share-stat-bg: rgba(255, 248, 240, 0.9);
--share-quote-bg: linear-gradient(145deg, rgba(251, 191, 36, 0.18), rgba(14, 165, 233, 0.14));
--share-moment-bg: rgba(255, 251, 246, 0.86);
--share-footer-bg: rgba(255, 243, 224, 0.88);
`,
sharePage: css`
min-height: 100vh;
padding: 24px 0 48px;
background: var(--share-page-bg);
`,
embeddedPage: css`
background: transparent;
@@ -23,8 +89,8 @@ export default createStyles(({ css, token }) => ({
card: css`
border-radius: 32px;
overflow: hidden;
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.12);
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--share-card-shadow);
background: var(--share-card-bg);
backdrop-filter: blur(18px);
`,
heroHeader: css`
@@ -39,20 +105,20 @@ export default createStyles(({ css, token }) => ({
min-width: 280px;
`,
heroBadge: css`
background: rgba(15, 23, 42, 0.08);
color: ${token.colorText};
background: var(--share-badge-bg);
color: var(--share-badge-color);
border-radius: 999px;
padding: 6px 12px;
`,
title: css`
margin: 14px 0 10px !important;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
font-family: var(--share-title-font);
letter-spacing: -0.03em;
`,
description: css`
max-width: 780px;
font-size: 16px;
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
line-height: 1.8;
`,
metaRow: css`
@@ -70,11 +136,11 @@ export default createStyles(({ css, token }) => ({
strong {
display: block;
color: ${token.colorText};
color: var(--share-text-color);
}
span {
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
font-size: 13px;
}
`,
@@ -84,9 +150,9 @@ export default createStyles(({ css, token }) => ({
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(15, 23, 42, 0.08);
color: ${token.colorTextSecondary};
background: var(--share-chip-bg);
border: 1px solid var(--share-border-color);
color: var(--share-text-secondary);
`,
actionRow: css`
display: flex;
@@ -104,7 +170,7 @@ export default createStyles(({ css, token }) => ({
min-height: 420px;
border-radius: 28px;
overflow: hidden;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.68));
background: var(--share-cover-bg);
position: relative;
`,
heroImage: css`
@@ -135,19 +201,19 @@ export default createStyles(({ css, token }) => ({
grid-template-columns: repeat(2, minmax(0, 1fr));
`,
statCard: css`
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(15, 23, 42, 0.08);
background: var(--share-stat-bg);
border: 1px solid var(--share-border-color);
border-radius: 22px;
min-height: 118px;
padding: 18px;
span {
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
display: block;
}
strong {
color: ${token.colorText};
color: var(--share-text-color);
display: block;
font-size: clamp(24px, 3vw, 36px);
margin-top: 14px;
@@ -155,14 +221,14 @@ export default createStyles(({ css, token }) => ({
}
`,
quoteCard: css`
background: linear-gradient(145deg, rgba(201, 111, 67, 0.12), rgba(17, 78, 121, 0.12));
border: 1px solid rgba(15, 23, 42, 0.08);
background: var(--share-quote-bg);
border: 1px solid var(--share-border-color);
border-radius: 22px;
padding: 20px;
p {
margin: 12px 0 0;
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
line-height: 1.9;
font-size: 15px;
}
@@ -179,8 +245,8 @@ export default createStyles(({ css, token }) => ({
h2 {
margin: 6px 0 0;
color: ${token.colorText};
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
color: var(--share-text-color);
font-family: var(--share-title-font);
font-size: 30px;
letter-spacing: -0.03em;
}
@@ -190,11 +256,11 @@ export default createStyles(({ css, token }) => ({
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: ${token.colorTextTertiary};
color: var(--share-text-tertiary);
font-weight: 700;
`,
sectionMeta: css`
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
`,
momentList: css`
display: grid;
@@ -204,8 +270,8 @@ export default createStyles(({ css, token }) => ({
display: grid;
gap: 16px;
grid-template-columns: 220px minmax(0, 1fr);
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(15, 23, 42, 0.08);
background: var(--share-moment-bg);
border: 1px solid var(--share-border-color);
border-radius: 24px;
padding: 16px;
`,
@@ -238,7 +304,7 @@ export default createStyles(({ css, token }) => ({
align-items: center;
justify-content: center;
background: linear-gradient(145deg, rgba(17, 78, 121, 0.14), rgba(201, 111, 67, 0.16));
color: ${token.colorText};
color: var(--share-text-color);
font-size: 64px;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
`,
@@ -260,14 +326,14 @@ export default createStyles(({ css, token }) => ({
h3 {
margin: 12px 0 8px;
color: ${token.colorText};
color: var(--share-text-color);
font-size: 28px;
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
font-family: var(--share-title-font);
letter-spacing: -0.03em;
}
p {
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
line-height: 1.8;
margin: 0;
flex: 1;
@@ -280,14 +346,14 @@ export default createStyles(({ css, token }) => ({
flex-wrap: wrap;
span {
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
}
`,
momentFoot: css`
display: flex;
gap: 16px;
flex-wrap: wrap;
border-top: 1px solid rgba(15, 23, 42, 0.08);
border-top: 1px solid var(--share-border-color);
padding-top: 14px;
margin-top: 16px;
@@ -295,7 +361,7 @@ export default createStyles(({ css, token }) => ({
display: inline-flex;
align-items: center;
gap: 6px;
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
}
`,
gallery: css`
@@ -323,7 +389,7 @@ export default createStyles(({ css, token }) => ({
margin-top: 28px;
padding: 18px 20px;
border-radius: 20px;
background: ${token.colorFillAlter};
background: var(--share-footer-bg);
display: flex;
align-items: center;
gap: 10px;
@@ -384,7 +450,7 @@ export default createStyles(({ css, token }) => ({
gap: 8px;
span {
color: ${token.colorText};
color: var(--share-text-color);
font-weight: 600;
}
`,
@@ -397,14 +463,14 @@ export default createStyles(({ css, token }) => ({
h3 {
margin: 6px 0 0;
color: ${token.colorText};
color: var(--share-text-color);
font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
font-size: 24px;
letter-spacing: -0.03em;
}
span {
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
}
`,
coverGrid: css`
@@ -415,7 +481,7 @@ export default createStyles(({ css, token }) => ({
coverOption: css`
appearance: none;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(15, 23, 42, 0.08);
border: 1px solid var(--share-border-color);
border-radius: 20px;
cursor: pointer;
padding: 10px;
@@ -475,12 +541,12 @@ export default createStyles(({ css, token }) => ({
margin-top: 10px;
strong {
color: ${token.colorText};
color: var(--share-text-color);
font-size: 14px;
}
span {
color: ${token.colorTextSecondary};
color: var(--share-text-secondary);
font-size: 12px;
}
`,
@@ -490,6 +556,237 @@ export default createStyles(({ css, token }) => ({
gap: 12px;
margin-top: 22px;
`,
templateGrid: css`
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
`,
templateOption: css`
appearance: none;
text-align: left;
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.82);
padding: 16px;
cursor: pointer;
transition:
transform 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
&:hover {
transform: translateY(-1px);
border-color: rgba(17, 78, 121, 0.28);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
}
strong {
display: block;
color: ${token.colorText};
margin-bottom: 8px;
}
span {
color: ${token.colorTextSecondary};
line-height: 1.6;
font-size: 13px;
}
`,
templateOptionActive: css`
border-color: rgba(17, 78, 121, 0.34);
background: linear-gradient(145deg, rgba(17, 78, 121, 0.12), rgba(255, 255, 255, 0.96));
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.08);
`,
studioInsightGrid: css`
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 18px;
`,
studioInsightCard: css`
border-radius: 22px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.82);
padding: 16px;
span {
display: block;
color: ${token.colorTextSecondary};
font-size: 12px;
}
strong {
display: block;
color: ${token.colorText};
font-size: 28px;
margin-top: 10px;
line-height: 1;
}
`,
studioHint: css`
margin: 0;
color: ${token.colorTextSecondary};
line-height: 1.7;
`,
studioCommentList: css`
display: grid;
gap: 12px;
`,
studioCommentCard: css`
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.82);
padding: 16px;
`,
studioCommentMeta: css`
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 8px;
`,
studioCommentName: css`
color: ${token.colorText};
font-weight: 700;
`,
studioCommentTime: css`
color: ${token.colorTextTertiary};
font-size: 12px;
`,
studioCommentBody: css`
margin: 0;
color: ${token.colorTextSecondary};
line-height: 1.75;
white-space: pre-wrap;
`,
feedbackSection: css`
max-width: 1180px;
margin: 20px auto 0;
padding: 0 16px;
`,
feedbackPanel: css`
border-radius: 28px;
border: 1px solid var(--share-border-color);
background: var(--share-card-bg);
backdrop-filter: blur(18px);
box-shadow: var(--share-card-shadow);
padding: 24px;
`,
feedbackHeader: css`
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
flex-wrap: wrap;
margin-bottom: 18px;
h2 {
margin: 6px 0 0;
color: var(--share-text-color);
font-family: var(--share-title-font);
font-size: 28px;
letter-spacing: -0.03em;
}
p {
margin: 10px 0 0;
color: var(--share-text-secondary);
}
`,
feedbackSummary: css`
display: flex;
gap: 12px;
flex-wrap: wrap;
`,
feedbackSummaryItem: css`
min-width: 120px;
border-radius: 20px;
border: 1px solid var(--share-border-color);
background: var(--share-stat-bg);
padding: 14px 16px;
span {
display: block;
color: var(--share-text-secondary);
font-size: 12px;
}
strong {
display: block;
color: var(--share-text-color);
font-size: 26px;
margin-top: 8px;
line-height: 1;
}
`,
feedbackGrid: css`
display: grid;
gap: 18px;
grid-template-columns: minmax(280px, 0.82fr) minmax(0, 1.18fr);
`,
feedbackForm: css`
display: grid;
gap: 14px;
align-content: start;
`,
feedbackLabel: css`
display: grid;
gap: 8px;
span {
color: var(--share-text-color);
font-weight: 600;
}
`,
feedbackHint: css`
color: var(--share-text-secondary);
line-height: 1.7;
margin: 0;
`,
feedbackList: css`
display: grid;
gap: 12px;
align-content: start;
`,
feedbackCard: css`
display: grid;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr);
border-radius: 22px;
border: 1px solid var(--share-border-color);
background: var(--share-moment-bg);
padding: 16px;
`,
feedbackMeta: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 6px;
`,
feedbackName: css`
color: var(--share-text-color);
font-weight: 700;
`,
feedbackTime: css`
color: var(--share-text-tertiary);
font-size: 12px;
`,
feedbackContent: css`
margin: 0;
color: var(--share-text-secondary);
line-height: 1.75;
white-space: pre-wrap;
`,
feedbackEmpty: css`
border-radius: 22px;
border: 1px dashed var(--share-border-color);
padding: 18px;
color: var(--share-text-secondary);
background: rgba(255, 255, 255, 0.38);
`,
'@media (max-width: 1100px)': {
studioLayout: {
@@ -498,6 +795,9 @@ export default createStyles(({ css, token }) => ({
studioPreviewPanel: {
position: 'static',
},
feedbackGrid: {
gridTemplateColumns: '1fr',
},
},
'@media (max-width: 960px)': {
@@ -518,6 +818,9 @@ export default createStyles(({ css, token }) => ({
alignItems: 'flex-start',
flexDirection: 'column',
},
studioInsightGrid: {
gridTemplateColumns: '1fr',
},
},
'@media (max-width: 640px)': {
@@ -530,5 +833,8 @@ export default createStyles(({ css, token }) => ({
coverGrid: {
gridTemplateColumns: '1fr 1fr',
},
templateGrid: {
gridTemplateColumns: '1fr',
},
},
}));
}));

View File

@@ -4,6 +4,12 @@ import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid'
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
import type { StoryItem, StoryItemTimeQueryParams, StoryType } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
import {
getStoryShareActionLabel,
getStorySharePath,
getStoryShareState,
getStoryShareStatusText,
} from '@/pages/story/utils/shareState';
import { judgePermission } from '@/pages/story/utils/utils';
import {
EyeOutlined,
@@ -156,6 +162,10 @@ const Index = () => {
const storyId = lineId || detail?.instanceId;
const canEdit = judgePermission(detail?.permissionType ?? null, 'edit');
const canManageCollaborators = judgePermission(detail?.permissionType ?? null, 'auth');
const shareState = getStoryShareState(detail);
const shareStatusText = getStoryShareStatusText(detail);
const shareActionLabel =
shareState === 'public' ? 'Open Public Share' : getStoryShareActionLabel(detail);
const refreshStory = useCallback(() => {
setItems([]);
@@ -189,6 +199,16 @@ const Index = () => {
history.push(`/share/studio/${storyId}`);
}, [storyId]);
const openShareSurface = useCallback(() => {
const sharePath = getStorySharePath(detail);
if (!sharePath) {
message.warning('This story is not ready for sharing yet.');
return;
}
history.push(sharePath);
}, [detail]);
useEffect(() => {
setItems([]);
setHasMoreOld(true);
@@ -334,10 +354,10 @@ const Index = () => {
if (isMobile) {
const menuItems: MenuProps['items'] = [
{
key: 'preview',
label: 'Preview',
icon: <EyeOutlined />,
onClick: openSharePreview,
key: 'share',
label: shareActionLabel,
icon: shareState === 'public' ? <ShareAltOutlined /> : <EyeOutlined />,
onClick: openShareSurface,
},
{
key: 'studio',
@@ -372,7 +392,7 @@ const Index = () => {
return (
<Space wrap>
<Button icon={<EyeOutlined />} onClick={openSharePreview}>
Preview
{shareActionLabel}
</Button>
<Button icon={<ShareAltOutlined />} onClick={openShareStudio}>
Share Studio
@@ -418,14 +438,18 @@ const Index = () => {
<span>{detail.storyTime || 'No story time set'}</span>
<span>{Number(detail.itemCount || 0)} moments</span>
<span>{detail.updateTime || 'No recent update'}</span>
<span>{shareStatusText}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Button type="primary" icon={<ShareAltOutlined />} onClick={openShareStudio}>
Open Studio
</Button>
<Button icon={<EyeOutlined />} onClick={openSharePreview}>
Preview
<Button
icon={shareState === 'public' ? <ShareAltOutlined /> : <EyeOutlined />}
onClick={openShareSurface}
>
{shareActionLabel}
</Button>
{canEdit && (
<Button

View File

@@ -12,6 +12,13 @@ import AuthorizeStoryModal from './components/AuthorizeStoryModal';
import OperationModal from './components/OperationModal';
import type { StoryItem, StoryType } from './data.d';
import { addStory, deleteStory, queryTimelineList, searchStoryItems, updateStory } from './service';
import {
getStoryPublicShareId,
getStoryShareActionLabel,
getStoryShareMenuLabel,
getStoryShareState,
getStoryShareStatusText,
} from './utils/shareState';
import useStyles from './style.style';
const { Search } = Input;
@@ -221,14 +228,15 @@ export const BasicList: FC = () => {
const shareStory = async (story?: StoryType) => {
if (!story) return;
if (story.shareId) {
const shareLink = `${window.location.origin}/share/${story.shareId}`;
const publicShareId = getStoryPublicShareId(story);
if (publicShareId) {
const shareLink = `${window.location.origin}/share/${publicShareId}`;
const copied = await copyText(shareLink);
if (copied) {
message.success('Public share link copied.');
} else {
history.push(`/share/${story.shareId}`);
history.push(`/share/${publicShareId}`);
message.info('Clipboard is unavailable, opening the public page instead.');
}
@@ -236,7 +244,9 @@ export const BasicList: FC = () => {
}
openSharePreview(story);
message.info('Opening share preview.');
message.info(
getStoryShareState(story) === 'draft' ? 'Opening draft preview.' : 'Opening share preview.',
);
};
const editAndDelete = (key: string | number, currentItem: StoryType) => {
@@ -321,7 +331,7 @@ export const BasicList: FC = () => {
}
items.push({ key: 'studio', label: 'Share Studio' });
items.push({ key: 'share', label: item.shareId ? 'Copy share link' : 'Open preview' });
items.push({ key: 'share', label: getStoryShareMenuLabel(item) });
if (items.length === 0) return null;
@@ -403,7 +413,7 @@ export const BasicList: FC = () => {
>
<span>{featuredStory.storyTime || 'No story time set'}</span>
<span>{Number(featuredStory.itemCount || 0)} moments</span>
<span>{featuredStory.shareId ? 'Public share is ready' : 'Preview available'}</span>
<span>{getStoryShareStatusText(featuredStory)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap' }}>
@@ -423,7 +433,7 @@ export const BasicList: FC = () => {
void shareStory(featuredStory);
}}
>
{featuredStory.shareId ? 'Copy Link' : 'Preview'}
{getStoryShareActionLabel(featuredStory)}
</Button>
<Button
onClick={(event) => {
@@ -540,7 +550,7 @@ export const BasicList: FC = () => {
Studio
</Button>,
<Button key="share" type="link" onClick={() => void shareStory(item)}>
{item.shareId ? 'Copy Link' : 'Preview'}
{getStoryShareActionLabel(item)}
</Button>,
]
}

View File

@@ -17,27 +17,44 @@ export type StorySharePublishPayload = {
title?: string;
description?: string;
quote?: string;
templateStyle?: ShareTemplateStyle;
featuredMomentIds?: string[];
};
export type ShareTemplateStyle = 'editorial' | 'cinematic' | 'scrapbook';
export type StorySharePublishResult = {
storyId: string;
shareId: string;
heroMomentId: string;
templateStyle?: ShareTemplateStyle;
publicPath: string;
publishedAt?: string;
};
export type StoryShareRecentComment = {
instanceId: string;
shareId: string;
visitorName: string;
content: string;
createTime: string;
};
export type StoryShareConfig = {
shareId?: string;
publicShareId?: string;
storyId: string;
heroMomentId?: string;
title?: string;
description?: string;
quote?: string;
templateStyle?: ShareTemplateStyle;
featuredMomentIds: string[];
published?: boolean;
updatedAt?: string;
viewCount?: number;
commentCount?: number;
recentComments?: StoryShareRecentComment[];
};
export type TimelineArchiveBucket = {
@@ -47,6 +64,8 @@ export type TimelineArchiveBucket = {
subtitle: string;
storyInstanceId?: string;
storyShareId?: string;
shareConfigured?: boolean;
sharePublished?: boolean;
coverInstanceId?: string;
coverSrc?: string;
sampleMomentTitle?: string;
@@ -56,6 +75,8 @@ export type TimelineArchiveMoment = {
key: string;
storyInstanceId?: string;
storyShareId?: string;
shareConfigured?: boolean;
sharePublished?: boolean;
storyTitle: string;
storyTime?: string;
itemInstanceId?: string;

View File

@@ -0,0 +1,72 @@
export type StoryShareMeta = {
instanceId?: string;
publicShareId?: string;
shareId?: string;
shareConfigured?: boolean;
sharePublished?: boolean;
};
export type StoryShareState = 'public' | 'draft' | 'preview';
export const getStoryPublicShareId = (story?: StoryShareMeta) =>
story?.publicShareId || story?.shareId;
export const getStoryShareState = (story?: StoryShareMeta): StoryShareState => {
const publicShareId = getStoryPublicShareId(story);
if (story?.sharePublished || publicShareId) {
return 'public';
}
if (story?.shareConfigured) {
return 'draft';
}
return 'preview';
};
export const getStoryShareStatusText = (story?: StoryShareMeta) => {
switch (getStoryShareState(story)) {
case 'public':
return 'Public share is live';
case 'draft':
return 'Share draft is ready';
default:
return 'Preview available';
}
};
export const getStoryShareActionLabel = (story?: StoryShareMeta) => {
switch (getStoryShareState(story)) {
case 'public':
return 'Copy Link';
case 'draft':
return 'Draft Preview';
default:
return 'Preview';
}
};
export const getStoryShareMenuLabel = (story?: StoryShareMeta) => {
switch (getStoryShareState(story)) {
case 'public':
return 'Copy share link';
case 'draft':
return 'Open draft preview';
default:
return 'Open preview';
}
};
export const getStorySharePath = (story?: StoryShareMeta) => {
const publicShareId = getStoryPublicShareId(story);
if (publicShareId) {
return `/share/${publicShareId}`;
}
if (story?.instanceId) {
return `/share/preview/${story.instanceId}`;
}
return undefined;
};