+
- {!isLoaded &&
}
+ {!isLoaded &&
}
);
};
diff --git a/src/components/index.ts b/src/components/index.ts
index 9bbae05..3d6d0d7 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,30 +1,11 @@
-/**
- * 这个文件作为组件的目录
- * 目的是统一管理对外输出的组件,方便分类
- */
-/**
- * 布局组件
- */
import Footer from './Footer';
import { Question, SelectLang } from './RightContent';
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };
export { default as ClientOnly } from './ClientOnly';
-
-// 导出Hooks
export { default as useFetchImageUrl } from './Hooks/useFetchImageUrl';
export { default as useWebSocket } from './Hooks/useWebSocket';
export { default as useFetchHighResImageUrl } from './Hooks/useFetchHighResImageUrl';
export { default as useAuthImageUrls } from './Hooks/useAuthImageUrls';
-
-// 导出同步状态组件
-export {
- SyncStatusIndicator,
- ConflictResolver,
- OfflineIndicator,
- SyncStatusManager,
-} from './SyncStatus';
-
-// 导出评论组件
-export { Comments, CommentList, CommentItem, CommentInput } from './Comments';
\ No newline at end of file
+export { Comments, CommentList, CommentItem, CommentInput } from './Comments';
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index ee77cb6..026646b 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -1,93 +1,96 @@
-
-import { useEffect, useState, useCallback } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useModel, request } from '@umijs/max';
import { notification as antdNotification } from 'antd';
import { Notification, NotificationType } from '@/types';
import { AUTH_API, USER_API } from '@/services/config/apiUrls';
-export const useNotifications = () => {
- const { initialState } = useModel('@@initialState');
- const { stompClient } = useModel('stomp');
- const [notifications, setNotifications] = useState
([]);
- const [unreadCount, setUnreadCount] = useState(0);
-
- const fetchUnreadNotifications = useCallback(async () => {
- if (!initialState?.currentUser) return;
- try {
- const res = await request(`${AUTH_API.INFO}/message/unread`);
- setNotifications(res);
- setUnreadCount(res.length);
- } catch (error) {
- console.error('Failed to fetch unread notifications', error);
- }
- }, [initialState?.currentUser]);
-
- useEffect(() => {
- fetchUnreadNotifications();
- }, [fetchUnreadNotifications]);
-
- useEffect(() => {
- if (initialState?.currentUser && stompClient?.connected) {
- const subscription = stompClient.subscribe(
- '/user/queue/notification',
- (message) => {
- try {
- const newNotification: Notification = JSON.parse(message.body);
- setNotifications((prev) => [newNotification, ...prev]);
- setUnreadCount((prev) => prev + 1);
-
- antdNotification.open({
- message: getNotificationTitle(newNotification.type),
- description: newNotification.content,
- });
- } catch (error) {
- console.error('Failed to parse notification', error);
- }
- }
- );
-
- return () => {
- subscription.unsubscribe();
- };
- }
- }, [initialState?.currentUser, stompClient]);
-
- const markAsRead = useCallback(async (ids: number[]) => {
- try {
- await request(USER_API.NOTIFICATIONS_READ, {
- method: 'POST',
- data: ids,
- });
- setNotifications((prev) =>
- prev.map((n) => (ids.includes(n.id) ? { ...n, read: true } : n))
- );
- setUnreadCount((prev) => prev - ids.length);
- } catch (error) {
- console.error('Failed to mark notifications as read', error);
- }
- }, []);
-
- const getNotificationTitle = (type: NotificationType): string => {
- switch (type) {
- case NotificationType.FRIEND_REQUEST:
- return '好友请求';
- case NotificationType.FRIEND_ACCEPTED:
- return '好友请求已接受';
- case NotificationType.NEW_COMMENT:
- return '有新的评论';
- case NotificationType.NEW_LIKE:
- return '有新的点赞';
- case NotificationType.SYSTEM:
- return '系统通知';
- default:
- return '通知';
- }
- };
-
- return {
- notifications,
- unreadCount,
- markAsRead,
- fetchUnreadNotifications,
- };
+const getNotificationTitle = (type: NotificationType): string => {
+ switch (type) {
+ case NotificationType.FRIEND_REQUEST:
+ return '��������';
+ case NotificationType.FRIEND_ACCEPTED:
+ return '����������ͨ��';
+ case NotificationType.NEW_COMMENT:
+ return '������';
+ case NotificationType.NEW_REACTION:
+ return '�»���';
+ case NotificationType.NEW_LIKE:
+ return '�µ���';
+ case NotificationType.SYSTEM:
+ return 'ϵͳ֪ͨ';
+ default:
+ return '֪ͨ';
+ }
+};
+
+export const useNotifications = () => {
+ const { initialState } = useModel('@@initialState');
+ const { stompClient } = useModel('stomp');
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+
+ const fetchUnreadNotifications = useCallback(async () => {
+ if (!initialState?.currentUser) {
+ setNotifications([]);
+ setUnreadCount(0);
+ return;
+ }
+
+ try {
+ const res = await request(`${AUTH_API.INFO}/message/unread`);
+ setNotifications(res);
+ setUnreadCount(res.filter((item) => !item.read).length);
+ } catch (error) {
+ console.error('Failed to fetch unread notifications', error);
+ }
+ }, [initialState?.currentUser]);
+
+ useEffect(() => {
+ fetchUnreadNotifications();
+ }, [fetchUnreadNotifications]);
+
+ useEffect(() => {
+ if (!initialState?.currentUser || !stompClient?.connected) {
+ return undefined;
+ }
+
+ const subscription = stompClient.subscribe('/user/queue/notification', (message) => {
+ try {
+ const newNotification: Notification = JSON.parse(message.body);
+ setNotifications((prev) => [newNotification, ...prev]);
+ setUnreadCount((prev) => prev + (newNotification.read ? 0 : 1));
+
+ antdNotification.open({
+ message: getNotificationTitle(newNotification.type),
+ description: newNotification.content,
+ });
+ } catch (error) {
+ console.error('Failed to parse notification', error);
+ }
+ });
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [initialState?.currentUser, stompClient]);
+
+ const markAsRead = useCallback(async (ids: number[]) => {
+ try {
+ await request(USER_API.NOTIFICATIONS_READ, {
+ method: 'POST',
+ data: ids,
+ });
+ setNotifications((prev) => prev.map((item) => (ids.includes(item.id) ? { ...item, read: true } : item)));
+ setUnreadCount((prev) => Math.max(0, prev - ids.length));
+ } catch (error) {
+ console.error('Failed to mark notifications as read', error);
+ }
+ }, []);
+
+ return {
+ notifications,
+ unreadCount,
+ markAsRead,
+ fetchUnreadNotifications,
+ };
};
diff --git a/src/models/albums.ts b/src/models/albums.ts
index 57b5228..fa844c3 100644
--- a/src/models/albums.ts
+++ b/src/models/albums.ts
@@ -23,6 +23,8 @@ export default function useAlbumsModel(): {
addPhotosToAlbum: (albumId: string, photoIds: string[]) => Promise;
removePhotosFromAlbum: (albumId: string, photoIds: string[]) => Promise;
reorderPhotos: (albumId: string, photoIds: string[]) => Promise;
+ setAlbumCover: (albumId: string, coverPhotoId: string) => Promise;
+ clearCurrentAlbum: () => void;
} {
const [albums, setAlbums] = useState([]);
const [currentAlbum, setCurrentAlbum] = useState(null);
@@ -133,7 +135,7 @@ export default function useAlbumsModel(): {
}
if (!syncState.isOnline) {
- message.info('删除操作已保存,将在联网后同步');
+ message.info('ɾѱ棬ͬ');
}
} catch (error) {
message.error('删除相册失败');
@@ -298,7 +300,7 @@ export default function useAlbumsModel(): {
);
if (!syncState.isOnline) {
- message.info('封面已设置,将在联网后同步');
+ message.info('ãͬ');
}
} catch (error) {
message.error('设置封面失败');
@@ -331,3 +333,5 @@ export default function useAlbumsModel(): {
clearCurrentAlbum,
};
}
+
+
diff --git a/src/models/theme.ts b/src/models/theme.ts
index a26888d..a11b879 100644
--- a/src/models/theme.ts
+++ b/src/models/theme.ts
@@ -97,6 +97,8 @@ export default function useThemeModel() {
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
+
+ return undefined;
}, [currentTheme]);
/**
@@ -201,3 +203,4 @@ export default function useThemeModel() {
fetchThemePreferences,
};
}
+
diff --git a/src/pages/account/center/components/Applications/index.tsx b/src/pages/account/center/components/Applications/index.tsx
index 57bb2f7..946f0bd 100644
--- a/src/pages/account/center/components/Applications/index.tsx
+++ b/src/pages/account/center/components/Applications/index.tsx
@@ -6,11 +6,13 @@ import {
} from '@ant-design/icons';
import { useRequest } from '@umijs/max';
import { Avatar, Card, Dropdown, List, Tooltip } from 'antd';
+import type { MenuProps } from 'antd';
import numeral from 'numeral';
import React from 'react';
import type { ListItemDataType } from '../../data.d';
import { queryFakeList } from '../../service';
import useStyles from './index.style';
+
export function formatWan(val: number) {
const v = val * 1;
if (!v || Number.isNaN(v)) return '';
@@ -28,16 +30,27 @@ export function formatWan(val: number) {
marginLeft: 2,
}}
>
- 万
+
);
}
return result;
}
+
+const moreItems: MenuProps['items'] = [
+ {
+ key: '1',
+ label: '1st menu item',
+ },
+ {
+ key: '2',
+ label: '2nd menu item',
+ },
+];
+
const Applications: React.FC = () => {
const { styles: stylesApplications } = useStyles();
- // 获取tab列表数据
const { data: listData } = useRequest(() => {
return queryFakeList({
count: 30,
@@ -50,15 +63,16 @@ const Applications: React.FC = () => {
}> = ({ activeUser, newUser }) => (
);
+
return (
rowKey="id"
@@ -81,28 +95,16 @@ const Applications: React.FC = () => {
paddingBottom: 20,
}}
actions={[
-
+
,
-
+
,
-
+
,
-
+
,
]}
@@ -120,4 +122,5 @@ const Applications: React.FC = () => {
/>
);
};
+
export default Applications;
diff --git a/src/pages/account/center/components/AvatarList/index.tsx b/src/pages/account/center/components/AvatarList/index.tsx
index 6981770..6813b0b 100644
--- a/src/pages/account/center/components/AvatarList/index.tsx
+++ b/src/pages/account/center/components/AvatarList/index.tsx
@@ -2,7 +2,9 @@ import { Avatar, Tooltip } from 'antd';
import classNames from 'classnames';
import React from 'react';
import useStyles from './index.style';
-export declare type SizeType = number | 'small' | 'default' | 'large';
+
+export type SizeType = number | 'small' | 'default' | 'large';
+
export type AvatarItemProps = {
tips: React.ReactNode;
src: string;
@@ -10,6 +12,7 @@ export type AvatarItemProps = {
style?: React.CSSProperties;
onClick?: () => void;
};
+
export type AvatarListProps = {
Item?: React.ReactElement;
size?: SizeType;
@@ -18,15 +21,21 @@ export type AvatarListProps = {
style?: React.CSSProperties;
children: React.ReactElement | React.ReactElement[];
};
-const Item: React.FC = ({ src, size, tips, onClick = () => {} }) => {
- const { styles } = useStyles();
- const avatarSizeToClassName = (size?: SizeType | 'mini') =>
+
+const useAvatarSizeToClassName = (styles: ReturnType['styles']) => {
+ return (size?: SizeType | 'mini') =>
classNames(styles.avatarItem, {
[styles.avatarItemLarge]: size === 'large',
[styles.avatarItemSmall]: size === 'small',
[styles.avatarItemMini]: size === 'mini',
});
+};
+
+const Item: React.FC = ({ src, size, tips, onClick = () => {} }) => {
+ const { styles } = useStyles();
+ const avatarSizeToClassName = useAvatarSizeToClassName(styles);
const cls = avatarSizeToClassName(size);
+
return (
{tips ? (
@@ -45,10 +54,12 @@ const Item: React.FC = ({ src, size, tips, onClick = () => {} }
);
};
+
const AvatarList: React.FC & {
Item: typeof Item;
} = ({ children, size, maxLength = 5, excessItemsStyle, ...other }) => {
const { styles } = useStyles();
+ const avatarSizeToClassName = useAvatarSizeToClassName(styles);
const numOfChildren = React.Children.count(children);
const numToShow = maxLength >= numOfChildren ? numOfChildren : maxLength;
const childrenArray = React.Children.toArray(children) as React.ReactElement[];
@@ -57,6 +68,7 @@ const AvatarList: React.FC & {
size,
}),
);
+
if (numToShow < numOfChildren) {
const cls = avatarSizeToClassName(size);
childrenWithProps.push(
@@ -65,11 +77,13 @@ const AvatarList: React.FC & {
,
);
}
+
return (
);
};
+
AvatarList.Item = Item;
export default AvatarList;
diff --git a/src/pages/archive/index.less b/src/pages/archive/index.less
new file mode 100644
index 0000000..e99d9fb
--- /dev/null
+++ b/src/pages/archive/index.less
@@ -0,0 +1,363 @@
+.smart-archive {
+ --archive-bg: #eef4f7;
+ --archive-panel: rgba(250, 253, 255, 0.92);
+ --archive-border: rgba(25, 42, 64, 0.08);
+ --archive-ink: #172338;
+ --archive-muted: #677488;
+ --archive-accent: #1f6aa5;
+ background:
+ radial-gradient(circle at top right, rgba(31, 106, 165, 0.16), transparent 26%),
+ linear-gradient(180deg, #f7fbfd 0%, #eaf1f5 100%);
+ min-height: calc(100vh - 104px);
+ padding: 24px 0 40px;
+}
+
+.smart-archive__shell {
+ margin: 0 auto;
+ max-width: 1180px;
+ padding: 0 16px;
+}
+
+.smart-archive__hero,
+.smart-archive__panel,
+.smart-archive__footer-panel {
+ backdrop-filter: blur(14px);
+ background: var(--archive-panel);
+ border: 1px solid var(--archive-border);
+ border-radius: 28px;
+ box-shadow: 0 24px 60px rgba(23, 35, 56, 0.08);
+}
+
+.smart-archive__hero {
+ padding: 32px;
+}
+
+.smart-archive__pill,
+.smart-archive__result-tag,
+.smart-archive__moment-meta .ant-tag {
+ background: rgba(23, 35, 56, 0.08);
+ border-radius: 999px;
+ color: var(--archive-ink);
+ padding: 6px 12px;
+}
+
+.smart-archive__hero h1,
+.smart-archive__panel h2,
+.smart-archive__moment-card h3 {
+ color: var(--archive-ink);
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ letter-spacing: -0.03em;
+ margin: 0;
+}
+
+.smart-archive__hero h1 {
+ font-size: clamp(34px, 5vw, 56px);
+ line-height: 1.05;
+ margin-top: 18px;
+ max-width: 13ch;
+}
+
+.smart-archive__hero p,
+.smart-archive__detail-summary p,
+.smart-archive__card p,
+.smart-archive__moment-card p,
+.smart-archive__footer-panel p {
+ color: var(--archive-muted);
+ line-height: 1.8;
+}
+
+.smart-archive__hero p {
+ font-size: 16px;
+ margin: 16px 0 0;
+ max-width: 72ch;
+}
+
+.smart-archive__hero-metrics {
+ display: grid;
+ gap: 14px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin-top: 24px;
+}
+
+.smart-archive__hero-metrics article {
+ background: rgba(255, 255, 255, 0.72);
+ border-radius: 20px;
+ min-height: 108px;
+ padding: 18px;
+}
+
+.smart-archive__hero-metrics span,
+.smart-archive__detail-summary span,
+.smart-archive__card span,
+.smart-archive__moment-foot span,
+.smart-archive__mode-btn strong {
+ color: var(--archive-muted);
+}
+
+.smart-archive__hero-metrics strong {
+ color: var(--archive-ink);
+ display: block;
+ font-size: clamp(28px, 4vw, 42px);
+ margin-top: 12px;
+}
+
+.smart-archive__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.smart-archive__actions .ant-btn-primary,
+.smart-archive__moment-actions .ant-btn-primary {
+ background: linear-gradient(135deg, var(--archive-accent), #124d7c);
+ border: none;
+}
+
+.smart-archive__explorer {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
+ margin-top: 20px;
+}
+
+.smart-archive__panel {
+ padding: 24px;
+}
+
+.smart-archive__panel-head {
+ align-items: center;
+ color: var(--archive-accent);
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 18px;
+}
+
+.smart-archive__panel-head--detail {
+ align-items: flex-start;
+}
+
+.smart-archive__eyebrow {
+ color: rgba(23, 35, 56, 0.58);
+ display: block;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.smart-archive__panel-head h2 {
+ font-size: 30px;
+ margin-top: 6px;
+}
+
+.smart-archive__mode-toggle {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-bottom: 18px;
+}
+
+.smart-archive__mode-btn,
+.smart-archive__card {
+ appearance: none;
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid rgba(23, 35, 56, 0.08);
+ border-radius: 20px;
+ cursor: pointer;
+ text-align: left;
+ transition:
+ border-color 180ms ease,
+ transform 180ms ease,
+ box-shadow 180ms ease;
+}
+
+.smart-archive__mode-btn:hover,
+.smart-archive__card:hover {
+ border-color: rgba(31, 106, 165, 0.28);
+ box-shadow: 0 18px 32px rgba(23, 35, 56, 0.08);
+ transform: translateY(-1px);
+}
+
+.smart-archive__mode-btn.is-active,
+.smart-archive__card.is-active {
+ background: linear-gradient(145deg, rgba(31, 106, 165, 0.14), rgba(255, 255, 255, 0.9));
+ border-color: rgba(31, 106, 165, 0.32);
+}
+
+.smart-archive__mode-btn {
+ align-items: center;
+ color: var(--archive-ink);
+ display: flex;
+ gap: 10px;
+ justify-content: space-between;
+ padding: 14px 16px;
+}
+
+.smart-archive__card-list {
+ display: grid;
+ gap: 14px;
+}
+
+.smart-archive__card {
+ align-items: center;
+ display: grid;
+ gap: 16px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding: 18px;
+}
+
+.smart-archive__card-copy strong,
+.smart-archive__footer-panel strong,
+.smart-archive__detail-summary strong,
+.smart-archive__moment-card h3,
+.smart-archive__moment-actions .ant-btn {
+ color: var(--archive-ink);
+}
+
+.smart-archive__card svg {
+ color: rgba(23, 35, 56, 0.4);
+}
+
+.smart-archive__detail-summary {
+ align-items: end;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ margin-bottom: 20px;
+}
+
+.smart-archive__detail-summary p {
+ margin: 0;
+}
+
+.smart-archive__moment-list {
+ display: grid;
+ gap: 16px;
+}
+
+.smart-archive__moment-card {
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid rgba(23, 35, 56, 0.08);
+ border-radius: 24px;
+ display: grid;
+ gap: 18px;
+ grid-template-columns: 180px minmax(0, 1fr);
+ overflow: hidden;
+ padding: 16px;
+}
+
+.smart-archive__moment-media {
+ min-height: 160px;
+ position: relative;
+}
+
+.smart-archive__moment-image,
+.smart-archive__moment-image :global(.imageContainer),
+.smart-archive__moment-image :global(.image) {
+ border-radius: 18px;
+ height: 100%;
+ width: 100%;
+}
+
+.smart-archive__moment-placeholder {
+ align-items: center;
+ background: linear-gradient(135deg, rgba(31, 106, 165, 0.18), rgba(17, 35, 57, 0.1));
+ border-radius: 18px;
+ color: var(--archive-ink);
+ display: flex;
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ font-size: 42px;
+ height: 100%;
+ justify-content: center;
+ width: 100%;
+}
+
+.smart-archive__moment-badge {
+ align-items: center;
+ background: rgba(23, 35, 56, 0.78);
+ border-radius: 999px;
+ color: #fff;
+ display: inline-flex;
+ gap: 6px;
+ left: 12px;
+ padding: 6px 10px;
+ position: absolute;
+ top: 12px;
+}
+
+.smart-archive__moment-copy {
+ display: flex;
+ flex-direction: column;
+}
+
+.smart-archive__moment-meta,
+.smart-archive__moment-foot,
+.smart-archive__moment-actions,
+.smart-archive__footer-panel {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+}
+
+.smart-archive__moment-meta,
+.smart-archive__moment-foot {
+ flex-wrap: wrap;
+}
+
+.smart-archive__moment-meta span,
+.smart-archive__moment-foot span {
+ color: var(--archive-muted);
+}
+
+.smart-archive__moment-card h3 {
+ font-size: 28px;
+ margin-top: 12px;
+}
+
+.smart-archive__moment-card p {
+ flex: 1;
+ margin: 10px 0 0;
+}
+
+.smart-archive__moment-foot {
+ border-top: 1px solid rgba(23, 35, 56, 0.08);
+ margin-top: 18px;
+ padding-top: 14px;
+}
+
+.smart-archive__moment-actions {
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+
+.smart-archive__footer-panel {
+ gap: 18px;
+ margin-top: 20px;
+ padding: 22px 24px;
+}
+
+.smart-archive__footer-panel svg {
+ color: var(--archive-accent);
+ font-size: 28px;
+}
+
+@media (max-width: 960px) {
+ .smart-archive__hero-metrics,
+ .smart-archive__explorer,
+ .smart-archive__mode-toggle,
+ .smart-archive__moment-card {
+ grid-template-columns: 1fr;
+ }
+
+ .smart-archive__detail-summary,
+ .smart-archive__moment-actions,
+ .smart-archive__footer-panel {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .smart-archive__moment-media {
+ min-height: 220px;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/archive/index.tsx b/src/pages/archive/index.tsx
new file mode 100644
index 0000000..0e19d08
--- /dev/null
+++ b/src/pages/archive/index.tsx
@@ -0,0 +1,564 @@
+import TimelineImage from '@/components/TimelineImage';
+import {
+ ArrowRightOutlined,
+ CompassOutlined,
+ EnvironmentOutlined,
+ PlayCircleOutlined,
+ ShareAltOutlined,
+ TagsOutlined,
+} from '@ant-design/icons';
+import { history, useRequest, useSearchParams } from '@umijs/max';
+import { Button, Empty, Skeleton, Tag } from 'antd';
+import React, { useEffect, useMemo } from 'react';
+import type { StoryItem, StoryType } from '@/pages/story/data.d';
+import {
+ queryStoryItem,
+ queryTimelineArchiveExplore,
+ queryTimelineArchiveSummary,
+ queryTimelineList,
+ type TimelineArchiveBucket,
+ type TimelineArchiveExplore,
+ type TimelineArchiveMoment,
+ type TimelineArchiveSummary,
+} from '@/pages/story/service';
+import './index.less';
+
+type ArchiveBucketType = 'location' | 'tag';
+
+type ArchivePageSummary = TimelineArchiveSummary & {
+ source: 'backend' | 'local';
+ moments?: TimelineArchiveMoment[];
+};
+
+type StoryItemsResponse = {
+ data?: {
+ list?: StoryItem[];
+ };
+ list?: StoryItem[];
+};
+
+const normalizeStories = (response: unknown): StoryType[] => {
+ if (Array.isArray((response as { data?: StoryType[] } | undefined)?.data)) {
+ return (response as { data: StoryType[] }).data;
+ }
+ if (Array.isArray(response)) return response as StoryType[];
+ return [];
+};
+
+const normalizeItems = (response: unknown): StoryItem[] => {
+ const dataList = (response as { data?: { list?: StoryItem[] } } | undefined)?.data?.list;
+ if (Array.isArray(dataList)) return dataList;
+
+ const list = (response as { list?: StoryItem[] } | undefined)?.list;
+ if (Array.isArray(list)) return list;
+
+ return [];
+};
+
+const normalizeArchiveSummary = (response: unknown): TimelineArchiveSummary | undefined => {
+ const data = (response as { data?: TimelineArchiveSummary } | undefined)?.data;
+ if (data && Array.isArray(data.locations) && Array.isArray(data.tags)) {
+ return data;
+ }
+
+ const direct = response as TimelineArchiveSummary | undefined;
+ if (direct && Array.isArray(direct.locations) && Array.isArray(direct.tags)) {
+ return direct;
+ }
+
+ return undefined;
+};
+
+const normalizeArchiveExplore = (response: unknown): TimelineArchiveExplore | undefined => {
+ const data = (response as { data?: TimelineArchiveExplore } | undefined)?.data;
+ if (data && Array.isArray(data.moments)) {
+ return data;
+ }
+
+ const direct = response as TimelineArchiveExplore | undefined;
+ if (direct && Array.isArray(direct.moments)) {
+ return direct;
+ }
+
+ return undefined;
+};
+
+const inferTags = (item: StoryItem): string[] => {
+ const text = `${item.title || ''} ${item.description || ''} ${item.content || ''}`.toLowerCase();
+ const tags = new Set();
+ const hashMatches = `${item.title || ''} ${item.description || ''}`.match(/#([\w\u4e00-\u9fa5-]+)/g);
+ hashMatches?.forEach((tag) => tags.add(tag.replace('#', '')));
+
+ if (text.includes('\u65c5\u884c') || text.includes('trip') || text.includes('\u51fa\u6e38')) tags.add('Travel');
+ if (text.includes('\u5bb6') || text.includes('family') || text.includes('\u7236\u6bcd') || text.includes('\u5b69\u5b50')) tags.add('Family');
+ if (text.includes('\u670b\u53cb') || text.includes('\u805a\u4f1a') || text.includes('party')) tags.add('Friends');
+ if (text.includes('\u751f\u65e5') || text.includes('\u8282\u65e5') || text.includes('\u65b0\u5e74')) tags.add('Celebration');
+ if (text.includes('\u5de5\u4f5c') || text.includes('\u9879\u76ee')) tags.add('Work');
+ if (item.videoUrl) tags.add('Video');
+ if (tags.size === 0) tags.add('Everyday');
+
+ return Array.from(tags);
+};
+
+const formatItemTime = (value: StoryItem['storyItemTime']) => {
+ if (!value) return 'Pending date';
+
+ if (Array.isArray(value)) {
+ const [year, month, day, hour = 0, minute = 0] = value;
+ const minuteLabel = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
+ return `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')} ${minuteLabel}`;
+ }
+
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return String(value);
+
+ return new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(parsed);
+};
+
+const getSortValue = (value: StoryItem['storyItemTime'] | StoryType['storyTime']) => {
+ if (!value) return 0;
+
+ if (Array.isArray(value)) {
+ const [year, month, day, hour = 0, minute = 0, second = 0] = value;
+ return new Date(year, month - 1, day, hour, minute, second).getTime();
+ }
+
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime();
+};
+
+const getPreviewMedia = (item: StoryItem) => {
+ if (item.coverInstanceId) return { coverInstanceId: item.coverInstanceId };
+
+ const imageInstanceId = item.images?.[0] || item.relatedImageInstanceIds?.[0] || item.thumbnailInstanceId;
+ if (imageInstanceId) return { coverInstanceId: imageInstanceId };
+
+ const coverSrc = item.coverImage || item.thumbnailUrl;
+ if (coverSrc) return { coverSrc };
+
+ return {};
+};
+
+const buildLocalArchiveSummary = async (): Promise => {
+ const stories = normalizeStories(await queryTimelineList({ count: 16 }));
+ const itemResponses = await Promise.all(
+ stories.slice(0, 12).map(async (story) => {
+ if (!story.instanceId) return { story, items: [] as StoryItem[] };
+ const response = await queryStoryItem({
+ storyInstanceId: story.instanceId,
+ current: 1,
+ pageSize: 80,
+ });
+ return { story, items: normalizeItems(response) };
+ }),
+ );
+
+ const locationMap = new Map();
+ const tagMap = new Map();
+ const moments: TimelineArchiveMoment[] = [];
+
+ itemResponses.forEach(({ story, items }) => {
+ items.forEach((item, index) => {
+ const preview = getPreviewMedia(item);
+ const tags = inferTags(item);
+ const sortValue = getSortValue(item.storyItemTime) || getSortValue(story.storyTime);
+ const moment: TimelineArchiveMoment = {
+ key: item.instanceId || `${story.instanceId || 'story'}-${index}`,
+ storyInstanceId: story.instanceId,
+ storyShareId: story.shareId,
+ storyTitle: story.title || 'Untitled story',
+ storyTime: typeof story.storyTime === 'string' ? story.storyTime : undefined,
+ itemInstanceId: item.instanceId,
+ itemTitle: item.title || 'Untitled moment',
+ itemDescription:
+ item.description || item.content || 'Add one clear sentence here and this memory becomes much easier to revisit.',
+ itemTime: formatItemTime(item.storyItemTime),
+ location: item.location,
+ tags,
+ coverInstanceId: preview.coverInstanceId,
+ coverSrc: preview.coverSrc,
+ mediaCount: Math.max(item.images?.length || 0, item.videoUrl ? 1 : 0),
+ hasVideo: Boolean(item.videoUrl),
+ sortValue,
+ };
+
+ moments.push(moment);
+
+ if (item.location) {
+ const current = locationMap.get(item.location) || {
+ key: item.location,
+ title: item.location,
+ count: 0,
+ subtitle: story.title || 'Untitled story',
+ storyInstanceId: story.instanceId,
+ storyShareId: story.shareId,
+ coverInstanceId: preview.coverInstanceId,
+ coverSrc: preview.coverSrc,
+ sampleMomentTitle: moment.itemTitle,
+ };
+ current.count += 1;
+ current.subtitle = current.subtitle || story.title || 'Untitled story';
+ current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle;
+ current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId;
+ current.coverSrc = current.coverSrc || preview.coverSrc;
+ current.storyShareId = current.storyShareId || story.shareId;
+ locationMap.set(item.location, current);
+ }
+
+ tags.forEach((tag) => {
+ const current = tagMap.get(tag) || {
+ key: tag,
+ title: tag,
+ count: 0,
+ subtitle: story.title || 'Untitled story',
+ storyInstanceId: story.instanceId,
+ storyShareId: story.shareId,
+ coverInstanceId: preview.coverInstanceId,
+ coverSrc: preview.coverSrc,
+ sampleMomentTitle: moment.itemTitle,
+ };
+ current.count += 1;
+ current.subtitle = current.subtitle || story.title || 'Untitled story';
+ current.sampleMomentTitle = current.sampleMomentTitle || moment.itemTitle;
+ current.coverInstanceId = current.coverInstanceId || preview.coverInstanceId;
+ current.coverSrc = current.coverSrc || preview.coverSrc;
+ current.storyShareId = current.storyShareId || story.shareId;
+ tagMap.set(tag, current);
+ });
+ });
+ });
+
+ return {
+ source: 'local',
+ storiesIndexed: stories.length,
+ 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,
+ videoMoments: moments.filter((moment) => moment.hasVideo).length,
+ };
+};
+
+const ArchivePage: React.FC = () => {
+ const [searchParams] = useSearchParams();
+ const summaryRequest = useRequest(async (): Promise => {
+ try {
+ const response = await queryTimelineArchiveSummary(8, 12);
+ const summary = normalizeArchiveSummary(response);
+ if (summary) {
+ return {
+ ...summary,
+ source: 'backend',
+ };
+ }
+ } catch (error) {
+ console.warn('Failed to load archive summary from backend, falling back to local inference.', error);
+ }
+
+ return buildLocalArchiveSummary();
+ });
+
+ const data = summaryRequest.data as ArchivePageSummary | undefined;
+ const { loading } = summaryRequest;
+
+ const requestedType = searchParams.get('type') === 'tag' ? 'tag' : 'location';
+ const requestedValue = searchParams.get('value') || '';
+
+ const selection = useMemo(() => {
+ if (!data) {
+ return {
+ activeType: 'location' as ArchiveBucketType,
+ activeCollection: [] as TimelineArchiveBucket[],
+ activeBucket: undefined as TimelineArchiveBucket | undefined,
+ };
+ }
+
+ const fallbackType: ArchiveBucketType = data.locations.length > 0 ? 'location' : 'tag';
+ const requestedCollection = requestedType === 'location' ? data.locations : data.tags;
+ const activeType: ArchiveBucketType = requestedCollection.length > 0 ? requestedType : fallbackType;
+ const activeCollection = activeType === 'location' ? data.locations : data.tags;
+ const activeBucket = activeCollection.find((item) => item.key === requestedValue) || activeCollection[0];
+
+ return {
+ activeType,
+ activeCollection,
+ activeBucket,
+ };
+ }, [data, requestedType, requestedValue]);
+
+ useEffect(() => {
+ if (!data || !selection.activeBucket) return;
+
+ if (selection.activeType !== requestedType || selection.activeBucket.key !== requestedValue) {
+ history.replace(
+ `/archive?type=${selection.activeType}&value=${encodeURIComponent(selection.activeBucket.key)}`,
+ );
+ }
+ }, [data, requestedType, requestedValue, selection]);
+
+ const exploreRequest = useRequest(
+ async () => {
+ if (!data || data.source === 'local' || !selection.activeBucket) return undefined;
+ const response = await queryTimelineArchiveExplore({
+ type: selection.activeType,
+ value: selection.activeBucket.key,
+ limit: 12,
+ });
+ return normalizeArchiveExplore(response);
+ },
+ {
+ refreshDeps: [data?.source, selection.activeType, selection.activeBucket?.key],
+ },
+ );
+ const exploreData = exploreRequest.data as TimelineArchiveExplore | undefined;
+
+ if (loading || !data) {
+ return (
+
+ );
+ }
+
+ const { activeType, activeCollection, activeBucket } = selection;
+ const matchedMoments =
+ data.source === 'local'
+ ? (data.moments || [])
+ .filter((moment) =>
+ activeBucket
+ ? activeType === 'location'
+ ? moment.location === activeBucket.key
+ : moment.tags.includes(activeBucket.key)
+ : false,
+ )
+ .slice(0, 12)
+ : exploreData?.moments || [];
+
+ const activateBucket = (type: ArchiveBucketType, key: string) => {
+ history.push(`/archive?type=${type}&value=${encodeURIComponent(key)}`);
+ };
+
+ return (
+
+
+
+
+ Smart Archive
+
+ Let the system organize memory by place and theme, so browsing feels like exploration.
+
+ We group moments by place hints, text cues, and media features. Instead of just seeing a top
+ list, you can now drill into real story moments and preview how those memories may be shared.
+
+
+
+ Stories indexed
+ {data.storiesIndexed}
+
+
+ Place clusters
+ {data.locations.length}
+
+
+ Theme tags
+ {data.tags.length}
+
+
+ Video moments
+ {data.videoMoments}
+
+
+
+ history.push('/gallery')}>
+ Return to gallery
+
+ history.push('/review')}>
+ Open yearly review
+
+
+
+
+
+
+
+
+ Navigator
+
Start with a place or theme
+
+
+
+
+
+
+ data.locations[0] &&
+ activateBucket(
+ 'location',
+ activeBucket?.key && activeType === 'location' ? activeBucket.key : data.locations[0].key,
+ )
+ }
+ type="button"
+ >
+
+ Explore by place
+ {data.locations.length}
+
+
+ data.tags[0] &&
+ activateBucket(
+ 'tag',
+ activeBucket?.key && activeType === 'tag' ? activeBucket.key : data.tags[0].key,
+ )
+ }
+ type="button"
+ >
+
+ Explore by theme
+ {data.tags.length}
+
+
+
+
+ {activeCollection.map((item) => (
+
activateBucket(activeType, item.key)}
+ type="button"
+ >
+
+
{item.title}
+
{item.sampleMomentTitle || item.subtitle}
+
{item.count} matched moments
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {activeType === 'location' ? 'Location Explorer' : 'Tag Explorer'}
+
+
{activeBucket?.title || 'Waiting for archive data'}
+
+ {activeBucket && (
+
+ {matchedMoments.length} matched moments
+
+ )}
+
+
+ {activeBucket ? (
+ <>
+
+
+ This cluster currently surfaces {matchedMoments.length} moments from
+ {data.storiesIndexed} indexed stories. {data.shareableStories}
+ {` `}stories are already close to public-share quality.
+
+
{activeBucket.subtitle}
+
+
+ {data.source === 'backend' && exploreRequest.loading ? (
+
+ ) : matchedMoments.length > 0 ? (
+
+ {matchedMoments.map((moment) => {
+ const previewPath = moment.storyInstanceId
+ ? `/share/preview/${moment.storyInstanceId}`
+ : '/story';
+ const sharePath = moment.storyShareId ? `/share/${moment.storyShareId}` : previewPath;
+
+ return (
+
+
+ {moment.coverInstanceId || moment.coverSrc ? (
+
+ ) : (
+
+ {moment.storyTitle.slice(0, 1)}
+
+ )}
+ {moment.hasVideo && (
+
+ Video
+
+ )}
+
+
+
+
+ {moment.storyTitle}
+ {moment.location && {moment.location} }
+
+
{moment.itemTitle}
+
{moment.itemDescription}
+
+ {moment.itemTime}
+ {moment.mediaCount} media items
+
+
+
+ moment.storyInstanceId && history.push(`/timeline/${moment.storyInstanceId}`)
+ }
+ >
+ Open story
+
+ history.push(sharePath)}>
+ {moment.storyShareId ? 'Open public share' : 'Open preview'}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
{data.shareableStories} stories are already close to being worth sharing outside the app
+
+ 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.
+
+
+
+
+
+ );
+};
+
+export default ArchivePage;
diff --git a/src/pages/gallery/components/GridView.tsx b/src/pages/gallery/components/GridView.tsx
index 346335d..69c557a 100644
--- a/src/pages/gallery/components/GridView.tsx
+++ b/src/pages/gallery/components/GridView.tsx
@@ -82,7 +82,7 @@ const GridView: FC = ({
key={item.instanceId}
item={item}
index={index}
- viewMode={viewMode}
+ viewMode={viewMode === 'large' ? 'large' : 'small'}
batchMode={batchMode}
isSelected={selectedRowKeys.includes(item.instanceId)}
imageSize={imageSize}
@@ -103,3 +103,4 @@ const GridView: FC = ({
};
export default GridView;
+
diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx
index 725a923..f9d5b1b 100644
--- a/src/pages/gallery/index.tsx
+++ b/src/pages/gallery/index.tsx
@@ -1,4 +1,4 @@
-// src/pages/gallery/index.tsx
+// src/pages/gallery/index.tsx
import { ImageItem } from '@/pages/gallery/typings';
import {
deleteImage,
@@ -204,6 +204,7 @@ const Gallery: FC = () => {
const uploadUrlRes = await getUploadUrl(fileName);
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
const uploadUrl = uploadUrlRes.data;
+ if (!uploadUrl) throw new Error('Missing upload URL');
// 2. 上传视频到 MinIO
await fetch(uploadUrl, {
@@ -247,14 +248,16 @@ const Gallery: FC = () => {
const thumbName = `thumb-${Date.now()}.jpg`;
const thumbUrlRes = await getUploadUrl(thumbName);
if (thumbUrlRes.code === 200) {
- await fetch(thumbUrlRes.data, { method: 'PUT', body: thumbnailBlob });
+ const thumbUploadUrl = thumbUrlRes.data;
+ if (!thumbUploadUrl) throw new Error('Missing thumbnail upload URL');
+ await fetch(thumbUploadUrl, { method: 'PUT', body: thumbnailBlob });
const thumbMetaRes = await saveFileMetadata({
imageName: thumbName,
objectKey: thumbName,
size: thumbnailBlob.size,
contentType: 'image/jpeg',
});
- if (thumbMetaRes.code === 200) {
+ if (thumbMetaRes.code === 200 && thumbMetaRes.data) {
thumbnailInstanceId = thumbMetaRes.data;
}
}
@@ -599,3 +602,5 @@ const Gallery: FC = () => {
};
export default Gallery;
+
+
diff --git a/src/pages/home/index.less b/src/pages/home/index.less
new file mode 100644
index 0000000..05a9408
--- /dev/null
+++ b/src/pages/home/index.less
@@ -0,0 +1,495 @@
+.timeline-home {
+ --timeline-home-bg: #f4efe7;
+ --timeline-home-panel: rgba(255, 250, 244, 0.84);
+ --timeline-home-border: rgba(27, 35, 54, 0.09);
+ --timeline-home-ink: #182033;
+ --timeline-home-muted: #5f6b7f;
+ --timeline-home-accent: #ef6f45;
+ --timeline-home-accent-deep: #cf542c;
+ --timeline-home-cool: #1f4d7a;
+ background:
+ radial-gradient(circle at top left, rgba(239, 111, 69, 0.12), transparent 28%),
+ radial-gradient(circle at 85% 20%, rgba(31, 77, 122, 0.14), transparent 24%),
+ linear-gradient(180deg, #fbf7f1 0%, #f3ede3 100%);
+ min-height: calc(100vh - 104px);
+ padding: 8px 0 32px;
+}
+
+.timeline-home__panel {
+ backdrop-filter: blur(18px);
+ background: var(--timeline-home-panel);
+ border: 1px solid var(--timeline-home-border);
+ border-radius: 28px;
+ box-shadow: 0 30px 80px rgba(24, 32, 51, 0.1);
+}
+
+.timeline-home__hero {
+ display: grid;
+ gap: 24px;
+ grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.85fr);
+ overflow: hidden;
+ padding: 32px;
+ position: relative;
+}
+
+.timeline-home__glow {
+ border-radius: 999px;
+ filter: blur(12px);
+ position: absolute;
+}
+
+.timeline-home__glow--left {
+ background: rgba(239, 111, 69, 0.22);
+ height: 220px;
+ left: -44px;
+ top: -40px;
+ width: 220px;
+}
+
+.timeline-home__glow--right {
+ background: rgba(31, 77, 122, 0.18);
+ height: 180px;
+ right: -24px;
+ top: 24px;
+ width: 180px;
+}
+
+.timeline-home__hero-copy,
+.timeline-home__hero-side {
+ position: relative;
+ z-index: 1;
+}
+
+.timeline-home__pill,
+.timeline-home__story-topline .ant-tag,
+.timeline-home__media-tag {
+ background: rgba(24, 32, 51, 0.08);
+ border-radius: 999px;
+ color: var(--timeline-home-ink);
+ font-size: 12px;
+ margin: 0;
+ padding: 6px 10px;
+}
+
+.timeline-home__hero-copy h1,
+.timeline-home__section-head h2 {
+ color: var(--timeline-home-ink);
+ font-family: Georgia, "Times New Roman", "Songti SC", serif;
+ letter-spacing: -0.03em;
+ margin: 0;
+}
+
+.timeline-home__hero-copy h1 {
+ font-size: clamp(32px, 5vw, 56px);
+ line-height: 1.02;
+ margin-top: 18px;
+ max-width: 9ch;
+}
+
+.timeline-home__hero-copy p,
+.timeline-home__story-card p,
+.timeline-home__spotlight-copy p {
+ color: var(--timeline-home-muted);
+ line-height: 1.75;
+}
+
+.timeline-home__hero-copy p {
+ font-size: 16px;
+ margin: 16px 0 0;
+ max-width: 62ch;
+}
+
+.timeline-home__hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 28px;
+}
+
+.timeline-home__hero-actions .ant-btn-primary {
+ background: linear-gradient(135deg, var(--timeline-home-accent), var(--timeline-home-accent-deep));
+ border: none;
+ box-shadow: 0 14px 28px rgba(239, 111, 69, 0.28);
+}
+
+.timeline-home__hero-side {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(250, 242, 232, 0.9));
+ border: 1px solid rgba(255, 255, 255, 0.7);
+ border-radius: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 22px;
+}
+
+.timeline-home__profile {
+ align-items: center;
+ display: flex;
+ gap: 14px;
+}
+
+.timeline-home__profile strong {
+ color: var(--timeline-home-ink);
+ display: block;
+ font-size: 18px;
+ margin-top: 4px;
+}
+
+.timeline-home__eyebrow {
+ color: rgba(24, 32, 51, 0.62);
+ display: block;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.timeline-home__metrics {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.timeline-home__metric-card {
+ background: rgba(255, 255, 255, 0.72);
+ border: 1px solid rgba(24, 32, 51, 0.08);
+ border-radius: 20px;
+ min-height: 112px;
+ padding: 18px;
+}
+
+.timeline-home__metric-card span,
+.timeline-home__story-meta span,
+.timeline-home__media-copy span {
+ color: var(--timeline-home-muted);
+}
+
+.timeline-home__metric-card strong {
+ color: var(--timeline-home-ink);
+ display: block;
+ font-size: clamp(28px, 4vw, 38px);
+ letter-spacing: -0.04em;
+ margin-top: 12px;
+}
+
+.timeline-home__section {
+ margin-top: 24px;
+}
+
+.timeline-home__section-head {
+ align-items: flex-end;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.timeline-home__section-head--stacked {
+ align-items: flex-start;
+}
+
+.timeline-home__section-head h2 {
+ font-size: clamp(24px, 3vw, 34px);
+ line-height: 1.1;
+ margin-top: 6px;
+}
+
+.timeline-home__story-grid {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.timeline-home__story-card,
+.timeline-home__media-card,
+.timeline-home__spotlight-card {
+ appearance: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ text-align: left;
+ width: 100%;
+}
+
+.timeline-home__story-card {
+ background: rgba(255, 255, 255, 0.72);
+ border: 1px solid rgba(24, 32, 51, 0.08);
+ border-radius: 24px;
+ box-shadow: 0 16px 30px rgba(24, 32, 51, 0.06);
+ min-height: 240px;
+ padding: 22px;
+ transition:
+ transform 180ms ease,
+ box-shadow 180ms ease,
+ border-color 180ms ease;
+}
+
+.timeline-home__story-card:hover,
+.timeline-home__media-card:hover,
+.timeline-home__spotlight-card:hover {
+ box-shadow: 0 22px 44px rgba(24, 32, 51, 0.12);
+ transform: translateY(-4px);
+}
+
+.timeline-home__story-topline,
+.timeline-home__story-meta {
+ align-items: center;
+ display: flex;
+ gap: 10px;
+ justify-content: space-between;
+}
+
+.timeline-home__story-topline {
+ margin-bottom: 18px;
+}
+
+.timeline-home__story-topline span:last-child {
+ color: var(--timeline-home-muted);
+ font-size: 13px;
+}
+
+.timeline-home__story-card h3 {
+ color: var(--timeline-home-ink);
+ font-size: 22px;
+ line-height: 1.2;
+ margin: 0;
+}
+
+.timeline-home__story-card p {
+ font-size: 14px;
+ margin: 14px 0 20px;
+ min-height: 72px;
+}
+
+.timeline-home__story-meta {
+ border-top: 1px solid rgba(24, 32, 51, 0.08);
+ font-size: 13px;
+ gap: 12px;
+ padding-top: 14px;
+}
+
+.timeline-home__story-meta span {
+ align-items: center;
+ display: inline-flex;
+ gap: 6px;
+}
+
+.timeline-home__overview {
+ display: grid;
+ gap: 24px;
+ grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
+ margin-top: 24px;
+}
+
+.timeline-home__media-panel,
+.timeline-home__spotlight {
+ padding: 24px;
+}
+
+.timeline-home__media-grid {
+ display: grid;
+ gap: 14px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.timeline-home__media-card {
+ border-radius: 22px;
+ min-height: 204px;
+ overflow: hidden;
+ position: relative;
+}
+
+.timeline-home__media-card img {
+ display: block;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 240ms ease;
+ width: 100%;
+}
+
+.timeline-home__media-card:hover img {
+ transform: scale(1.03);
+}
+
+.timeline-home__media-overlay {
+ background: linear-gradient(180deg, rgba(12, 17, 28, 0.04), rgba(12, 17, 28, 0.82));
+ inset: 0;
+ position: absolute;
+}
+
+.timeline-home__media-copy {
+ bottom: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ left: 14px;
+ position: absolute;
+ right: 14px;
+ z-index: 1;
+}
+
+.timeline-home__media-copy strong {
+ color: #fffaf4;
+ font-size: 16px;
+}
+
+.timeline-home__media-copy span {
+ color: rgba(255, 250, 244, 0.78);
+ font-size: 13px;
+}
+
+.timeline-home__media-icon {
+ color: #fff6ef;
+ font-size: 26px;
+ position: absolute;
+ right: 14px;
+ top: 14px;
+ z-index: 1;
+}
+
+.timeline-home__media-skeleton {
+ align-items: center;
+ display: flex !important;
+ height: 100%;
+ justify-content: center;
+ width: 100%;
+}
+
+.timeline-home__spotlight-list {
+ display: grid;
+ gap: 14px;
+}
+
+.timeline-home__spotlight-card {
+ align-items: center;
+ background: rgba(255, 255, 255, 0.62);
+ border: 1px solid rgba(24, 32, 51, 0.08);
+ border-radius: 22px;
+ display: grid;
+ gap: 14px;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ padding: 18px;
+}
+
+.timeline-home__spotlight-icon {
+ align-items: center;
+ background: linear-gradient(135deg, rgba(239, 111, 69, 0.18), rgba(31, 77, 122, 0.18));
+ border-radius: 18px;
+ color: var(--timeline-home-cool);
+ display: inline-flex;
+ font-size: 20px;
+ height: 48px;
+ justify-content: center;
+ width: 48px;
+}
+
+.timeline-home__spotlight-copy strong {
+ color: var(--timeline-home-ink);
+ display: block;
+ font-size: 18px;
+}
+
+.timeline-home__spotlight-copy p {
+ font-size: 14px;
+ margin: 8px 0 0;
+}
+
+.timeline-home__spotlight-action {
+ align-items: center;
+ color: var(--timeline-home-cool);
+ display: inline-flex;
+ font-size: 13px;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.timeline-home__empty {
+ align-items: center;
+ background: rgba(255, 255, 255, 0.56);
+ border: 1px dashed rgba(24, 32, 51, 0.16);
+ border-radius: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ justify-content: center;
+ min-height: 260px;
+ padding: 24px;
+}
+
+.timeline-home__empty--soft {
+ min-height: 360px;
+}
+
+.timeline-home__reveal {
+ animation: timeline-home-rise 560ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+@keyframes timeline-home-rise {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 18px, 0);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+@media (max-width: 1280px) {
+ .timeline-home__story-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .timeline-home__overview {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 900px) {
+ .timeline-home__hero {
+ grid-template-columns: 1fr;
+ padding: 24px;
+ }
+
+ .timeline-home__media-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .timeline-home {
+ padding-bottom: 24px;
+ }
+
+ .timeline-home__hero,
+ .timeline-home__media-panel,
+ .timeline-home__spotlight {
+ border-radius: 24px;
+ padding: 20px;
+ }
+
+ .timeline-home__metrics,
+ .timeline-home__story-grid,
+ .timeline-home__media-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .timeline-home__section-head,
+ .timeline-home__spotlight-card {
+ grid-template-columns: 1fr;
+ }
+
+ .timeline-home__section-head {
+ align-items: flex-start;
+ }
+
+ .timeline-home__story-card p {
+ min-height: 0;
+ }
+
+ .timeline-home__spotlight-card {
+ justify-items: flex-start;
+ }
+}
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
new file mode 100644
index 0000000..32b0a16
--- /dev/null
+++ b/src/pages/home/index.tsx
@@ -0,0 +1,378 @@
+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';
+import { getImagesList } from '@/services/file/api';
+import { getAuthization } from '@/utils/userUtils';
+import {
+ ArrowRightOutlined,
+ CalendarOutlined,
+ CameraOutlined,
+ ClusterOutlined,
+ PlayCircleFilled,
+ ThunderboltOutlined,
+} from '@ant-design/icons';
+import { history, useRequest } from '@umijs/max';
+import { Avatar, Button, Empty, Skeleton, Tag } from 'antd';
+import React from 'react';
+import './index.less';
+
+type HomeSnapshot = {
+ mediaList: ImageItem[];
+ mediaTotal: number;
+ stories: StoryType[];
+ totalMoments: number;
+ user: {
+ avatar?: string;
+ nickname?: React.ReactNode;
+ unreadCount?: number;
+ };
+};
+
+const spotlightCards = [
+ {
+ action: '打开故事',
+ description: '把照片、视频和文字组织成真正有起伏的生活叙事。',
+ icon: ,
+ path: '/story',
+ title: '故事时间线',
+ },
+ {
+ action: '查看回顾',
+ description: '把一整年的素材整理成一份能展示、能分享的年度回顾。',
+ icon: ,
+ path: '/review',
+ title: '年度回顾',
+ },
+ {
+ action: '智能归档',
+ description: '按地点和主题自动归拢内容,让浏览体验更像探索。',
+ icon: ,
+ path: '/archive',
+ title: '智能标签归档',
+ },
+] as const;
+
+const emptySnapshot: HomeSnapshot = {
+ mediaList: [],
+ mediaTotal: 0,
+ stories: [],
+ totalMoments: 0,
+ user: {},
+};
+
+const unwrapPayload = (response: any, fallback: T): T => {
+ if (response?.data !== undefined) {
+ return response.data as T;
+ }
+
+ if (response !== undefined) {
+ return response as T;
+ }
+
+ return fallback;
+};
+
+const getGreeting = () => {
+ const hour = new Date().getHours();
+
+ if (hour < 6) {
+ return '夜深了';
+ }
+
+ if (hour < 11) {
+ return '早上好';
+ }
+
+ if (hour < 14) {
+ return '中午好';
+ }
+
+ if (hour < 18) {
+ return '下午好';
+ }
+
+ return '晚上好';
+};
+
+const formatDateLabel = (value?: string) => {
+ if (!value) {
+ return '等待新的记录';
+ }
+
+ const date = new Date(value);
+
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return new Intl.DateTimeFormat('zh-CN', {
+ day: 'numeric',
+ month: 'short',
+ }).format(date);
+};
+
+const buildMediaUrl = (item: ImageItem) => {
+ const auth = encodeURIComponent(getAuthization());
+ const previewId = item.thumbnailInstanceId || item.instanceId;
+
+ return `/api/file/image-low-res/${previewId}?Authorization=${auth}`;
+};
+
+const Home: React.FC = () => {
+ const homeRequest = useRequest(async () => {
+ const [userResult, storyResult, mediaResult] = await Promise.allSettled([
+ queryCurrent(),
+ queryTimelineList({ count: 6 }),
+ getImagesList({ current: 1, pageSize: 8 }),
+ ]);
+
+ const user = unwrapPayload(
+ userResult.status === 'fulfilled' ? userResult.value : undefined,
+ {},
+ );
+
+ const storyPayload = unwrapPayload(
+ storyResult.status === 'fulfilled' ? storyResult.value : undefined,
+ [],
+ );
+ const stories = Array.isArray(storyPayload) ? storyPayload.slice(0, 4) : [];
+
+ const mediaPayload = unwrapPayload<{ list?: ImageItem[]; total?: number }>(
+ mediaResult.status === 'fulfilled' ? mediaResult.value : undefined,
+ {},
+ );
+ const mediaList = Array.isArray(mediaPayload?.list) ? mediaPayload.list.slice(0, 6) : [];
+ const totalMoments = stories.reduce((sum, story) => sum + Number(story.itemCount || 0), 0);
+
+ return {
+ mediaList,
+ mediaTotal: Number(mediaPayload?.total || mediaList.length),
+ stories,
+ totalMoments,
+ user: {
+ avatar: user?.avatar,
+ nickname: user?.nickname || user?.name || '你',
+ unreadCount: Number(user?.unreadCount || 0),
+ },
+ } satisfies HomeSnapshot;
+ });
+ const data = (homeRequest.data as HomeSnapshot | undefined) ?? emptySnapshot;
+ const { loading, refresh } = homeRequest;
+
+ return (
+
+
+
+
+
+
+
+ 个人影像记忆仓
+
+
+ {getGreeting()},{data.user.nickname}
+
+
+ 把照片、视频与故事串成会呼吸的时间线。现在你的项目已经具备内容管理基础,
+ 但更需要一个能让人愿意反复回来浏览的入口。
+
+
+ history.push('/story')}>
+ 开始整理故事
+
+ history.push('/gallery')}>
+ 查看最近上传
+
+
+
+
+
+
+
+ {String(data.user.nickname || '你').slice(0, 1)}
+
+
+ 今日入口
+ 让内容先讲故事,再展示功能
+
+
+
+
+
+ 故事线
+ {data.stories.length}
+
+
+ 内容时刻
+ {data.totalMoments}
+
+
+ 媒体文件
+ {data.mediaTotal}
+
+
+ 待处理提醒
+ {data.user.unreadCount}
+
+
+
+
+
+
+
+
+ Recent Stories
+
最近值得继续打磨的故事
+
+
history.push('/story')}>
+ 查看全部
+
+
+
+
+ {loading ? (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+
+
+ ))}
+
+ ) : data.stories.length > 0 ? (
+
+ {data.stories.map((story, index) => (
+
history.push(`/timeline/${story.instanceId || ''}`)}
+ style={{ animationDelay: `${120 + index * 90}ms` }}
+ type="button"
+ >
+
+ {formatDateLabel(story.updateTime || story.createTime)}
+ {story.ownerName || '我的故事'}
+
+ {story.title || '未命名故事'}
+ {story.description || '为这条时间线补上一段更有温度的描述,让它更像作品。'}
+
+
+
+ {story.storyTime || '等待设定时间'}
+
+
+
+ {Number(story.itemCount || 0)} 个时刻
+
+
+
+ ))}
+
+ ) : (
+
+
+ history.push('/story')}>
+ 去创建第一条故事
+
+
+ )}
+
+
+
+
+
+
+ Recent Media
+
最近上传
+
+
history.push('/gallery')}>
+ 打开图库
+
+
+
+
+ {loading ? (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+ ))}
+
+ ) : data.mediaList.length > 0 ? (
+
+ {data.mediaList.map((item, index) => {
+ const isVideo = Boolean(item.duration || item.thumbnailInstanceId);
+
+ return (
+
history.push('/gallery')}
+ style={{ animationDelay: `${160 + index * 70}ms` }}
+ type="button"
+ >
+
+
+
+
+ {isVideo ? '视频' : '图片'}
+
+ {item.imageName}
+ {formatDateLabel(item.createTime || item.uploadTime)}
+
+ {isVideo && }
+
+ );
+ })}
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ Spotlight
+
把功能改造成体验
+
+
刷新总览
+
+
+
+ {spotlightCards.map((item, index) => (
+
history.push(item.path)}
+ style={{ animationDelay: `${220 + index * 90}ms` }}
+ type="button"
+ >
+ {item.icon}
+
+
{item.title}
+
{item.description}
+
+
+ {item.action}
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Home;
+
+
+
diff --git a/src/pages/review/index.less b/src/pages/review/index.less
new file mode 100644
index 0000000..3b49ef1
--- /dev/null
+++ b/src/pages/review/index.less
@@ -0,0 +1,230 @@
+.yearly-review {
+ --review-bg: #f8f1e8;
+ --review-panel: rgba(255, 250, 245, 0.92);
+ --review-border: rgba(37, 45, 63, 0.08);
+ --review-ink: #1d2433;
+ --review-muted: #687284;
+ --review-accent: #d85f38;
+ --review-cool: #1f4d7a;
+ background:
+ radial-gradient(circle at top left, rgba(216, 95, 56, 0.16), transparent 28%),
+ linear-gradient(180deg, #fdf8f1 0%, #f3ece2 100%);
+ min-height: calc(100vh - 104px);
+ padding: 24px 0 40px;
+}
+
+.yearly-review__shell {
+ margin: 0 auto;
+ max-width: 1180px;
+ padding: 0 16px;
+}
+
+.yearly-review__hero,
+.yearly-review__stats,
+.yearly-review__panel {
+ backdrop-filter: blur(14px);
+ background: var(--review-panel);
+ border: 1px solid var(--review-border);
+ border-radius: 28px;
+ box-shadow: 0 24px 60px rgba(29, 36, 51, 0.1);
+}
+
+.yearly-review__hero {
+ padding: 32px;
+}
+
+.yearly-review__pill {
+ background: rgba(29, 36, 51, 0.08);
+ border-radius: 999px;
+ color: var(--review-ink);
+ padding: 6px 12px;
+}
+
+.yearly-review__hero h1,
+.yearly-review__panel h2,
+.yearly-review__feature-card h3 {
+ color: var(--review-ink);
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ letter-spacing: -0.03em;
+ margin: 0;
+}
+
+.yearly-review__hero h1 {
+ font-size: clamp(34px, 5vw, 58px);
+ line-height: 1.04;
+ margin-top: 18px;
+ max-width: 12ch;
+}
+
+.yearly-review__hero p,
+.yearly-review__feature-card p,
+.yearly-review__feature-tip {
+ color: var(--review-muted);
+ font-size: 16px;
+ line-height: 1.8;
+}
+
+.yearly-review__hero p {
+ margin: 16px 0 0;
+ max-width: 72ch;
+}
+
+.yearly-review__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.yearly-review__actions .ant-btn-primary,
+.yearly-review__feature-actions .ant-btn-primary {
+ background: linear-gradient(135deg, var(--review-accent), #b94b28);
+ border: none;
+}
+
+.yearly-review__stats {
+ display: grid;
+ gap: 16px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin-top: 20px;
+ padding: 18px;
+}
+
+.yearly-review__stats article {
+ background: rgba(255, 255, 255, 0.7);
+ border-radius: 20px;
+ min-height: 120px;
+ padding: 18px;
+}
+
+.yearly-review__stats span,
+.yearly-review__bar-row span,
+.yearly-review__feature-foot span,
+.yearly-review__feature-meta span {
+ color: var(--review-muted);
+}
+
+.yearly-review__stats strong {
+ color: var(--review-ink);
+ display: block;
+ font-size: clamp(28px, 4vw, 42px);
+ margin-top: 14px;
+}
+
+.yearly-review__content {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
+ margin-top: 20px;
+}
+
+.yearly-review__panel {
+ padding: 24px;
+}
+
+.yearly-review__panel-head {
+ align-items: center;
+ color: var(--review-cool);
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 20px;
+}
+
+.yearly-review__eyebrow {
+ color: rgba(29, 36, 51, 0.58);
+ display: block;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.yearly-review__panel-head h2 {
+ font-size: 30px;
+ margin-top: 6px;
+}
+
+.yearly-review__bars {
+ display: grid;
+ gap: 14px;
+}
+
+.yearly-review__bar-row {
+ align-items: center;
+ display: grid;
+ gap: 12px;
+ grid-template-columns: 64px minmax(0, 1fr) 44px;
+}
+
+.yearly-review__bar-track {
+ background: rgba(29, 36, 51, 0.08);
+ border-radius: 999px;
+ height: 12px;
+ overflow: hidden;
+}
+
+.yearly-review__bar-fill {
+ background: linear-gradient(90deg, var(--review-accent), var(--review-cool));
+ border-radius: inherit;
+ height: 100%;
+}
+
+.yearly-review__feature-card {
+ background: linear-gradient(160deg, rgba(216, 95, 56, 0.16), rgba(31, 77, 122, 0.16));
+ border: 1px solid rgba(29, 36, 51, 0.08);
+ border-radius: 24px;
+ padding: 22px;
+ width: 100%;
+}
+
+.yearly-review__feature-meta,
+.yearly-review__feature-foot,
+.yearly-review__feature-actions {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+.yearly-review__feature-card h3 {
+ font-size: 30px;
+ margin: 18px 0 10px;
+}
+
+.yearly-review__feature-foot {
+ border-top: 1px solid rgba(29, 36, 51, 0.1);
+ margin-top: 18px;
+ padding-top: 14px;
+}
+
+.yearly-review__feature-foot strong,
+.yearly-review__bar-row strong,
+.yearly-review__feature-tip strong {
+ color: var(--review-ink);
+}
+
+.yearly-review__feature-tip {
+ background: rgba(255, 255, 255, 0.5);
+ border-radius: 18px;
+ margin-top: 16px;
+ padding: 14px 16px;
+}
+
+.yearly-review__feature-actions {
+ flex-wrap: wrap;
+ margin-top: 18px;
+}
+
+@media (max-width: 960px) {
+ .yearly-review__stats,
+ .yearly-review__content {
+ grid-template-columns: 1fr;
+ }
+
+ .yearly-review__feature-meta,
+ .yearly-review__feature-foot,
+ .yearly-review__feature-actions {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
\ No newline at end of file
diff --git a/src/pages/review/index.tsx b/src/pages/review/index.tsx
new file mode 100644
index 0000000..4cdced0
--- /dev/null
+++ b/src/pages/review/index.tsx
@@ -0,0 +1,274 @@
+import { CalendarOutlined, CompassOutlined, ShareAltOutlined } from '@ant-design/icons';
+import { history, useRequest } from '@umijs/max';
+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 './index.less';
+
+type ReviewSummary = {
+ year: number;
+ stories: StoryType[];
+ totalStories: number;
+ totalMoments: number;
+ sharedStories: number;
+ busiestMonth: { label: string; count: number };
+ monthlyCounts: Array<{ label: string; count: number }>;
+ featuredStory?: StoryType;
+ topLocation?: string;
+ topTag?: string;
+};
+
+type YearlyReportResponse = {
+ year?: number;
+ totalMoments?: number;
+ totalMedia?: number;
+ mostActiveMonth?: string;
+ mostActiveDay?: string;
+ topLocation?: string;
+ topTag?: string;
+ monthlyBreakdown?: Array<{ month?: string; count?: number }>;
+};
+
+const buildMonthLabel = (month: number) => `M${month}`;
+
+const normalizeStories = (response: unknown): StoryType[] => {
+ if (Array.isArray((response as { data?: StoryType[] } | undefined)?.data)) {
+ return (response as { data: StoryType[] }).data;
+ }
+ if (Array.isArray(response)) return response as StoryType[];
+ return [];
+};
+
+const normalizeYearlyReport = (response: unknown): YearlyReportResponse | undefined => {
+ if ((response as { data?: YearlyReportResponse } | undefined)?.data) {
+ return (response as { data: YearlyReportResponse }).data;
+ }
+ if (response && typeof response === 'object') {
+ return response as YearlyReportResponse;
+ }
+ return undefined;
+};
+
+const getStoryDate = (story: StoryType) => {
+ const source = story.storyTime || story.updateTime || story.createTime;
+ if (!source) return undefined;
+ const date = new Date(source);
+ return Number.isNaN(date.getTime()) ? undefined : date;
+};
+
+const buildFallbackMonthlyCounts = (stories: StoryType[], year: number) => {
+ const monthlyCounts = Array.from({ length: 12 }, (_, index) => ({
+ label: buildMonthLabel(index + 1),
+ count: 0,
+ }));
+
+ stories.forEach((story) => {
+ const date = getStoryDate(story);
+ if (!date || date.getFullYear() !== year) return;
+ monthlyCounts[date.getMonth()].count += Number(story.itemCount || 0) || 1;
+ });
+
+ return monthlyCounts;
+};
+
+const YearlyReviewPage: React.FC = () => {
+ const reviewRequest = useRequest(async (): Promise => {
+ const stories = normalizeStories(await queryTimelineList({ count: 50 }));
+ const currentYear = new Date().getFullYear();
+ const inYear = stories.filter((story) => {
+ const date = getStoryDate(story);
+ return !date || date.getFullYear() === currentYear;
+ });
+
+ let report: YearlyReportResponse | undefined;
+ try {
+ report = normalizeYearlyReport(await queryTimelineYearlyReport(currentYear));
+ } catch (error) {
+ report = undefined;
+ }
+
+ const monthlyCounts = report?.monthlyBreakdown?.length
+ ? report.monthlyBreakdown.map((item, index) => ({
+ label: item.month || buildMonthLabel(index + 1),
+ count: Number(item.count || 0),
+ }))
+ : buildFallbackMonthlyCounts(inYear, currentYear);
+
+ const busiestMonth =
+ monthlyCounts.find((item) => item.label === report?.mostActiveMonth) ||
+ monthlyCounts.reduce(
+ (best, item) => (item.count > best.count ? item : best),
+ monthlyCounts[0] || { label: buildMonthLabel(1), count: 0 },
+ );
+
+ const featuredStory = [...inYear].sort(
+ (a, b) => Number(b.itemCount || 0) - Number(a.itemCount || 0),
+ )[0];
+
+ return {
+ year: report?.year || currentYear,
+ stories: inYear,
+ totalStories: inYear.length,
+ totalMoments:
+ typeof report?.totalMoments === 'number'
+ ? report.totalMoments
+ : inYear.reduce((sum, story) => sum + Number(story.itemCount || 0), 0),
+ sharedStories: inYear.filter((story) => Boolean(story.shareId)).length,
+ busiestMonth,
+ monthlyCounts,
+ featuredStory,
+ topLocation: report?.topLocation,
+ topTag: report?.topTag,
+ };
+ });
+
+ const data = reviewRequest.data as ReviewSummary | undefined;
+ const { loading, refresh } = reviewRequest;
+
+ if (loading || !data) {
+ return (
+
+ );
+ }
+
+ 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';
+
+ return (
+
+
+
+
+ {data.year} Review
+
+ Turn a year of photos, videos, and stories into a shareable memory review.
+
+ You captured {data.totalStories} storylines and {data.totalMoments} moments this year.
+ {` `}
+ {data.sharedStories} of them are already close to public-share quality.
+ {data.topLocation && data.topLocation !== '-' ? ` Top place: ${data.topLocation}.` : ''}
+ {data.topTag && data.topTag !== '-' ? ` Signature theme: ${data.topTag}.` : ''}
+
+
+ history.push('/story')}>
+ Keep shaping stories
+
+ history.push('/archive')}>
+ Open smart archive
+
+ refresh()}>
+ Refresh review
+
+
+
+
+
+
+ Stories
+ {data.totalStories}
+
+
+ Moments
+ {data.totalMoments}
+
+
+ Busiest month
+ {data.busiestMonth.label}
+
+
+ Share-ready
+ {data.sharedStories}
+
+
+
+
+
+
+
+ Monthly Pulse
+
Activity by month
+
+
+
+
+ {data.monthlyCounts.map((item) => (
+
+
{item.label}
+
+
{item.count}
+
+ ))}
+
+
+
+
+
+
+ Featured Story
+
The story that deserves the cover
+
+
+
+ {data.featuredStory ? (
+
+
+ {data.featuredStory.ownerName || 'My story'}
+ {data.featuredStory.shareId ? (
+
+ Public share is available
+
+ ) : (
+ Preview is ready even before the public endpoint is finished
+ )}
+
+
{data.featuredStory.title || 'Untitled story'}
+
+ {data.featuredStory.description ||
+ 'This is the densest storyline of the year and the best candidate for a polished cover story.'}
+
+
+ {data.featuredStory.storyTime || 'Add a story date to complete the narrative'}
+ {Number(data.featuredStory.itemCount || 0)} moments
+
+
+ Polish one representative story first. A single strong share experience will do more for product appeal than ten average pages.
+ {data.topLocation && data.topLocation !== '-' ? ` ${data.topLocation} is emerging as the strongest memory anchor this year.` : ''}
+
+
+ history.push(`/timeline/${data.featuredStory?.instanceId || ''}`)}
+ >
+ Keep editing
+
+ history.push(featuredSharePath)}>
+ {data.featuredStory.shareId ? 'Open public share' : 'Open preview'}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
+
+export default YearlyReviewPage;
\ No newline at end of file
diff --git a/src/pages/share/StoryShowcase.tsx b/src/pages/share/StoryShowcase.tsx
new file mode 100644
index 0000000..fd0a0e6
--- /dev/null
+++ b/src/pages/share/StoryShowcase.tsx
@@ -0,0 +1,243 @@
+import TimelineImage from '@/components/TimelineImage';
+import {
+ CalendarOutlined,
+ CameraOutlined,
+ EnvironmentOutlined,
+ PlayCircleOutlined,
+ ShareAltOutlined,
+} from '@ant-design/icons';
+import { PageContainer } from '@ant-design/pro-components';
+import { Avatar, Button, Card, Empty, Tag, Typography } from 'antd';
+import React from 'react';
+import ReactPlayer from 'react-player';
+import useStyles from './style.style';
+import type { ShareAction, StoryShowcaseData } from './showcase-utils';
+
+const { Paragraph, Text, Title } = Typography;
+
+type StoryShowcaseProps = {
+ data: StoryShowcaseData;
+ actions: ShareAction[];
+ embedded?: boolean;
+};
+
+const resolveVideoSrc = (videoInstanceId?: string) => {
+ if (!videoInstanceId) return undefined;
+ if (videoInstanceId.startsWith('http://') || videoInstanceId.startsWith('https://')) {
+ return videoInstanceId;
+ }
+ return `/api/file/download/${videoInstanceId}`;
+};
+
+const StoryShowcase: React.FC = ({ data, actions, embedded = false }) => {
+ const { styles } = useStyles();
+ const heroVideoSrc = resolveVideoSrc(data.hero?.videoInstanceId);
+
+ const showcaseCard = (
+
+
+
+
+
+ {data.badge}
+
+
+ {data.title}
+
+
{data.description}
+
+
+
+ {data.authorName?.slice(0, 1)}
+
+
+ {data.authorName}
+ A personal visual story from Timeline
+
+
+ {data.timeLabel && (
+
+
+ {data.timeLabel}
+
+ )}
+ {data.locationLabel && (
+
+
+ {data.locationLabel}
+
+ )}
+
+
+
+ {actions.length > 0 && (
+
+ {actions.map((action) => (
+
+ {action.label}
+
+ ))}
+
+ )}
+
+
+
+
+ {heroVideoSrc ? (
+
+
+
+ ) : data.hero?.imageInstanceId || data.hero?.src ? (
+
+ ) : (
+
{data.title.slice(0, 1)}
+ )}
+
+
+
+
+ {data.stats.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+ {data.quote && (
+
+
Story Note
+
{data.quote}
+
+ )}
+
+
+
+
+
+
+ Featured Moments
+
Moments worth slowing down for
+
+
{data.moments.length} selected moments
+
+
+ {data.moments.length > 0 ? (
+
+ {data.moments.map((moment) => {
+ const momentVideo = resolveVideoSrc(moment.cover?.videoInstanceId);
+ return (
+
+
+ {momentVideo ? (
+
+
+
+ ) : moment.cover?.imageInstanceId || moment.cover?.src ? (
+
+ ) : (
+
{moment.title.slice(0, 1)}
+ )}
+ {moment.cover?.videoInstanceId && (
+
+ Video
+
+ )}
+
+
+
+ {moment.storyTitle && {moment.storyTitle} }
+ {moment.location && {moment.location} }
+
+
{moment.title}
+
{moment.description}
+
+
+ {moment.timeLabel}
+
+
+ {moment.mediaCount} media items
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+ {data.gallery.length > 0 && (
+
+
+
+ Gallery
+
Visual gallery
+
+
{data.gallery.length} frames
+
+
+ {data.gallery.map((image) => (
+
+ ))}
+
+
+ )}
+
+ {data.footnote && (
+
+
+ {data.footnote}
+
+ )}
+
+
+ );
+
+ if (embedded) {
+ return {showcaseCard}
;
+ }
+
+ return {showcaseCard} ;
+};
+
+export default StoryShowcase;
\ No newline at end of file
diff --git a/src/pages/share/[shareId].tsx b/src/pages/share/[shareId].tsx
index d8f9bb5..8c19b12 100644
--- a/src/pages/share/[shareId].tsx
+++ b/src/pages/share/[shareId].tsx
@@ -1,91 +1,141 @@
-import { PageContainer } from '@ant-design/pro-components';
-import { useParams, useServerData, history } from '@umijs/max';
+import { ArrowLeftOutlined, CopyOutlined, HomeOutlined } from '@ant-design/icons';
+import { history, request, useParams, useRequest } from '@umijs/max';
+import { Button, Result, Skeleton, message } from 'antd';
import React from 'react';
import { Helmet } from 'react-helmet';
-import { Card, Typography, Avatar, Result, Button } from 'antd';
-import TimelineImage from '@/components/TimelineImage';
-import ReactPlayer from 'react-player';
+import StoryShowcase from './StoryShowcase';
+import { normalizePublicShowcase } from './showcase-utils';
import useStyles from './style.style';
-const { Title, Paragraph } = Typography;
+interface PublicStoryItem {
+ instanceId?: string;
+ storyItemTime?: string | number[];
+ title?: string;
+ description?: string;
+ content?: string;
+ createTime?: string;
+ authorAvatar?: string;
+ authorName?: string;
+ ownerName?: string;
+ video?: string;
+ videoUrl?: string;
+ thumbnailUrl?: string;
+ thumbnailInstanceId?: string;
+ images?: string[];
+ relatedImageInstanceIds?: string[];
+ location?: string;
+ coverInstanceId?: string;
+ shareTitle?: string;
+ shareDescription?: string;
+ shareQuote?: string;
+ heroMomentId?: string;
+ featuredMomentIds?: string[];
+ featuredMoments?: PublicStoryItem[];
+}
+
+const fetchShareStory = async (shareId: string) => {
+ const candidates = [
+ `/api/story/item/public/story/item/${shareId}`,
+ `/story/item/public/story/item/${shareId}`,
+ `/api/public/story/${shareId}`,
+ ];
+
+ for (const url of candidates) {
+ try {
+ const response = await request<{ data?: PublicStoryItem } | PublicStoryItem>(url, {
+ method: 'GET',
+ skipErrorHandler: true,
+ });
+ if (!response) continue;
+ if ('data' in response) return response.data;
+ return response;
+ } catch (error) {
+ console.warn('Failed to fetch public share story from', url, error);
+ }
+ }
+
+ return undefined;
+};
const SharePage: React.FC = () => {
const { styles } = useStyles();
- const params = useParams();
- const { shareId } = params;
- const serverData = useServerData();
- const storyItem = serverData?.storyItem;
+ const { shareId } = useParams<{ shareId: string }>();
+ const shareRequest = useRequest(
+ async () => {
+ if (!shareId) return undefined;
+ return fetchShareStory(shareId);
+ },
+ {
+ refreshDeps: [shareId],
+ },
+ );
- const handleOpenInApp = () => {
- // TODO: Implement deep linking logic here
- // Example: window.location.href = `timeline://story/${shareId}`;
- console.log('Attempting to open in app...');
- };
+ const storyItem = shareRequest.data as PublicStoryItem | undefined;
+ const { loading } = shareRequest;
- if (!storyItem) {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!shareId || !storyItem) {
return (
history.push('/')}>Back Home}
+ title="Share is not available"
+ subTitle="This public link may be invalid, or the backend public endpoint is not open yet."
+ extra={
+ history.push('/home')}>
+ Return home
+
+ }
/>
);
}
+ const showcase = normalizePublicShowcase(storyItem, shareId);
+ const pageUrl = typeof window !== 'undefined' ? window.location.href : `/share/${shareId}`;
+
return (
-
+ <>
- {storyItem.title}
-
-
-
- {/* */}
+ {showcase.title}
+
+
+
- 在 App 中打开}
- >
- }
- title={storyItem.authorName}
- description={storyItem.createTime}
- />
-
- {storyItem.title}
-
- {storyItem.description}
- {storyItem.video && (
-
-
-
- )}
- {storyItem.images?.map((image: string) => (
-
- ))}
-
-
+ ,
+ onClick: () => history.push('/home'),
+ },
+ {
+ key: 'copy',
+ label: 'Copy link',
+ icon: ,
+ onClick: async () => {
+ await navigator.clipboard.writeText(pageUrl);
+ message.success('Public share link copied');
+ },
+ },
+ {
+ key: 'back',
+ label: 'Back home',
+ icon: ,
+ onClick: () => history.push('/home'),
+ },
+ ]}
+ />
+ >
);
};
export default SharePage;
-
-export async function data() {
- const { shareId } = this.params;
- const res = await fetch(`http://localhost:8082/story/item/public/story/item/${shareId}`);
- const data = await res.json();
- return { storyItem: data.data };
-}
diff --git a/src/pages/share/preview/[storyId].tsx b/src/pages/share/preview/[storyId].tsx
new file mode 100644
index 0000000..d54f4f8
--- /dev/null
+++ b/src/pages/share/preview/[storyId].tsx
@@ -0,0 +1,132 @@
+import { ArrowLeftOutlined, CopyOutlined, EditOutlined, EyeOutlined, ShareAltOutlined } from '@ant-design/icons';
+import { history, useParams, useRequest } from '@umijs/max';
+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 StoryShowcase from '../StoryShowcase';
+import { buildPreviewShowcase } from '../showcase-utils';
+import { loadShareDraft } from '../shareDraft';
+import useStyles from '../style.style';
+
+type StoryItemsResponse = {
+ data?: {
+ list?: StoryItem[];
+ };
+ list?: StoryItem[];
+};
+
+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 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([
+ queryStoryDetail(storyId),
+ queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 60 }),
+ ]);
+ return {
+ story: storyResponse.data as StoryType,
+ items: normalizeItems(itemsResponse as StoryItemsResponse),
+ };
+ },
+ {
+ refreshDeps: [storyId],
+ },
+ );
+
+ const payload = previewRequest.data as { story: StoryType; items: StoryItem[] } | undefined;
+ const { loading } = previewRequest;
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!storyId || !payload?.story) {
+ return (
+ history.push('/story')}>
+ Return to stories
+
+ }
+ />
+ );
+ }
+
+ const draft = loadShareDraft(storyId);
+ const showcase = buildPreviewShowcase(payload.story, 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 studioUrl = `/share/studio/${storyId}`;
+
+ return (
+ <>
+
+ {`${showcase.title} - Share Preview`}
+
+
+ ,
+ onClick: () => history.push(`/timeline/${storyId}`),
+ },
+ {
+ key: 'studio',
+ label: 'Open Share Studio',
+ icon: ,
+ onClick: () => history.push(studioUrl),
+ },
+ ...(publicUrl
+ ? [
+ {
+ key: 'public',
+ label: 'Open public share',
+ icon: ,
+ onClick: () => history.push(publicUrl),
+ },
+ ]
+ : [
+ {
+ key: 'stories',
+ label: 'Back to stories',
+ icon: ,
+ onClick: () => history.push('/story'),
+ },
+ ]),
+ {
+ key: 'copy',
+ label: 'Copy preview link',
+ icon: ,
+ onClick: async () => {
+ await navigator.clipboard.writeText(previewUrl);
+ message.success('Preview link copied');
+ },
+ },
+ ]}
+ />
+ >
+ );
+};
+
+export default PreviewSharePage;
\ No newline at end of file
diff --git a/src/pages/share/shareDraft.ts b/src/pages/share/shareDraft.ts
new file mode 100644
index 0000000..f13bcd9
--- /dev/null
+++ b/src/pages/share/shareDraft.ts
@@ -0,0 +1,59 @@
+export type ShareDraft = {
+ storyId: string;
+ title?: string;
+ description?: string;
+ quote?: string;
+ heroMomentId?: string;
+ featuredMomentIds?: string[];
+ updatedAt?: string;
+};
+
+const STORAGE_KEY = 'timeline_share_drafts_v1';
+
+type DraftMap = Record;
+
+const readDraftMap = (): DraftMap => {
+ if (typeof window === 'undefined') return {};
+
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw) as DraftMap;
+ return parsed && typeof parsed === 'object' ? parsed : {};
+ } catch {
+ return {};
+ }
+};
+
+const writeDraftMap = (draftMap: DraftMap) => {
+ if (typeof window === 'undefined') return;
+
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(draftMap));
+ } catch {
+ // Ignore storage failures and keep the editor usable.
+ }
+};
+
+export const loadShareDraft = (storyId: string): ShareDraft | undefined => {
+ if (!storyId) return undefined;
+ const draftMap = readDraftMap();
+ return draftMap[storyId];
+};
+
+export const saveShareDraft = (draft: ShareDraft) => {
+ if (!draft.storyId) return;
+ const draftMap = readDraftMap();
+ draftMap[draft.storyId] = {
+ ...draft,
+ updatedAt: draft.updatedAt || new Date().toISOString(),
+ };
+ writeDraftMap(draftMap);
+};
+
+export const clearShareDraft = (storyId: string) => {
+ if (!storyId) return;
+ const draftMap = readDraftMap();
+ delete draftMap[storyId];
+ writeDraftMap(draftMap);
+};
diff --git a/src/pages/share/showcase-utils.ts b/src/pages/share/showcase-utils.ts
new file mode 100644
index 0000000..cfd39dc
--- /dev/null
+++ b/src/pages/share/showcase-utils.ts
@@ -0,0 +1,333 @@
+import type { ReactNode } from 'react';
+import type { StoryItem, StoryType } from '@/pages/story/data.d';
+import type { ShareDraft } from './shareDraft';
+
+export type ShareAction = {
+ key: string;
+ label: string;
+ primary?: boolean;
+ icon?: ReactNode;
+ onClick: () => void;
+};
+
+export type ShareStat = {
+ label: string;
+ value: number | string;
+};
+
+export type ShareMedia = {
+ key: string;
+ title?: string;
+ imageInstanceId?: string;
+ src?: string;
+ videoInstanceId?: string;
+ thumbnailInstanceId?: string;
+};
+
+export type ShareMoment = {
+ key: string;
+ title: string;
+ description: string;
+ timeLabel: string;
+ location?: string;
+ storyTitle?: string;
+ mediaCount: number;
+ cover?: ShareMedia;
+ tags?: string[];
+};
+
+export type StoryShowcaseData = {
+ badge: string;
+ title: string;
+ description: string;
+ authorName: string;
+ authorAvatar?: string;
+ timeLabel?: string;
+ locationLabel?: string;
+ hero: ShareMedia | undefined;
+ quote?: string;
+ stats: ShareStat[];
+ gallery: ShareMedia[];
+ moments: ShareMoment[];
+ footnote?: string;
+};
+
+type PublicStoryItem = {
+ instanceId?: string;
+ storyItemTime?: string | number[];
+ title?: string;
+ description?: string;
+ content?: string;
+ createTime?: string;
+ authorAvatar?: string;
+ authorName?: string;
+ ownerName?: string;
+ video?: string;
+ videoUrl?: string;
+ thumbnailUrl?: string;
+ thumbnailInstanceId?: string;
+ images?: string[];
+ relatedImageInstanceIds?: string[];
+ location?: string;
+ coverInstanceId?: string;
+ shareTitle?: string;
+ shareDescription?: string;
+ shareQuote?: string;
+ heroMomentId?: string;
+ featuredMomentIds?: string[];
+ featuredMoments?: PublicStoryItem[];
+};
+
+const pad = (value: number) => String(value).padStart(2, '0');
+
+export const formatStoryTime = (value?: string | number[]) => {
+ if (!value) return 'Pending date';
+
+ if (Array.isArray(value)) {
+ const [year, month, day, hour = 0, minute = 0] = value;
+ return `${year}.${pad(month)}.${pad(day)} ${pad(hour)}:${pad(minute)}`;
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return String(value);
+
+ return new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+};
+
+const getSortValue = (value?: string | number[]) => {
+ if (!value) return 0;
+
+ if (Array.isArray(value)) {
+ const [year, month, day, hour = 0, minute = 0, second = 0] = value;
+ return new Date(year, month - 1, day, hour, minute, second).getTime();
+ }
+
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? 0 : date.getTime();
+};
+
+const pickHeroFromItem = (item?: Partial | PublicStoryItem): ShareMedia | undefined => {
+ if (!item) return undefined;
+
+ const publicVideo = 'video' in item ? item.video : undefined;
+ const videoInstanceId = item.videoUrl || publicVideo;
+ const thumbnailInstanceId = item.thumbnailInstanceId || item.thumbnailUrl;
+ if (videoInstanceId) {
+ return {
+ key: `video-${videoInstanceId}`,
+ title: item.title,
+ videoInstanceId,
+ thumbnailInstanceId,
+ };
+ }
+
+ const imageInstanceId =
+ item.coverInstanceId ||
+ item.images?.[0] ||
+ item.relatedImageInstanceIds?.[0] ||
+ item.thumbnailInstanceId;
+
+ if (imageInstanceId) {
+ return {
+ key: `image-${imageInstanceId}`,
+ title: item.title,
+ imageInstanceId,
+ };
+ }
+
+ if (item.thumbnailUrl) {
+ return {
+ key: `thumb-${item.thumbnailUrl}`,
+ title: item.title,
+ src: item.thumbnailUrl,
+ };
+ }
+
+ return undefined;
+};
+
+const collectGalleryImages = (items: Array) => {
+ const seen = new Set();
+ const gallery: ShareMedia[] = [];
+
+ items.forEach((item) => {
+ const sources = [
+ item.coverInstanceId,
+ ...(item.images || []),
+ ...(item.relatedImageInstanceIds || []),
+ ].filter(Boolean) as string[];
+
+ sources.forEach((imageId) => {
+ if (seen.has(imageId) || gallery.length >= 12) return;
+ seen.add(imageId);
+ gallery.push({
+ key: `gallery-${imageId}`,
+ title: item.title,
+ imageInstanceId: imageId,
+ });
+ });
+ });
+
+ return gallery;
+};
+
+const buildLocationLabel = (items: Array<{ location?: string }>, fallback?: string) => {
+ const locations = Array.from(new Set(items.map((item) => item.location).filter(Boolean))) as string[];
+ if (locations.length === 0) return fallback;
+ if (locations.length === 1) return locations[0];
+ return `${locations[0]} +${locations.length - 1} more places`;
+};
+
+const orderPreviewItems = (items: StoryItem[], draft?: ShareDraft) => {
+ const sortedItems = [...items].sort(
+ (left, right) => getSortValue(right.storyItemTime) - getSortValue(left.storyItemTime),
+ );
+
+ if (!draft?.heroMomentId) return sortedItems;
+
+ const heroIndex = sortedItems.findIndex((item) => item.instanceId === draft.heroMomentId);
+ if (heroIndex === -1) return sortedItems;
+
+ const [heroItem] = sortedItems.splice(heroIndex, 1);
+ sortedItems.unshift(heroItem);
+ return sortedItems;
+};
+
+const selectFeaturedItems = (orderedItems: StoryItem[], draft?: ShareDraft) => {
+ const featuredIds = (draft?.featuredMomentIds || []).filter(Boolean);
+ if (featuredIds.length === 0) return orderedItems.slice(0, 6);
+
+ const byId = new Map(orderedItems.map((item) => [item.instanceId, item]));
+ const selected = featuredIds
+ .map((id) => byId.get(id))
+ .filter((item): item is StoryItem => Boolean(item));
+
+ return selected.length > 0 ? selected.slice(0, 6) : orderedItems.slice(0, 6);
+};
+
+export const buildPreviewShowcase = (
+ story: StoryType,
+ items: StoryItem[],
+ draft?: ShareDraft,
+): StoryShowcaseData => {
+ const orderedItems = orderPreviewItems(items, draft);
+ const featuredItems = selectFeaturedItems(orderedItems, draft);
+ const heroSource = orderedItems.find((item) => pickHeroFromItem(item)) || orderedItems[0];
+ const hero = pickHeroFromItem(heroSource);
+ const gallery = collectGalleryImages(orderedItems);
+ const distinctLocations = Array.from(
+ new Set(orderedItems.map((item) => item.location).filter(Boolean)),
+ ) as string[];
+ const draftTitle = draft?.title?.trim();
+ const draftDescription = draft?.description?.trim();
+ const draftQuote = draft?.quote?.trim();
+ const quote =
+ draftQuote ||
+ story.description ||
+ heroSource?.description ||
+ heroSource?.content ||
+ 'Shape one strong story page and the whole product starts to feel more shareable.';
+
+ return {
+ badge: story.shareId ? 'Share Preview' : 'Story Preview',
+ title: draftTitle || story.title || 'Untitled story',
+ description:
+ draftDescription ||
+ story.description ||
+ 'This preview is generated from the current story content, so you can refine presentation before relying on a public endpoint.',
+ authorName: story.ownerName || 'Timeline user',
+ timeLabel: formatStoryTime(story.storyTime || story.updateTime || story.createTime),
+ locationLabel: buildLocationLabel(orderedItems),
+ hero,
+ quote,
+ stats: [
+ { label: 'Moments', value: orderedItems.length || Number(story.itemCount || 0) },
+ { label: 'Featured', value: featuredItems.length },
+ { label: 'Places', value: distinctLocations.length },
+ { label: 'Share state', value: story.shareId ? 'Public' : 'Preview' },
+ ],
+ gallery,
+ moments: featuredItems.map((item, index) => ({
+ key: item.instanceId || `${story.instanceId || 'story'}-${index}`,
+ title: item.title || 'Untitled moment',
+ description:
+ item.description || item.content || 'Add one line of context and this moment becomes much easier to revisit.',
+ timeLabel: formatStoryTime(item.storyItemTime),
+ location: item.location,
+ storyTitle: story.title,
+ mediaCount: Math.max(
+ item.images?.length || 0,
+ item.relatedImageInstanceIds?.length || 0,
+ item.videoUrl ? 1 : 0,
+ ),
+ cover: pickHeroFromItem(item),
+ })),
+ footnote: draft?.updatedAt
+ ? `Edited in Share Studio on ${new Date(draft.updatedAt).toLocaleString('zh-CN')}`
+ : story.shareId
+ ? 'This is a preview layer for a story that already has a public share id.'
+ : 'Public sharing is not required to keep improving how the story will look when shared.',
+ };
+};
+
+export const normalizePublicShowcase = (
+ storyItem: PublicStoryItem,
+ shareId: string,
+): StoryShowcaseData => {
+ const featuredMoments = storyItem.featuredMoments?.length
+ ? storyItem.featuredMoments
+ : [storyItem];
+ const hero = pickHeroFromItem(storyItem);
+ const gallery = collectGalleryImages([storyItem, ...featuredMoments]);
+ const description =
+ storyItem.shareDescription ||
+ storyItem.description ||
+ storyItem.content ||
+ 'A public story page from Timeline.';
+ const quote = storyItem.shareQuote || storyItem.shareDescription || storyItem.description || storyItem.content;
+ const locationLabel = buildLocationLabel(featuredMoments, storyItem.location);
+
+ return {
+ badge: 'Public Share',
+ title: storyItem.shareTitle || storyItem.title || 'Memory share',
+ description,
+ authorName: storyItem.authorName || storyItem.ownerName || 'Timeline user',
+ authorAvatar: storyItem.authorAvatar,
+ timeLabel: formatStoryTime(storyItem.storyItemTime || storyItem.createTime),
+ locationLabel,
+ hero,
+ quote,
+ stats: [
+ { label: 'Images', value: gallery.length },
+ { label: 'Video clips', value: storyItem.videoUrl || storyItem.video ? 1 : 0 },
+ { label: 'Featured', value: featuredMoments.length },
+ { label: 'Share id', value: shareId.slice(0, 8) },
+ ],
+ gallery,
+ moments: featuredMoments.map((moment, index) => {
+ const momentGallery = collectGalleryImages([moment]);
+ return {
+ key: moment.instanceId || `${shareId}-${index}`,
+ title: moment.title || 'Shared moment',
+ description:
+ moment.description ||
+ moment.content ||
+ 'This moment is part of the curated public story.',
+ timeLabel: formatStoryTime(moment.storyItemTime || moment.createTime),
+ location: moment.location,
+ mediaCount: Math.max(momentGallery.length, moment.videoUrl || moment.video ? 1 : 0),
+ cover: pickHeroFromItem(moment),
+ };
+ }),
+ footnote:
+ featuredMoments.length > 1
+ ? 'This public page now includes a curated sequence of featured moments.'
+ : 'This page is designed for external viewing and simple forwarding.',
+ };
+};
diff --git a/src/pages/share/studio/[storyId].tsx b/src/pages/share/studio/[storyId].tsx
new file mode 100644
index 0000000..3148a51
--- /dev/null
+++ b/src/pages/share/studio/[storyId].tsx
@@ -0,0 +1,585 @@
+import TimelineImage from '@/components/TimelineImage';
+import {
+ ArrowLeftOutlined,
+ CopyOutlined,
+ DeleteOutlined,
+ EyeOutlined,
+ PlayCircleOutlined,
+ SaveOutlined,
+ ShareAltOutlined,
+} from '@ant-design/icons';
+import { PageContainer } from '@ant-design/pro-components';
+import { history, useParams, useRequest } from '@umijs/max';
+import { Alert, Button, Empty, Input, Skeleton, Space, Tag, Typography, message } from 'antd';
+import React, { useEffect, useMemo, useState } from 'react';
+import type { StoryItem, StoryType } from '@/pages/story/data.d';
+import {
+ publishStoryShare,
+ queryStoryDetail,
+ queryStoryItem,
+ queryStoryShareConfig,
+ type StoryShareConfig,
+ unpublishStoryShare,
+} from '@/pages/story/service';
+import StoryShowcase from '../StoryShowcase';
+import { buildPreviewShowcase, formatStoryTime } from '../showcase-utils';
+import { clearShareDraft, loadShareDraft, saveShareDraft, type ShareDraft } from '../shareDraft';
+import useStyles from '../style.style';
+
+const { TextArea } = Input;
+const { Paragraph, Title } = Typography;
+
+type StoryItemsResponse = {
+ data?: {
+ list?: StoryItem[];
+ };
+ list?: StoryItem[];
+};
+
+type StoryDetailResponse = {
+ data?: StoryType;
+} & StoryType;
+
+type ShareConfigResponse = {
+ data?: StoryShareConfig;
+};
+
+type DraftFormState = {
+ title: string;
+ description: string;
+ quote: string;
+ heroMomentId?: string;
+ featuredMomentIds: string[];
+};
+
+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 normalizeStory = (response: StoryDetailResponse | undefined) => {
+ if (!response) return undefined;
+ if (response.data) return response.data;
+ return response as StoryType;
+};
+
+const normalizeShareConfig = (response: { data?: StoryShareConfig } | StoryShareConfig | undefined) => {
+ if (!response) return undefined;
+ if ('data' in response) return response.data;
+ return response;
+};
+
+const getMomentPreview = (item: StoryItem) => {
+ const imageInstanceId =
+ item.coverInstanceId || item.images?.[0] || item.relatedImageInstanceIds?.[0] || item.thumbnailInstanceId;
+ const src = item.coverImage || item.thumbnailUrl;
+ return { imageInstanceId, src, hasVideo: Boolean(item.videoUrl) };
+};
+
+const hasPreviewMedia = (item: StoryItem) => {
+ const preview = getMomentPreview(item);
+ return Boolean(preview.imageInstanceId || preview.src || item.videoUrl);
+};
+
+const getDefaultFeaturedMomentIds = (items: StoryItem[], heroMomentId?: string) => {
+ const result: string[] = [];
+ const candidates = [...items.filter((item) => Boolean(item.instanceId))];
+
+ if (heroMomentId) {
+ result.push(heroMomentId);
+ }
+
+ candidates.forEach((item) => {
+ if (!item.instanceId || result.includes(item.instanceId) || result.length >= 6) return;
+ result.push(item.instanceId);
+ });
+
+ return result;
+};
+
+const buildInitialDraft = (
+ storyId: string,
+ story: StoryType,
+ items: StoryItem[],
+ shareConfig?: StoryShareConfig,
+) => {
+ const saved = loadShareDraft(storyId);
+ const configFeaturedMomentIds = shareConfig?.featuredMomentIds?.filter(Boolean) || [];
+ const defaultHero = shareConfig?.heroMomentId || items.find(hasPreviewMedia)?.instanceId;
+ const featuredMomentIds =
+ saved?.featuredMomentIds?.filter(Boolean) ||
+ (configFeaturedMomentIds.length > 0 ? configFeaturedMomentIds : undefined) ||
+ getDefaultFeaturedMomentIds(items, saved?.heroMomentId || defaultHero);
+
+ return {
+ title: saved?.title || shareConfig?.title || story.title || '',
+ description: saved?.description || shareConfig?.description || story.description || '',
+ quote: saved?.quote || shareConfig?.quote || story.description || '',
+ heroMomentId: saved?.heroMomentId || shareConfig?.heroMomentId || defaultHero,
+ featuredMomentIds,
+ } satisfies DraftFormState;
+};
+
+const ShareStudioPage: React.FC = () => {
+ const { styles } = useStyles();
+ const { storyId } = useParams<{ storyId: string }>();
+ const [draft, setDraft] = useState({
+ title: '',
+ description: '',
+ quote: '',
+ heroMomentId: undefined,
+ featuredMomentIds: [],
+ });
+ const [activeShareId, setActiveShareId] = useState();
+ const [persistedShareConfig, setPersistedShareConfig] = useState();
+ const [publishing, setPublishing] = useState(false);
+ const [unpublishing, setUnpublishing] = useState(false);
+
+ const studioRequest = useRequest(
+ async () => {
+ if (!storyId) return undefined;
+ const [storyResponse, itemsResponse, shareConfigResponse] = await Promise.all([
+ queryStoryDetail(storyId),
+ queryStoryItem({ storyInstanceId: storyId, current: 1, pageSize: 80 }),
+ queryStoryShareConfig(storyId).catch(() => undefined),
+ ]);
+ return {
+ story: normalizeStory(storyResponse as StoryDetailResponse),
+ items: normalizeItems(itemsResponse as StoryItemsResponse),
+ shareConfig: normalizeShareConfig(shareConfigResponse as ShareConfigResponse | StoryShareConfig | undefined),
+ };
+ },
+ {
+ refreshDeps: [storyId],
+ },
+ );
+
+ const payload = studioRequest.data as
+ | { story?: StoryType; items: StoryItem[]; shareConfig?: StoryShareConfig }
+ | undefined;
+ const { loading } = studioRequest;
+
+ useEffect(() => {
+ if (!storyId || !payload?.story) return;
+ setPersistedShareConfig(payload.shareConfig);
+ setDraft(buildInitialDraft(storyId, payload.story, payload.items, payload.shareConfig));
+ setActiveShareId(payload.story.shareId);
+ }, [payload, storyId]);
+
+ const showcaseDraft = useMemo(() => {
+ if (!storyId || !payload?.story) return undefined;
+ return {
+ storyId,
+ title: draft.title,
+ description: draft.description,
+ quote: draft.quote,
+ heroMomentId: draft.heroMomentId,
+ featuredMomentIds: draft.featuredMomentIds,
+ updatedAt: loadShareDraft(storyId)?.updatedAt,
+ };
+ }, [draft, payload, storyId]);
+
+ const story = payload?.story;
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!storyId || !story) {
+ return (
+
+
+
+ );
+ }
+
+ const shareReady = Boolean(activeShareId);
+ const storyWithShareState = {
+ ...story,
+ shareId: activeShareId,
+ };
+ const showcase = buildPreviewShowcase(storyWithShareState, payload.items, showcaseDraft);
+ const publicUrl = activeShareId ? `/share/${activeShareId}` : undefined;
+ 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 copyLink = async (relativeUrl: string, successMessage: string) => {
+ const fullUrl = `${window.location.origin}${relativeUrl}`;
+
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(fullUrl);
+ message.success(successMessage);
+ return;
+ }
+
+ message.info(fullUrl);
+ };
+
+ const saveDraft = () => {
+ const nextDraft: ShareDraft = {
+ storyId,
+ title: draft.title.trim(),
+ description: draft.description.trim(),
+ quote: draft.quote.trim(),
+ heroMomentId: draft.heroMomentId,
+ featuredMomentIds: draft.featuredMomentIds,
+ updatedAt: new Date().toISOString(),
+ };
+ saveShareDraft(nextDraft);
+ message.success('Share draft saved');
+ };
+
+ const persistDraft = () => {
+ const nextDraft: ShareDraft = {
+ storyId,
+ title: draft.title.trim(),
+ description: draft.description.trim(),
+ quote: draft.quote.trim(),
+ heroMomentId: draft.heroMomentId,
+ featuredMomentIds: draft.featuredMomentIds,
+ updatedAt: new Date().toISOString(),
+ };
+ saveShareDraft(nextDraft);
+ return nextDraft;
+ };
+
+ const resetDraft = () => {
+ clearShareDraft(storyId);
+ setDraft(buildInitialDraft(storyId, story, payload.items, persistedShareConfig));
+ message.success('Share draft reset');
+ };
+
+ const toggleFeaturedMoment = (momentId?: string) => {
+ if (!momentId) return;
+
+ if (draft.featuredMomentIds.includes(momentId)) {
+ setDraft((current) => ({
+ ...current,
+ featuredMomentIds: current.featuredMomentIds.filter((id) => id !== momentId),
+ }));
+ return;
+ }
+
+ if (draft.featuredMomentIds.length >= 6) {
+ message.info('Pick up to 6 spotlight moments.');
+ return;
+ }
+
+ setDraft((current) => ({
+ ...current,
+ featuredMomentIds: [...current.featuredMomentIds, momentId],
+ }));
+ };
+
+ const handlePublish = async () => {
+ if (!draft.heroMomentId) {
+ message.warning('Choose a hero moment before publishing.');
+ return;
+ }
+
+ setPublishing(true);
+ try {
+ const nextDraft = persistDraft();
+ const response = await publishStoryShare({
+ storyId,
+ heroMomentId: draft.heroMomentId,
+ title: nextDraft.title,
+ description: nextDraft.description,
+ quote: nextDraft.quote,
+ featuredMomentIds: nextDraft.featuredMomentIds,
+ });
+ const nextShareId = response?.data?.shareId;
+ if (!nextShareId) {
+ throw new Error('Missing share id');
+ }
+ setActiveShareId(nextShareId);
+ setPersistedShareConfig({
+ shareId: nextShareId,
+ storyId,
+ heroMomentId: draft.heroMomentId,
+ title: nextDraft.title,
+ description: nextDraft.description,
+ quote: nextDraft.quote,
+ featuredMomentIds: nextDraft.featuredMomentIds || [],
+ published: true,
+ updatedAt: new Date().toISOString(),
+ });
+ message.success(shareReady ? 'Public share updated.' : 'Public share is now live.');
+ } catch (error) {
+ console.error('Failed to publish story share', error);
+ message.error('Failed to publish this story right now.');
+ } finally {
+ setPublishing(false);
+ }
+ };
+
+ const handleUnpublish = async () => {
+ setUnpublishing(true);
+ try {
+ await unpublishStoryShare(storyId);
+ setActiveShareId(undefined);
+ setPersistedShareConfig((current) =>
+ current
+ ? {
+ ...current,
+ heroMomentId: draft.heroMomentId,
+ title: draft.title.trim(),
+ description: draft.description.trim(),
+ quote: draft.quote.trim(),
+ featuredMomentIds: draft.featuredMomentIds,
+ published: false,
+ updatedAt: new Date().toISOString(),
+ }
+ : undefined,
+ );
+ message.success('Public share has been turned off.');
+ } catch (error) {
+ console.error('Failed to unpublish story share', error);
+ message.error('Failed to turn off the public share.');
+ } finally {
+ setUnpublishing(false);
+ }
+ };
+
+ return (
+ history.push(`/timeline/${storyId}`)}
+ >
+
+
+
+
+
+
+ Share workflow
+
+
Tune the story before sharing
+
+ Public endpoints can come later. What matters now is shaping a clean cover, a clear hook, and a page that feels intentional.
+
+
+
+ } onClick={() => history.push(`/timeline/${storyId}`)}>
+ Back to timeline
+
+ } onClick={saveDraft}>
+ Save draft
+
+
+
+
+
+
+ 0 ? 'success' : 'warning'}
+ showIcon
+ />
+
+
+
+ Preview title
+ setDraft((current) => ({ ...current, title: event.target.value }))}
+ />
+
+
+
+ Preview description
+
+
+
+ Story note
+
+
+
+
+
+ Cover Selection
+
Choose the hero moment
+
+
{coverCandidates.length} media-ready candidates
+
+
+ {coverCandidates.length > 0 ? (
+
+ {coverCandidates.map((item) => {
+ const preview = getMomentPreview(item);
+ const active = draft.heroMomentId === item.instanceId;
+ return (
+
+ setDraft((current) => ({ ...current, heroMomentId: item.instanceId }))
+ }
+ type="button"
+ >
+
+ {preview.imageInstanceId || preview.src ? (
+
+ ) : (
+
{item.title?.slice(0, 1) || 'M'}
+ )}
+ {preview.hasVideo && (
+
+ Video
+
+ )}
+
+
+ {item.title || 'Untitled moment'}
+ {formatStoryTime(item.storyItemTime)}
+ {item.location && {item.location} }
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+
+ Spotlight Moments
+
Choose up to six moments for the share page
+
+
{draft.featuredMomentIds.length}/6 selected
+
+
+ {spotlightCandidates.length > 0 ? (
+
+ {spotlightCandidates.map((item) => {
+ const preview = getMomentPreview(item);
+ const active = draft.featuredMomentIds.includes(item.instanceId);
+ return (
+
toggleFeaturedMoment(item.instanceId)}
+ type="button"
+ >
+
+ {preview.imageInstanceId || preview.src ? (
+
+ ) : (
+
{item.title?.slice(0, 1) || 'M'}
+ )}
+
+ {active ? 'Featured' : preview.hasVideo ? 'Video' : 'Moment'}
+
+
+
+ {item.title || 'Untitled moment'}
+ {formatStoryTime(item.storyItemTime)}
+ {item.location && {item.location} }
+ {active ? 'Pinned in the preview' : 'Tap to feature this moment'}
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+ }
+ loading={publishing}
+ onClick={() => void handlePublish()}
+ >
+ {shareReady ? 'Update public share' : 'Publish public share'}
+
+ } onClick={() => history.push(previewUrl)}>
+ Open preview
+
+ } onClick={() => void copyLink(previewUrl, 'Preview link copied')}>
+ Copy preview link
+
+ {publicUrl && (
+ <>
+ } onClick={() => history.push(publicUrl)}>
+ Open public share
+
+ } onClick={() => void copyLink(publicUrl, 'Public link copied')}>
+ Copy public link
+
+ void handleUnpublish()}>
+ Turn off public share
+
+ >
+ )}
+ } onClick={resetDraft}>
+ Reset draft
+
+
+
+
+
+
+
+ Live Preview
+
Preview the share page while you edit
+
+
{shareReady ? 'Public route is available' : 'Preview route only'}
+
+
+
+
+
+
+ );
+};
+
+export default ShareStudioPage;
diff --git a/src/pages/share/style.style.ts b/src/pages/share/style.style.ts
index 7b6ee7e..95694c0 100644
--- a/src/pages/share/style.style.ts
+++ b/src/pages/share/style.style.ts
@@ -2,29 +2,533 @@ import { createStyles } from 'antd-style';
export default createStyles(({ css, token }) => ({
sharePage: css`
- background-color: ${token.colorBgLayout};
- padding: 24px;
+ min-height: 100vh;
+ padding: 24px 0 48px;
+ background:
+ 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%);
+ `,
+ embeddedPage: css`
+ background: transparent;
+ `,
+ shell: css`
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 0 16px;
+ `,
+ embeddedShell: css`
+ padding: 0;
`,
card: css`
- max-width: 800px;
- margin: auto;
- border-radius: 8px;
- box-shadow: ${token.boxShadowSecondary};
+ border-radius: 32px;
+ overflow: hidden;
+ box-shadow: 0 28px 80px rgba(15, 23, 42, 0.12);
+ background: rgba(255, 255, 255, 0.9);
+ backdrop-filter: blur(18px);
`,
- meta: css`
- margin-bottom: 16px;
+ heroHeader: css`
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 6px 0 30px;
+ flex-wrap: wrap;
+ `,
+ heroCopy: css`
+ flex: 1;
+ min-width: 280px;
+ `,
+ heroBadge: css`
+ background: rgba(15, 23, 42, 0.08);
+ color: ${token.colorText};
+ border-radius: 999px;
+ padding: 6px 12px;
`,
title: css`
- margin-bottom: 16px;
+ margin: 14px 0 10px !important;
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ letter-spacing: -0.03em;
`,
- playerWrapper: css`
+ description: css`
+ max-width: 780px;
+ font-size: 16px;
+ color: ${token.colorTextSecondary};
+ line-height: 1.8;
+ `,
+ metaRow: css`
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 20px;
+ align-items: center;
+ `,
+ authorBlock: css`
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 220px;
+
+ strong {
+ display: block;
+ color: ${token.colorText};
+ }
+
+ span {
+ color: ${token.colorTextSecondary};
+ font-size: 13px;
+ }
+ `,
+ metaChip: css`
+ display: inline-flex;
+ align-items: center;
+ 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};
+ `,
+ actionRow: css`
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ `,
+ heroGrid: css`
+ display: grid;
+ gap: 20px;
+ grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
+ margin-bottom: 28px;
+ `,
+ coverPanel: css`
+ min-height: 420px;
+ border-radius: 28px;
+ overflow: hidden;
+ background: linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.68));
position: relative;
- padding-top: 56.25%; /* 16:9 aspect ratio */
+ `,
+ heroImage: css`
+ width: 100%;
+ height: 100%;
+ min-height: 420px;
+ border-radius: 28px;
+ overflow: hidden;
+ `,
+ coverPlaceholder: css`
+ width: 100%;
+ min-height: 420px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 120px;
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ `,
+ sidePanel: css`
+ display: grid;
+ gap: 16px;
+ align-content: start;
+ `,
+ statsGrid: css`
+ display: grid;
+ gap: 14px;
+ 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);
+ border-radius: 22px;
+ min-height: 118px;
+ padding: 18px;
+
+ span {
+ color: ${token.colorTextSecondary};
+ display: block;
+ }
+
+ strong {
+ color: ${token.colorText};
+ display: block;
+ font-size: clamp(24px, 3vw, 36px);
+ margin-top: 14px;
+ line-height: 1.1;
+ }
+ `,
+ 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);
+ border-radius: 22px;
+ padding: 20px;
+
+ p {
+ margin: 12px 0 0;
+ color: ${token.colorTextSecondary};
+ line-height: 1.9;
+ font-size: 15px;
+ }
+ `,
+ section: css`
+ margin-top: 28px;
+ `,
+ sectionHead: css`
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
margin-bottom: 16px;
+
+ h2 {
+ margin: 6px 0 0;
+ color: ${token.colorText};
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ font-size: 30px;
+ letter-spacing: -0.03em;
+ }
+ `,
+ sectionEyebrow: css`
+ display: block;
+ font-size: 11px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: ${token.colorTextTertiary};
+ font-weight: 700;
+ `,
+ sectionMeta: css`
+ color: ${token.colorTextSecondary};
+ `,
+ momentList: css`
+ display: grid;
+ gap: 16px;
+ `,
+ momentCard: css`
+ 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);
+ border-radius: 24px;
+ padding: 16px;
+ `,
+ momentMedia: css`
+ min-height: 180px;
+ position: relative;
+ `,
+ momentVideoShell: css`
+ border-radius: 18px;
+ overflow: hidden;
+ min-height: 180px;
+ padding-top: 56.25%;
+ background: #0f172a;
+ position: relative;
`,
reactPlayer: css`
position: absolute;
- top: 0;
- left: 0;
+ inset: 0;
`,
-}));
+ momentImage: css`
+ width: 100%;
+ min-height: 180px;
+ border-radius: 18px;
+ overflow: hidden;
+ `,
+ momentPlaceholder: css`
+ min-height: 180px;
+ border-radius: 18px;
+ display: flex;
+ 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};
+ font-size: 64px;
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ `,
+ mediaBadge: css`
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: white;
+ background: rgba(15, 23, 42, 0.78);
+ border-radius: 999px;
+ padding: 6px 10px;
+ `,
+ momentCopy: css`
+ display: flex;
+ flex-direction: column;
+
+ h3 {
+ margin: 12px 0 8px;
+ color: ${token.colorText};
+ font-size: 28px;
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ letter-spacing: -0.03em;
+ }
+
+ p {
+ color: ${token.colorTextSecondary};
+ line-height: 1.8;
+ margin: 0;
+ flex: 1;
+ }
+ `,
+ momentTopline: css`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+
+ span {
+ color: ${token.colorTextSecondary};
+ }
+ `,
+ momentFoot: css`
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ border-top: 1px solid rgba(15, 23, 42, 0.08);
+ padding-top: 14px;
+ margin-top: 16px;
+
+ span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: ${token.colorTextSecondary};
+ }
+ `,
+ gallery: css`
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 16px;
+ margin-top: 12px;
+ `,
+ timelineImage: css`
+ border-radius: 20px;
+ overflow: hidden;
+ min-height: 240px;
+ box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
+ `,
+ playerWrapper: css`
+ position: relative;
+ overflow: hidden;
+ border-radius: 28px;
+ padding-top: 56.25%;
+ height: 100%;
+ min-height: 420px;
+ background: #0f172a;
+ `,
+ footerCard: css`
+ margin-top: 28px;
+ padding: 18px 20px;
+ border-radius: 20px;
+ background: ${token.colorFillAlter};
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ `,
+ emptyState: css`
+ min-height: calc(100vh - 120px);
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ `,
+ studioShell: css`
+ padding-top: 8px;
+ `,
+ studioLayout: css`
+ display: grid;
+ gap: 20px;
+ grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.08fr);
+ align-items: start;
+ `,
+ studioPanel: css`
+ background: rgba(255, 255, 255, 0.86);
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 28px;
+ padding: 24px;
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
+ backdrop-filter: blur(14px);
+ `,
+ studioPreviewPanel: css`
+ position: sticky;
+ top: 24px;
+ `,
+ studioHeader: css`
+ display: grid;
+ gap: 18px;
+ margin-bottom: 18px;
+
+ h2 {
+ margin: 14px 0 8px;
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ letter-spacing: -0.03em;
+ }
+
+ p {
+ margin-bottom: 0;
+ color: ${token.colorTextSecondary};
+ }
+ `,
+ studioAlert: css`
+ margin-bottom: 18px;
+ border-radius: 18px;
+ `,
+ studioFields: css`
+ display: grid;
+ gap: 16px;
+ `,
+ studioField: css`
+ display: grid;
+ gap: 8px;
+
+ span {
+ color: ${token.colorText};
+ font-weight: 600;
+ }
+ `,
+ studioSectionHead: css`
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
+ gap: 12px;
+ margin: 26px 0 14px;
+
+ h3 {
+ margin: 6px 0 0;
+ color: ${token.colorText};
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ font-size: 24px;
+ letter-spacing: -0.03em;
+ }
+
+ span {
+ color: ${token.colorTextSecondary};
+ }
+ `,
+ coverGrid: css`
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ `,
+ coverOption: css`
+ appearance: none;
+ background: rgba(255, 255, 255, 0.82);
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 20px;
+ cursor: pointer;
+ padding: 10px;
+ text-align: left;
+ 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 16px 30px rgba(15, 23, 42, 0.08);
+ }
+ `,
+ coverOptionActive: css`
+ background: linear-gradient(145deg, rgba(17, 78, 121, 0.12), rgba(255, 255, 255, 0.95));
+ border-color: rgba(17, 78, 121, 0.32);
+ `,
+ coverThumb: css`
+ min-height: 120px;
+ position: relative;
+ border-radius: 14px;
+ overflow: hidden;
+ background: linear-gradient(145deg, rgba(17, 78, 121, 0.12), rgba(201, 111, 67, 0.14));
+ `,
+ coverThumbImage: css`
+ width: 100%;
+ min-height: 120px;
+ border-radius: 14px;
+ overflow: hidden;
+ `,
+ coverThumbPlaceholder: css`
+ min-height: 120px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${token.colorText};
+ font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+ font-size: 48px;
+ `,
+ coverThumbBadge: css`
+ position: absolute;
+ left: 10px;
+ bottom: 10px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border-radius: 999px;
+ padding: 5px 10px;
+ color: white;
+ background: rgba(15, 23, 42, 0.74);
+ `,
+ coverMeta: css`
+ display: grid;
+ gap: 4px;
+ margin-top: 10px;
+
+ strong {
+ color: ${token.colorText};
+ font-size: 14px;
+ }
+
+ span {
+ color: ${token.colorTextSecondary};
+ font-size: 12px;
+ }
+ `,
+ studioActions: css`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 22px;
+ `,
+
+ '@media (max-width: 1100px)': {
+ studioLayout: {
+ gridTemplateColumns: '1fr',
+ },
+ studioPreviewPanel: {
+ position: 'static',
+ },
+ },
+
+ '@media (max-width: 960px)': {
+ heroGrid: {
+ gridTemplateColumns: '1fr',
+ },
+ statsGrid: {
+ gridTemplateColumns: '1fr 1fr',
+ },
+ momentCard: {
+ gridTemplateColumns: '1fr',
+ },
+ sectionHead: {
+ alignItems: 'flex-start',
+ flexDirection: 'column',
+ },
+ studioSectionHead: {
+ alignItems: 'flex-start',
+ flexDirection: 'column',
+ },
+ },
+
+ '@media (max-width: 640px)': {
+ statsGrid: {
+ gridTemplateColumns: '1fr',
+ },
+ actionRow: {
+ width: '100%',
+ },
+ coverGrid: {
+ gridTemplateColumns: '1fr 1fr',
+ },
+ },
+}));
\ No newline at end of file
diff --git a/src/pages/story/components/AddTimeLineItemModal.tsx b/src/pages/story/components/AddTimeLineItemModal.tsx
index 6c60f39..cb798b5 100644
--- a/src/pages/story/components/AddTimeLineItemModal.tsx
+++ b/src/pages/story/components/AddTimeLineItemModal.tsx
@@ -1,4 +1,4 @@
-// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
+// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
import { batchGetFileInfo, getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
@@ -23,6 +23,8 @@ import {
Tabs,
Tooltip,
Upload,
+ type UploadFile,
+ type UploadProps,
} from 'antd';
import dayjs from 'dayjs';
import moment from 'moment';
@@ -227,6 +229,7 @@ const AddTimeLineItemModal: React.FC = ({
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
const uploadUrl = uploadUrlRes.data;
+ if (!uploadUrl) throw new Error('Missing upload URL');
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl, true);
@@ -284,14 +287,16 @@ const AddTimeLineItemModal: React.FC = ({
const thumbName = `thumb-${Date.now()}.jpg`;
const thumbUrlRes = await getUploadUrl(thumbName);
if (thumbUrlRes.code === 200) {
- await fetch(thumbUrlRes.data, { method: 'PUT', body: thumbnailBlob });
+ const thumbUploadUrl = thumbUrlRes.data;
+ if (!thumbUploadUrl) throw new Error('Missing thumbnail upload URL');
+ await fetch(thumbUploadUrl, { method: 'PUT', body: thumbnailBlob });
const thumbMetaRes = await saveFileMetadata({
imageName: thumbName,
objectKey: thumbName,
size: thumbnailBlob.size,
contentType: 'image/jpeg',
});
- if (thumbMetaRes.code === 200) {
+ if (thumbMetaRes.code === 200 && thumbMetaRes.data) {
thumbnailInstanceId = thumbMetaRes.data;
}
}
@@ -311,7 +316,7 @@ const AddTimeLineItemModal: React.FC = ({
duration: duration,
});
- if (metaRes.code !== 200) throw new Error('保存元数据失败');
+ if (metaRes.code !== 200 || !metaRes.data) throw new Error('保存元数据失败');
const videoInstanceId = metaRes.data;
setVideoList((prev) =>
@@ -413,20 +418,20 @@ const AddTimeLineItemModal: React.FC = ({
}
};
- const uploadImagesProps = {
+ const uploadImagesProps: UploadProps = {
beforeUpload: () => false,
- onChange: ({ fileList }) => {
- const updatedFileList = fileList.map((file) => {
+ onChange: ({ fileList }: { fileList: UploadFile[] }) => {
+ const updatedFileList = fileList.map((file: UploadFile) => {
if (file.originFileObj && !(file.originFileObj instanceof File)) {
- file.originFileObj = new File([file.originFileObj], file.name, { type: file.type });
+ file.originFileObj = new File([file.originFileObj], file.name, { type: file.type }) as any;
}
return file;
});
setImageList(updatedFileList);
},
- listType: 'picture-card',
+ listType: 'picture-card' as const,
multiple: true,
- defaultFileList: initialValues?.images?.map((url) => ({ url })),
+ defaultFileList: initialValues?.images?.map((url: string) => ({ url })),
};
// 切换图库图片选择状态
@@ -755,3 +760,6 @@ const AddTimeLineItemModal: React.FC = ({
};
export default AddTimeLineItemModal;
+
+
+
diff --git a/src/pages/story/components/SubTimeLineItemModal.tsx b/src/pages/story/components/SubTimeLineItemModal.tsx
index 2846ca4..fec1276 100644
--- a/src/pages/story/components/SubTimeLineItemModal.tsx
+++ b/src/pages/story/components/SubTimeLineItemModal.tsx
@@ -1,6 +1,6 @@
-// file: SubTimeLineItemModal.tsx
+// file: SubTimeLineItemModal.tsx
import React, { useEffect } from 'react';
-import { Form, Modal, Input, DatePicker, Upload, Button } from 'antd';
+import { Form, Modal, Input, DatePicker, Upload, Button, type UploadFile } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import moment from 'moment';
@@ -45,7 +45,9 @@ const SubTimeLineItemModal: React.FC = ({ visible, onCancel, onOk, i
const uploadProps = {
beforeUpload: () => false,
- onChange: ({ fileList }) => {},
+ onChange: ({ fileList }: { fileList: UploadFile[] }) => {
+ void fileList;
+ },
listType: 'picture' as const,
maxCount: 1,
};
@@ -95,3 +97,4 @@ const SubTimeLineItemModal: React.FC = ({ visible, onCancel, onOk, i
};
export default SubTimeLineItemModal;
+
diff --git a/src/pages/story/components/TimelineGridItem.tsx b/src/pages/story/components/TimelineGridItem.tsx
index b3841cb..92132f0 100644
--- a/src/pages/story/components/TimelineGridItem.tsx
+++ b/src/pages/story/components/TimelineGridItem.tsx
@@ -1,8 +1,7 @@
-// TimelineGridItem.tsx - Grid card layout for timeline items
-import TimelineImage from '@/components/TimelineImage';
+import TimelineImage from '@/components/TimelineImage';
import { ReactionBar } from '@/components/Reactions';
import useReactions from '@/hooks/useReactions';
-import { StoryItem } from '@/pages/story/data';
+import type { StoryItem } from '@/pages/story/data';
import { batchGetFileInfo, queryStoryItemImages } from '@/services/file/api';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { useIntl, useRequest } from '@umijs/max';
@@ -11,17 +10,21 @@ import React, { useEffect } from 'react';
import { removeStoryItem } from '../service';
import TimelineVideo from './TimelineVideo';
-// 格式化时间数组为易读格式
+interface FileInfo {
+ instanceId: string;
+ imageName?: string;
+ contentType?: string;
+ thumbnailInstanceId?: string;
+}
+
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
- // 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
- const [, , , hour, minute] = time;
+ const [, , , hour = 0, minute = 0] = time;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
- // 如果已经是字符串格式,提取时间部分
const timeStr = String(time);
const timePart = timeStr.split(' ')[1];
if (timePart) {
@@ -41,24 +44,14 @@ const TimelineGridItem: React.FC<{
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
const intl = useIntl();
const { token } = theme.useToken();
+ const { reactions, addReaction, updateReaction, removeReaction, actionLoading: reactionLoading } =
+ useReactions('story', item.instanceId || '', {
+ autoFetch: true,
+ autoSubscribe: true,
+ });
- // Initialize reactions for this story item
- const {
- reactions,
- addReaction,
- updateReaction,
- removeReaction,
- actionLoading: reactionLoading,
- } = useReactions('story', item.instanceId || '', {
- autoFetch: true,
- autoSubscribe: true,
- });
-
- // 动态设置CSS变量以适配主题
useEffect(() => {
const root = document.documentElement;
-
- // 根据Ant Design的token设置主题变量
root.style.setProperty('--timeline-bg', token.colorBgContainer);
root.style.setProperty('--timeline-card-bg', token.colorBgElevated);
root.style.setProperty('--timeline-card-border', token.colorBorder);
@@ -67,30 +60,31 @@ const TimelineGridItem: React.FC<{
root.style.setProperty('--timeline-text-secondary', token.colorTextSecondary);
root.style.setProperty('--timeline-text-tertiary', token.colorTextTertiary);
root.style.setProperty('--timeline-header-color', token.colorPrimary);
- root.style.setProperty('--timeline-location-bg', `${token.colorSuccess}1A`); // 10% opacity
- root.style.setProperty('--timeline-location-border', `${token.colorSuccess}4D`); // 30% opacity
+ root.style.setProperty('--timeline-location-bg', `${token.colorSuccess}1A`);
+ root.style.setProperty('--timeline-location-border', `${token.colorSuccess}4D`);
root.style.setProperty('--timeline-location-color', token.colorSuccess);
root.style.setProperty('--timeline-image-border', token.colorBorder);
root.style.setProperty('--timeline-more-bg', token.colorBgMask);
root.style.setProperty('--timeline-more-color', token.colorWhite);
}, [token]);
- const { data: filesInfo } = useRequest(
+ const filesRequest = useRequest(
async () => {
const idsResponse = await queryStoryItemImages(item.instanceId);
- // @ts-ignore
- const ids = idsResponse.data || idsResponse || [];
- if (Array.isArray(ids) && ids.length > 0) {
- const res = await batchGetFileInfo(ids);
- return res.data || [];
+ const ids = Array.isArray(idsResponse.data) ? idsResponse.data : [];
+ if (ids.length === 0) {
+ return [];
}
- return [];
+ const res = await batchGetFileInfo(ids);
+ return Array.isArray(res.data) ? (res.data as FileInfo[]) : [];
},
{
refreshDeps: [item.instanceId],
},
);
+ const filesInfo = (filesRequest.data || []) as FileInfo[];
+
const handleDelete = async () => {
try {
if (!item.instanceId) return;
@@ -101,66 +95,30 @@ const TimelineGridItem: React.FC<{
} else {
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
}
- } catch (error) {
+ } catch {
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
}
};
- // 动态计算卡片大小(1-4格)
- const calculateCardSize = () => {
- const imageCount = filesInfo?.length || 0;
- const descriptionLength = item.description?.length || 0;
+ const imageCount = filesInfo.length;
+ const descriptionLength = item.description?.length || 0;
+ const cardSize = imageCount >= 4 || descriptionLength > 200 ? 4 : imageCount >= 2 || descriptionLength > 100 ? 2 : imageCount >= 1 && descriptionLength > 50 ? 2 : 1;
+ const descriptionMaxLength = cardSize >= 4 ? 300 : cardSize === 2 ? 150 : 80;
+ const truncatedDescription =
+ item.description && item.description.length > descriptionMaxLength
+ ? `${item.description.substring(0, descriptionMaxLength)}...`
+ : item.description;
- // 根据图片数量和描述长度决定卡片大小
- if (imageCount >= 4 || descriptionLength > 200) {
- return 4; // 占据整行
- } else if (imageCount >= 2 || descriptionLength > 100) {
- return 2; // 占据2格
- } else if (imageCount >= 1 && descriptionLength > 50) {
- return 2; // 有图片且描述较长,占据2格
- } else {
- return 1; // 默认占据1格
- }
- };
-
- const cardSize = calculateCardSize();
-
- // 统一的文本长度 - 根据卡片大小调整
- const getDescriptionMaxLength = (size: number) => {
- switch (size) {
- case 1:
- return 80;
- case 2:
- return 150;
- case 3:
- return 200;
- case 4:
- return 300;
- default:
- return 100;
- }
- };
-
- const descriptionMaxLength = getDescriptionMaxLength(cardSize);
-
- // 截断描述文本
- const truncateText = (text: string | undefined, maxLength: number) => {
- if (!text) return '';
- return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
- };
-
- // 只返回article元素,不包含任何其他元素
return (
onOpenDetail(item)}>
- {/* Action buttons */}
{!disableEdit && (
- e.stopPropagation()}>
+
event.stopPropagation()}>
}
- onClick={(e) => {
- e.stopPropagation();
+ onClick={(event) => {
+ event.stopPropagation();
handleOption(item, 'edit');
}}
aria-label={intl.formatMessage({ id: 'story.edit' })}
@@ -168,9 +126,9 @@ const TimelineGridItem: React.FC<{
{
- e?.stopPropagation();
- handleDelete();
+ onConfirm={(event) => {
+ event?.stopPropagation();
+ void handleDelete();
}}
okText={intl.formatMessage({ id: 'story.yes' })}
cancelText={intl.formatMessage({ id: 'story.no' })}
@@ -180,24 +138,18 @@ const TimelineGridItem: React.FC<{
size="small"
icon={ }
danger
- onClick={(e) => e.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
aria-label={intl.formatMessage({ id: 'story.delete' })}
/>
)}
- {/* Time header */}
{formatTimeArray(item.storyItemTime)}
-
- {/* Title */}
{item.title}
+
{truncatedDescription}
- {/* Description */}
-
{truncateText(item.description, descriptionMaxLength)}
-
- {/* Images preview - 固定间隔,单行展示,多余折叠 */}
- {item.videoUrl || (filesInfo && filesInfo.length > 0) ? (
+ {item.videoUrl || imageCount > 0 ? (
{item.videoUrl && (
@@ -207,34 +159,28 @@ const TimelineGridItem: React.FC<{
/>
)}
- {filesInfo && filesInfo.length > 0 && (
+ {imageCount > 0 && (
- {filesInfo
- .slice(0, 6) // 最多显示6张图片
- .map((file, index) => (
-
- {file.contentType && file.contentType.startsWith('video') ? (
-
- ) : (
-
- )}
-
- ))}
- {filesInfo.length > 6 && (
-
+{filesInfo.length - 6}
- )}
+ {filesInfo.slice(0, 6).map((file: FileInfo, index: number) => (
+
+ {file.contentType?.startsWith('video') ? (
+
+ ) : (
+
+ )}
+
+ ))}
+ {imageCount > 6 &&
+{imageCount - 6}
}
)}
) : null}
- {/* Location badge */}
- {item.location &&
📍 {item.location} }
+ {item.location &&
地点 {item.location} }
- {/* Creator/Updater tags */}
{item.createName && (
@@ -248,12 +194,7 @@ const TimelineGridItem: React.FC<{
)}
- {/* Reactions */}
-
e.stopPropagation()}
- style={{ marginTop: '8px' }}
- >
+
event.stopPropagation()} style={{ marginTop: '8px' }}>
= (props) => {
entityId={storyItem.instanceId || ''}
comments={comments}
loading={commentsLoading}
- onCreate={addComment}
- onEdit={updateComment}
+ onCreate={async (data) => {
+ await addComment(data);
+ }}
+ onEdit={async (id, text) => {
+ await updateComment(id, text);
+ }}
onDelete={deleteComment}
createLoading={createLoading}
editLoading={updateLoading}
@@ -287,3 +291,5 @@ const TimelineItemDrawer: React.FC = (props) => {
};
export default TimelineItemDrawer;
+
+
diff --git a/src/pages/story/data.d.ts b/src/pages/story/data.d.ts
index b43ef00..f60c517 100644
--- a/src/pages/story/data.d.ts
+++ b/src/pages/story/data.d.ts
@@ -6,7 +6,7 @@ export type Member = {
export type BasicListItemDataType = {
id: string;
- ownerId: string;
+ ownerId?: string;
title: string;
avatar: string;
cover: string;
@@ -27,9 +27,12 @@ export type BasicListItemDataType = {
content: string;
members: Member[];
};
+
export interface StoryType {
id?: number;
instanceId?: string;
+ shareId?: string;
+ userId?: string;
title?: string;
description?: string;
status?: string;
@@ -37,23 +40,28 @@ export interface StoryType {
ownerId?: string;
ownerName?: string;
updateName?: string;
- itemCount: number,
+ itemCount?: number;
updatedId?: string;
updateTime?: string;
- storyTime: string;
+ storyTime?: string;
logo?: string;
- permissionType?: number;
+ coverImage?: string;
+ coverInstanceId?: string;
+ permissionType?: number | null;
items?: StoryItem[];
}
+
export interface BaseResponse {
code: number;
message: string;
}
+
export interface ErrorResponse extends BaseResponse {
data: null;
- code: -1,
- message: "请求失败"
+ code: -1;
+ message: 'ʧ';
}
+
export interface StoryItem {
id?: number;
instanceId: string;
@@ -61,15 +69,19 @@ export interface StoryItem {
storyInstanceId: string;
masterItemId?: string;
description: string;
- storyItemTime: string; // YYYY-MM-DD
- createTime: string; // YYYY-MM-DD
- updateTime: string; // YYYY-MM-DD
+ content?: string;
+ storyItemTime: string | number[];
+ createTime?: string | number[];
+ updateTime?: string | number[];
location?: string;
- coverInstanceId?: string; // 封面图
+ coverInstanceId?: string;
+ coverImage?: string;
videoUrl?: string;
duration?: number;
thumbnailUrl?: string;
- images?: string[]; // 多张图片
+ thumbnailInstanceId?: string;
+ images?: string[];
+ relatedImageInstanceIds?: string[];
subItems?: StoryItem[];
isRoot: number;
updateId?: string;
@@ -77,24 +89,27 @@ export interface StoryItem {
createId?: string;
createName?: string;
}
+
export interface StoryItemTimeQueryParams {
storyInstanceId?: string;
beforeTime?: string;
afterTime?: string;
}
-export interface AddStoryItem extends StoryItem{
+
+export interface AddStoryItem extends StoryItem {
file: FormData;
}
+
export interface TimelineEvent {
id?: number;
title: string;
description: string;
- date: string; // YYYY-MM-DD
- time?: string; // HH:mm (可选)
+ date: string;
+ time?: string;
location?: string;
- cover?: string; // 封面图
- images?: string[]; // 多张图片
+ cover?: string;
+ images?: string[];
subItems?: TimelineEvent[];
}
-export type PermissionType = ''
+export type PermissionType = '';
diff --git a/src/pages/story/detail.tsx b/src/pages/story/detail.tsx
index 05955c0..95f3953 100644
--- a/src/pages/story/detail.tsx
+++ b/src/pages/story/detail.tsx
@@ -1,95 +1,223 @@
-// src/pages/story/detail.tsx
import { useIsMobile } from '@/hooks/useIsMobile';
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid';
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
-import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
+import type { StoryItem, StoryItemTimeQueryParams, StoryType } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
import { judgePermission } from '@/pages/story/utils/utils';
-import { MoreOutlined, PlusOutlined, SyncOutlined, TeamOutlined } from '@ant-design/icons';
+import {
+ EyeOutlined,
+ MoreOutlined,
+ PlusOutlined,
+ ShareAltOutlined,
+ SyncOutlined,
+ TeamOutlined,
+ VerticalAlignTopOutlined,
+} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useParams, useRequest } from '@umijs/max';
-import { Button, Dropdown, Empty, FloatButton, MenuProps, message, Space, Spin } from 'antd';
+import { Button, Dropdown, Empty, FloatButton, message, Space, Spin, Tag } from 'antd';
+import type { MenuProps } from 'antd';
import { PullToRefresh } from 'antd-mobile';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './detail.css';
import CollaboratorModal from './components/CollaboratorModal';
+type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number };
+type LoadDirection = 'init' | 'older' | 'newer' | 'refresh';
+
+const normalizeStoryDetailResponse = (response: any): StoryType | undefined => {
+ if (!response) return undefined;
+ if (response?.data) return response.data as StoryType;
+ return response as StoryType;
+};
+
+const normalizeStoryItemsResponse = (response: any): StoryItem[] => {
+ if (Array.isArray(response?.list)) return response.list as StoryItem[];
+ if (Array.isArray(response?.data?.list)) return response.data.list as StoryItem[];
+ if (Array.isArray(response?.data)) return response.data as StoryItem[];
+ return [];
+};
+
+const normalizeTimeParam = (value: StoryItem['storyItemTime'] | undefined): string | undefined => {
+ if (!value) return undefined;
+
+ if (Array.isArray(value)) {
+ const [year, month, day, hour = 0, minute = 0, second = 0] = value;
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
+ }
+
+ return String(value);
+};
+
+const getTimeValue = (time: StoryItem['storyItemTime'] | undefined): number => {
+ if (!time) return 0;
+
+ if (Array.isArray(time)) {
+ const [year, month, day, hour = 0, minute = 0, second = 0] = time;
+ return new Date(year, month - 1, day, hour, minute, second).getTime();
+ }
+
+ return new Date(String(time)).getTime();
+};
+
+const getDateKey = (time: StoryItem['storyItemTime'] | undefined): { label: string; sortValue: number } => {
+ if (Array.isArray(time)) {
+ const [year, month, day] = time;
+ return {
+ label: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
+ sortValue: new Date(year, month - 1, day).getTime(),
+ };
+ }
+
+ if (time) {
+ const datePart = String(time).split(' ')[0];
+ return {
+ label: datePart,
+ sortValue: new Date(datePart).getTime(),
+ };
+ }
+
+ return {
+ label: 'Undated',
+ sortValue: 0,
+ };
+};
+
+const groupItemsByDate = (items: StoryItem[]) => {
+ const groups: Record = {};
+
+ items.forEach((item) => {
+ const { label, sortValue } = getDateKey(item.storyItemTime);
+
+ if (!groups[label]) {
+ groups[label] = { dateKey: label, items: [], sortValue };
+ }
+
+ groups[label].items.push(item);
+ });
+
+ Object.values(groups).forEach((group) => {
+ group.items.sort((a, b) => getTimeValue(a.storyItemTime) - getTimeValue(b.storyItemTime));
+ });
+
+ return groups;
+};
+
+const mergeGroupOrder = (items: StoryItem[], dateKey: string, newItems: StoryItem[]) => {
+ const groups = groupItemsByDate(items);
+
+ if (!groups[dateKey]) {
+ return items;
+ }
+
+ groups[dateKey].items = newItems;
+
+ return Object.values(groups)
+ .sort((a, b) => b.sortValue - a.sortValue)
+ .flatMap((group) => group.items);
+};
+
const Index = () => {
const isMobile = useIsMobile();
const { id: lineId } = useParams<{ id: string }>();
const containerRef = useRef(null);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
- const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
- const [hasMoreNew, setHasMoreNew] = useState(true); // 是否有更新的数据
+ const [hasMoreOld, setHasMoreOld] = useState(true);
+ const [hasMoreNew, setHasMoreNew] = useState(true);
const [openAddItemModal, setOpenAddItemModal] = useState(false);
const [currentItem, setCurrentItem] = useState();
- const [currentOption, setCurrentOption] = useState<
- 'add' | 'edit' | 'addSubItem' | 'editSubItem'
- >();
+ const [currentOption, setCurrentOption] = useState<'add' | 'edit' | 'addSubItem' | 'editSubItem'>();
const [openDetailDrawer, setOpenDetailDrawer] = useState(false);
const [openCollaboratorModal, setOpenCollaboratorModal] = useState(false);
const [detailItem, setDetailItem] = useState();
const [pagination, setPagination] = useState({ current: 1, pageSize: 30 });
- const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
- const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
- const [currentTimeArr, setCurrentTimeArr] = useState<[string | undefined, string | undefined]>([
- undefined,
- undefined,
- ]);
- const [loadDirection, setLoadDirection] = useState<'init' | 'older' | 'newer' | 'refresh'>(
- 'init',
- );
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [showScrollTop, setShowScrollTop] = useState(false);
+ const [currentTimeArr, setCurrentTimeArr] = useState<
+ [StoryItem['storyItemTime'] | undefined, StoryItem['storyItemTime'] | undefined]
+ >([undefined, undefined]);
+ const [loadDirection, setLoadDirection] = useState('init');
const hasShownNoMoreOldRef = useRef(false);
const hasShownNoMoreNewRef = useRef(false);
- type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number };
-
const { data: response, run } = useRequest(
- (params?: QueryParams) => {
- return queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params });
- },
- {
- manual: true,
- },
+ (params?: QueryParams) =>
+ queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params }),
+ { manual: true },
);
- const {
- data: detail,
- run: queryDetail,
- loading: queryDetailLoading,
- } = useRequest(() => {
- return queryStoryDetail(lineId ?? '');
- });
+ const { data: detailResponse, run: queryDetail, loading: queryDetailLoading } = useRequest(
+ () => queryStoryDetail(lineId ?? ''),
+ );
+
+ const detail = normalizeStoryDetailResponse(detailResponse);
+ const storyId = lineId || detail?.instanceId;
+ const canEdit = judgePermission(detail?.permissionType ?? null, 'edit');
+ const canManageCollaborators = judgePermission(detail?.permissionType ?? null, 'auth');
+
+ const refreshStory = useCallback(() => {
+ setItems([]);
+ setPagination({ current: 1, pageSize: 30 });
+ setLoadDirection('refresh');
+ setLoading(true);
+ setIsRefreshing(false);
+ setHasMoreOld(true);
+ setHasMoreNew(true);
+ hasShownNoMoreOldRef.current = false;
+ hasShownNoMoreNewRef.current = false;
+ void run({ current: 1 });
+ void queryDetail();
+ }, [queryDetail, run]);
+
+ const openSharePreview = useCallback(() => {
+ if (!storyId) {
+ message.warning('This story is not ready for preview yet.');
+ return;
+ }
+
+ history.push(`/share/preview/${storyId}`);
+ }, [storyId]);
+
+ const openShareStudio = useCallback(() => {
+ if (!storyId) {
+ message.warning('This story is not ready for Share Studio yet.');
+ return;
+ }
+
+ history.push(`/share/studio/${storyId}`);
+ }, [storyId]);
- // 初始化加载数据
useEffect(() => {
+ setItems([]);
setHasMoreOld(true);
setHasMoreNew(true);
setLoadDirection('init');
setLoading(true);
- queryDetail();
- run();
- }, [lineId]);
- // 处理响应数据
+ setIsRefreshing(false);
+ hasShownNoMoreOldRef.current = false;
+ hasShownNoMoreNewRef.current = false;
+ void queryDetail();
+ void run();
+ }, [lineId, queryDetail, run]);
+
useEffect(() => {
if (!response) return;
- // 兼容 response.list 和 response.data.list
- // @ts-ignore
- const fetched = response.list || response.data?.list || [];
- const pageSize = pagination.pageSize;
- const noMore = !(fetched.length === pageSize);
- // 若无新数据则避免触发列表重绘,只更新加载状态
+ const fetched = normalizeStoryItemsResponse(response);
+ const pageSize = pagination.pageSize;
+ const noMore = fetched.length < pageSize;
+
if (!fetched.length) {
if (loadDirection === 'older') {
setHasMoreOld(false);
} else if (loadDirection === 'newer') {
setHasMoreNew(false);
- } else if (loadDirection === 'init' || loadDirection === 'refresh') {
+ } else {
setItems([]);
}
+
setLoading(false);
setIsRefreshing(false);
setLoadDirection('init');
@@ -103,9 +231,10 @@ const Index = () => {
return next;
});
setHasMoreOld(!noMore);
+
if (noMore && !hasShownNoMoreOldRef.current) {
hasShownNoMoreOldRef.current = true;
- message.info('没有更多历史内容了');
+ message.info('No older moments left.');
}
} else if (loadDirection === 'newer') {
setItems((prev) => {
@@ -114,38 +243,38 @@ const Index = () => {
return next;
});
setHasMoreNew(!noMore);
+
if (noMore && !hasShownNoMoreNewRef.current) {
hasShownNoMoreNewRef.current = true;
- message.info('没有更多更新内容了');
+ message.info('No newer moments found.');
}
- } else if (loadDirection === 'refresh' || loadDirection === 'init') {
+ } else {
setItems(fetched);
- if (fetched.length > 0) {
- setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
- }
+ setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
setHasMoreOld(!noMore);
+ setHasMoreNew(true);
}
setLoading(false);
setIsRefreshing(false);
setLoadDirection('init');
- }, [response, loadDirection, pagination.pageSize]);
+ }, [loadDirection, pagination.pageSize, response]);
- // 滚动到底部加载更老的数据
const loadOlder = useCallback(() => {
if (loading || !hasMoreOld) {
return;
}
+
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
if (!beforeTime) return;
+
const nextPage = pagination.current + 1;
setPagination((prev) => ({ ...prev, current: nextPage }));
setLoadDirection('older');
setLoading(true);
- run({ current: nextPage });
- }, [loading, hasMoreOld, items, currentTimeArr, pagination.current, run]);
+ void run({ current: nextPage, beforeTime: normalizeTimeParam(beforeTime) });
+ }, [currentTimeArr, hasMoreOld, items, loading, pagination.current, run]);
- // 滚动到顶部加载更新的数据
const loadNewer = useCallback(() => {
if (loading || !hasMoreNew || isRefreshing) {
return;
@@ -153,25 +282,22 @@ const Index = () => {
const afterTime = items[0]?.storyItemTime || currentTimeArr[0];
if (!afterTime) return;
+
setPagination((prev) => ({ ...prev, current: 1 }));
setLoadDirection('newer');
setIsRefreshing(true);
setLoading(true);
- run({ afterTime: afterTime });
- }, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
+ void run({ current: 1, afterTime: normalizeTimeParam(afterTime) });
+ }, [currentTimeArr, hasMoreNew, isRefreshing, items, loading, run]);
- // 监听滚动事件
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
-
- // 显示回到顶部按钮
setShowScrollTop(scrollTop > 300);
- // 接近底部时加载更多
if (scrollHeight - scrollTop - clientHeight < 200) {
loadOlder();
}
@@ -181,8 +307,7 @@ const Index = () => {
return () => container.removeEventListener('scroll', handleScroll);
}, [loadOlder]);
- // 手动刷新最新数据
- const handleRefresh = () => {
+ const handleRefresh = useCallback(() => {
if (isRefreshing) return;
setIsRefreshing(true);
@@ -190,88 +315,48 @@ const Index = () => {
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
- run({ current: 1 });
- };
+ setLoading(true);
+ void run({ current: 1 });
+ void queryDetail();
+ }, [isRefreshing, queryDetail, run]);
- // 回到顶部
const scrollToTop = () => {
- if (containerRef.current) {
- containerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
- }
+ containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
};
- // 按日期分组items,并在每个组内按时间排序
- const groupItemsByDate = (items: StoryItem[]) => {
- const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } =
- {};
+ const groupedItems = useMemo(() => groupItemsByDate(items), [items]);
- items.forEach((item) => {
- let dateKey = '';
- let sortValue = 0;
+ const summaryDescription =
+ detail?.description ||
+ 'Build the timeline, curate a cover, and turn the story into a polished public page from Share Studio.';
- if (Array.isArray(item.storyItemTime)) {
- const [year, month, day] = item.storyItemTime;
- dateKey = `${year}年${month}月${day}日`;
- sortValue = new Date(year, month - 1, day).getTime();
- } else if (item.storyItemTime) {
- const dateStr = String(item.storyItemTime);
- const datePart = dateStr.split(' ')[0];
- dateKey = datePart;
- sortValue = new Date(datePart).getTime();
- }
-
- if (!groups[dateKey]) {
- groups[dateKey] = { dateKey, items: [], sortValue };
- }
- groups[dateKey].items.push(item);
- });
-
- // 对每个日期组内的项目按时间排序(从早到晚)
- Object.keys(groups).forEach((dateKey) => {
- groups[dateKey].items.sort((a, b) => {
- const timeA = getTimeValue(a.storyItemTime);
- const timeB = getTimeValue(b.storyItemTime);
- return timeA - timeB;
- });
- });
-
- return groups;
- };
-
- // 将时间转换为可比较的数值
- const getTimeValue = (time: string | number[] | undefined): number => {
- if (!time) return 0;
-
- if (Array.isArray(time)) {
- const [year, month, day, hour = 0, minute = 0, second = 0] = time;
- return new Date(year, month - 1, day, hour, minute, second).getTime();
- }
-
- return new Date(String(time)).getTime();
- };
-
- const groupedItems = groupItemsByDate(items);
-
- const getExtraContent = () => {
+ const extraContent = (() => {
if (isMobile) {
const menuItems: MenuProps['items'] = [
+ {
+ key: 'preview',
+ label: 'Preview',
+ icon: ,
+ onClick: openSharePreview,
+ },
+ {
+ key: 'studio',
+ label: 'Share Studio',
+ icon: ,
+ onClick: openShareStudio,
+ },
{
key: 'refresh',
- label: '刷新',
+ label: 'Refresh',
icon: ,
- onClick: () => {
- setItems([]);
- setPagination({ current: 1, pageSize: 30 });
- setLoadDirection('refresh');
- run({ current: 1 });
- },
+ onClick: handleRefresh,
},
];
- if (judgePermission(detail?.permissionType ?? null, 'auth')) {
+ if (canManageCollaborators) {
menuItems.unshift({
key: 'collaborators',
- label: '协作成员',
+ label: 'Collaborators',
icon: ,
onClick: () => setOpenCollaboratorModal(true),
});
@@ -285,36 +370,80 @@ const Index = () => {
}
return (
-
- {judgePermission(detail?.permissionType ?? null, 'auth') && (
+
+ } onClick={openSharePreview}>
+ Preview
+
+ } onClick={openShareStudio}>
+ Share Studio
+
+ {canManageCollaborators && (
} onClick={() => setOpenCollaboratorModal(true)}>
- 协作成员
+ Collaborators
)}
- }
- onClick={() => {
- setItems([]);
- setPagination({ current: 1, pageSize: 30 });
- setLoadDirection('refresh');
- run({ current: 1 });
- }}
- loading={isRefreshing}
- >
- 刷新
+ } onClick={handleRefresh} loading={isRefreshing}>
+ Refresh
);
- };
+ })();
return (
history.push('/story')}
- title={
- queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
- }
- extra={getExtraContent()}
+ title={queryDetailLoading ? 'Loading story...' : detail?.title || 'Story timeline'}
+ extra={extraContent}
>
+ {detail && (
+
+
+
+
+ Story canvas
+
+
+ {detail.title || 'Untitled story'}
+
+
{summaryDescription}
+
+ {detail.storyTime || 'No story time set'}
+ {Number(detail.itemCount || 0)} moments
+ {detail.updateTime || 'No recent update'}
+
+
+
+ } onClick={openShareStudio}>
+ Open Studio
+
+ } onClick={openSharePreview}>
+ Preview
+
+ {canEdit && (
+ }
+ onClick={() => {
+ setCurrentOption('add');
+ setCurrentItem(undefined);
+ setOpenAddItemModal(true);
+ }}
+ >
+ Add Moment
+
+ )}
+
+
+
+ )}
+
{
height: 'calc(100vh - 200px)',
overflow: 'auto',
position: 'relative',
- padding: '0 8px', // 减少内边距
+ padding: '0 8px',
}}
>
{items.length > 0 ? (
-
+ {
+ loadNewer();
+ }}
+ disabled={!isMobile}
+ >
{hasMoreNew && !isMobile && (
- 加载新内容
+ Load Newer Moments
)}
+
{Object.values(groupedItems)
.sort((a, b) => b.sortValue - a.sortValue)
.map(({ dateKey, items: dateItems, sortValue }) => (
@@ -343,61 +478,37 @@ const Index = () => {
items={dateItems}
dateKey={dateKey}
sortValue={sortValue}
- handleOption={(
- item: StoryItem,
- option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
- ) => {
+ handleOption={(item, option) => {
setCurrentItem(item);
setCurrentOption(option);
setOpenAddItemModal(true);
}}
- onOpenDetail={(item: StoryItem) => {
+ onOpenDetail={(item) => {
setDetailItem(item);
setOpenDetailDrawer(true);
}}
- disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
- refresh={() => {
- setPagination((prev) => ({ ...prev, current: 1 }));
- hasShownNoMoreOldRef.current = false;
- hasShownNoMoreNewRef.current = false;
- setLoadDirection('refresh');
- run({ current: 1 });
- queryDetail();
- }}
+ disableEdit={!canEdit}
+ refresh={refreshStory}
onOrderChange={(changedDateKey, newItems) => {
- setItems((prev) => {
- const updated = [...prev];
- const startIdx = updated.findIndex(
- (item) => item.storyItemTime === newItems[0]?.storyItemTime
- );
- if (startIdx !== -1) {
- updated.splice(startIdx, newItems.length, ...newItems);
- }
- return updated;
- });
+ setItems((prev) => mergeGroupOrder(prev, changedDateKey, newItems));
}}
/>
))}
- {loading && 加载中...
}
- {!loading && !hasMoreOld && 已加载全部历史数据
}
- {/* 回到顶部按钮 */}
+ {loading && Loading moments...
}
+ {!loading && !hasMoreOld && All historical moments are loaded.
}
+
{showScrollTop && (
- }
- onClick={scrollToTop}
- />
+ } onClick={scrollToTop} />
)}
@@ -415,52 +526,37 @@ const Index = () => {
{loading ? (
<>
-
- 正在加载时间线数据...
-
+ Loading timeline data...
>
) : (
<>
-
- 还没有添加任何时刻
+
+ Start with a first memory, then curate it in Share Studio.
{
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
- 添加第一个时刻
+ Add First Moment
>
)}
)}
+
{
setCurrentOption('add');
@@ -468,60 +564,47 @@ const Index = () => {
setOpenAddItemModal(true);
}}
icon={ }
- disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
+ disabled={!canEdit}
type="primary"
- style={{
- right: 24,
- bottom: 24,
- }}
+ style={{ right: 24, bottom: 24 }}
/>
{
- setOpenAddItemModal(false);
- }}
+ onCancel={() => setOpenAddItemModal(false)}
onOk={() => {
setOpenAddItemModal(false);
- // 添加新项后刷新数据
- setPagination((prev) => ({ ...prev, current: 1 }));
- setLoadDirection('refresh');
- run({ current: 1 });
- queryDetail();
+ refreshStory();
}}
storyId={lineId}
/>
- {/* 详情抽屉 - 在外层管理,不影响网格布局 */}
{detailItem && (
{
- // 这里需要实现删除逻辑
try {
if (!detailItem.instanceId) return;
- const response = await removeStoryItem(detailItem.instanceId);
- if (response.code === 200) {
- message.success('删除成功');
+
+ const removeResponse = await removeStoryItem(detailItem.instanceId);
+ if (removeResponse.code === 200) {
+ message.success('Moment deleted.');
setOpenDetailDrawer(false);
- // 刷新数据
- setPagination((prev) => ({ ...prev, current: 1 }));
- setLoadDirection('refresh');
- run({ current: 1 });
- queryDetail();
- } else {
- message.error('删除失败');
+ refreshStory();
+ return;
}
+
+ message.error('Delete failed.');
} catch (error) {
- message.error('删除失败');
+ message.error('Delete failed.');
}
}}
- disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
- handOption={(item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => {
+ disableEdit={!canEdit}
+ handOption={(item, option) => {
setCurrentItem(item);
setCurrentOption(option);
setOpenDetailDrawer(false);
@@ -529,6 +612,7 @@ const Index = () => {
}}
/>
)}
+
setOpenCollaboratorModal(false)}
diff --git a/src/pages/story/index.tsx b/src/pages/story/index.tsx
index 632b36f..1dc5570 100644
--- a/src/pages/story/index.tsx
+++ b/src/pages/story/index.tsx
@@ -1,10 +1,11 @@
import Highlight from '@/components/Highlight';
import { useIsMobile } from '@/hooks/useIsMobile';
import { judgePermission } from '@/pages/story/utils/utils';
-import { DownOutlined, MoreOutlined, PlusOutlined } from '@ant-design/icons';
+import { DownOutlined, MoreOutlined, PlusOutlined, ShareAltOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useRequest, useSearchParams } from '@umijs/max';
-import { Avatar, Button, Card, Dropdown, Input, List, MenuProps, message, Modal } from 'antd';
+import { Avatar, Button, Card, Dropdown, Input, List, message, Modal, Tag } from 'antd';
+import type { MenuProps } from 'antd';
import type { FC } from 'react';
import React, { useEffect, useState } from 'react';
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
@@ -15,6 +16,32 @@ import useStyles from './style.style';
const { Search } = Input;
+const normalizeListResponse = (response: any): StoryType[] => {
+ if (Array.isArray(response)) return response;
+ if (Array.isArray(response?.data)) return response.data;
+ if (Array.isArray(response?.list)) return response.list;
+ if (Array.isArray(response?.data?.list)) return response.data.list;
+ return [];
+};
+
+const normalizeSearchResponse = (response: any): { list: StoryItem[]; total: number } => {
+ if (!response) return { list: [], total: 0 };
+ if (Array.isArray(response?.list)) return { list: response.list, total: response.total || 0 };
+ if (Array.isArray(response?.data?.list)) {
+ return { list: response.data.list, total: response.data.total || 0 };
+ }
+ return { list: [], total: 0 };
+};
+
+const copyText = async (text: string) => {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+
+ return false;
+};
+
const ListContent = ({
data: { storyTime, updateTime, updateName, ownerName, itemCount },
isMobile,
@@ -31,12 +58,12 @@ const ListContent = ({
className={styles.listContentItem}
style={{ width: '100%', textAlign: 'left', marginBottom: 4 }}
>
- 更新时间:
- {updateTime}
+ Updated
+ {updateTime || '-'}
-
节点数:
-
{itemCount}
+
Moments
+
{itemCount || 0}
);
@@ -45,295 +72,424 @@ const ListContent = ({
return (
-
创建人
-
{ownerName}
+
Owner
+
{ownerName || '-'}
-
最近更新人
-
{updateName ?? ownerName}
+
Updated By
+
{updateName || ownerName || '-'}
-
节点数
-
{itemCount}
+
Moments
+
{itemCount || 0}
-
故事时间
-
{storyTime}
+
Story Time
+
{storyTime || '-'}
-
更新时间
-
{updateTime}
+
Updated
+
{updateTime || '-'}
);
};
+
export const BasicList: FC = () => {
const { styles } = useStyles();
const isMobile = useIsMobile();
const [searchParams] = useSearchParams();
- const [done, setDone] = useState(false);
- const [open, setVisible] = useState(false);
- const [authorizeModelOpen, setAuthorizeModelOpen] = useState(false);
+ const [done, setDone] = useState(false);
+ const [open, setVisible] = useState(false);
+ const [authorizeModelOpen, setAuthorizeModelOpen] = useState(false);
const [current, setCurrent] = useState | undefined>(undefined);
- const [isSearching, setIsSearching] = useState(false);
+ const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState([]);
- const [searchPagination, setSearchPagination] = useState<{
- current: number;
- pageSize: number;
- total: number;
- keyword?: string;
- }>({ current: 1, pageSize: 10, total: 0 });
+ const [searchPagination, setSearchPagination] = useState({
+ current: 1,
+ pageSize: 10,
+ total: 0,
+ keyword: undefined as string | undefined,
+ });
const { loading: searchLoading, run: searchRun } = useRequest(
- (params) => {
- // 兼容参数处理:支持直接传入 params 对象或者不传(使用 searchPagination 状态)
+ async (params?: { keyword?: string; page?: number; pageNum?: number; pageSize?: number }) => {
const finalParams = params || {
keyword: searchPagination.keyword,
page: searchPagination.current,
pageSize: searchPagination.pageSize,
};
- if (!finalParams.keyword) return Promise.resolve({ list: [], total: 0 });
+ if (!finalParams.keyword) return { list: [], total: 0 };
+
return searchStoryItems({
keyword: finalParams.keyword,
- page: finalParams.page || finalParams.pageNum, // 兼容 pageNum 参数
- pageSize: finalParams.pageSize,
+ page: finalParams.page || finalParams.pageNum,
+ pageSize: finalParams.pageSize || 10,
});
},
{
- manual: true, // 改为手动触发,避免与 useEffect 冲突
+ manual: true,
onSuccess: (data) => {
- if (data) {
- setSearchResults(data.list || []);
- setSearchPagination((prev) => ({
- ...prev,
- total: data.total || 0,
- }));
- }
+ const normalized = normalizeSearchResponse(data);
+ setSearchResults(normalized.list);
+ setSearchPagination((prev) => ({ ...prev, total: normalized.total }));
},
},
);
- // 监听 URL 参数变化,自动触发搜索
useEffect(() => {
const keyword = searchParams.get('keyword');
- const page = parseInt(searchParams.get('page') || '1', 10);
+ const page = Number.parseInt(searchParams.get('page') || '1', 10);
+
if (keyword) {
setSearchPagination((prev) => ({ ...prev, keyword, current: page }));
setIsSearching(true);
- searchRun({ keyword, page, pageSize: 10 });
- } else {
- setIsSearching(false);
+ void searchRun({ keyword, page, pageSize: 10 });
+ return;
}
- }, [searchParams]);
- const {
- data: listData,
- loading,
- run,
- } = useRequest((storyName?: string) => {
- return queryTimelineList({
- count: 50,
- storyName,
- });
- });
+ setIsSearching(false);
+ setSearchResults([]);
+ setSearchPagination((prev) => ({ ...prev, current: 1, total: 0, keyword: undefined }));
+ }, [searchParams, searchRun]);
+
+ const { data: listData, loading, run } = useRequest((storyName?: string) =>
+ queryTimelineList({ count: 50, storyName }),
+ );
const { run: postRun } = useRequest(
- (method, params) => {
- if (method === 'remove') {
- return deleteStory(params);
- }
- if (method === 'update') {
- return updateStory(params);
- }
+ async (method: 'add' | 'update' | 'remove', params: Partial) => {
+ if (method === 'remove') return deleteStory(params);
+ if (method === 'update') return updateStory(params);
return addStory(params);
},
{
manual: true,
onSuccess: () => {
- run();
+ void run();
},
},
);
- const list = listData || [];
+
+ const list = normalizeListResponse(listData);
+ const featuredStory = list[0];
+
const paginationProps = {
showSizeChanger: true,
showQuickJumper: true,
pageSize: 5,
total: list.length,
};
- const showEditModal = (item: StoryType) => {
+
+ const showEditModal = (item?: StoryType) => {
setVisible(true);
setCurrent(item);
};
+
const deleteItem = (id: string) => {
- postRun('remove', {
- instanceId: id,
- });
+ void postRun('remove', { instanceId: id });
};
+
+ const openTimeline = (story?: StoryType) => {
+ if (!story?.instanceId) {
+ message.warning('This story is missing an id.');
+ return;
+ }
+
+ history.push(`/timeline/${story.instanceId}`);
+ };
+
+ const openShareStudio = (story?: StoryType) => {
+ if (!story?.instanceId) {
+ message.warning('This story is not ready for Share Studio yet.');
+ return;
+ }
+
+ history.push(`/share/studio/${story.instanceId}`);
+ };
+
+ const openSharePreview = (story?: StoryType) => {
+ if (!story?.instanceId) {
+ message.warning('This story is not ready for sharing yet.');
+ return;
+ }
+
+ history.push(`/share/preview/${story.instanceId}`);
+ };
+
+ const shareStory = async (story?: StoryType) => {
+ if (!story) return;
+
+ if (story.shareId) {
+ const shareLink = `${window.location.origin}/share/${story.shareId}`;
+ const copied = await copyText(shareLink);
+
+ if (copied) {
+ message.success('Public share link copied.');
+ } else {
+ history.push(`/share/${story.shareId}`);
+ message.info('Clipboard is unavailable, opening the public page instead.');
+ }
+
+ return;
+ }
+
+ openSharePreview(story);
+ message.info('Opening share preview.');
+ };
+
const editAndDelete = (key: string | number, currentItem: StoryType) => {
- if (key === 'edit') showEditModal(currentItem);
- else if (key === 'delete') {
+ if (key === 'edit') {
+ showEditModal(currentItem);
+ return;
+ }
+
+ if (key === 'delete') {
Modal.confirm({
- title: '删除故事',
- content: '确定删除该故事吗?',
- okText: '确认',
- cancelText: '取消',
+ title: 'Delete story',
+ content: 'This will permanently remove the story and its moments.',
+ okText: 'Delete',
+ cancelText: 'Cancel',
onOk: () => deleteItem(currentItem.instanceId ?? ''),
});
- } else if (key === 'authorize') {
+ return;
+ }
+
+ if (key === 'authorize') {
setCurrent(currentItem);
setAuthorizeModelOpen(true);
- } else if (key === 'share') {
- const shareLink = `${window.location.origin}/share/${currentItem.shareId}`;
- navigator.clipboard.writeText(shareLink);
- message.success('分享链接已复制到剪贴板');
+ return;
+ }
+
+ if (key === 'studio') {
+ openShareStudio(currentItem);
+ return;
+ }
+
+ if (key === 'share') {
+ void shareStory(currentItem);
}
};
+
+ const resetSearch = () => {
+ history.push('/story');
+ };
+
const extraContent = (
-
+
{
- setVisible(true);
- }}
- style={{
- marginBottom: isMobile ? 0 : 8,
- float: isMobile ? 'none' : 'left',
- }}
+ onClick={() => showEditModal()}
+ style={{ marginBottom: isMobile ? 0 : 8, float: isMobile ? 'none' : 'left' }}
>
- 新建故事
+ New Story
{
- history.push(`/story?keyword=${value}&page=1`);
+ const keyword = value.trim();
+
+ if (!keyword) {
+ resetSearch();
+ return;
+ }
+
+ history.push(`/story?keyword=${encodeURIComponent(keyword)}&page=1`);
}}
style={{ width: isMobile ? '100%' : 272 }}
/>
);
- const MoreBtn: React.FC<{
- item: StoryType;
- }> = ({ item }) => {
+
+ const MoreBtn: React.FC<{ item: StoryType }> = ({ item }) => {
const items: MenuProps['items'] = [];
- if (judgePermission(item?.permissionType, 'edit')) {
- items.push({
- key: 'edit',
- label: '编辑',
- });
+ if (judgePermission(item.permissionType, 'edit')) {
+ items.push({ key: 'edit', label: 'Edit' });
}
- if (judgePermission(item?.permissionType, 'auth')) {
- items.push({
- key: 'authorize',
- label: '授权',
- });
+ if (judgePermission(item.permissionType, 'auth')) {
+ items.push({ key: 'authorize', label: 'Collaborators' });
}
- if (judgePermission(item?.permissionType, 'delete')) {
- items.push({
- key: 'delete',
- label: '删除',
- });
+ if (judgePermission(item.permissionType, 'delete')) {
+ items.push({ key: 'delete', label: 'Delete' });
}
- items.push({
- key: 'share',
- label: '分享',
- });
+ items.push({ key: 'studio', label: 'Share Studio' });
+ items.push({ key: 'share', label: item.shareId ? 'Copy share link' : 'Open preview' });
if (items.length === 0) return null;
return (
-
editAndDelete(key, item),
- items: items,
- }}
- >
+ editAndDelete(key, item) }}>
{isMobile ? (
} />
) : (
- 更多
+ More
)}
);
};
+
const handleDone = () => {
setDone(false);
setVisible(false);
- setCurrent({});
+ setCurrent(undefined);
};
+
const handleSubmit = (values: StoryType) => {
setDone(true);
const method = current?.instanceId ? 'update' : 'add';
- postRun(method, { ...current, ...values });
- run();
+ void postRun(method, { ...current, ...values });
};
+
return (
-
+
+ {featuredStory && !isSearching && (
+ openTimeline(featuredStory)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ openTimeline(featuredStory);
+ }
+ }}
+ style={{
+ marginBottom: 20,
+ padding: isMobile ? 20 : 24,
+ border: '1px solid rgba(24, 144, 255, 0.14)',
+ borderRadius: 24,
+ background: 'linear-gradient(135deg, rgba(24,144,255,0.10), rgba(250,173,20,0.10))',
+ cursor: 'pointer',
+ }}
+ >
+
+
+
+ Featured story
+
+
+ {featuredStory.title || 'Untitled story'}
+
+
+ {featuredStory.description ||
+ 'Open the story, tune the cover, and publish it as a polished share page.'}
+
+
+ {featuredStory.storyTime || 'No story time set'}
+ {Number(featuredStory.itemCount || 0)} moments
+ {featuredStory.shareId ? 'Public share is ready' : 'Preview available'}
+
+
+
+ {
+ event.stopPropagation();
+ openTimeline(featuredStory);
+ }}
+ >
+ Open Timeline
+
+ }
+ onClick={(event) => {
+ event.stopPropagation();
+ void shareStory(featuredStory);
+ }}
+ >
+ {featuredStory.shareId ? 'Copy Link' : 'Preview'}
+
+ {
+ event.stopPropagation();
+ openShareStudio(featuredStory);
+ }}
+ >
+ Studio
+
+
+
+
+ )}
+
{
- if (value) {
- setSearchPagination({ ...searchPagination, current: 1, keyword: value });
- searchRun({ keyword: value, pageNum: 1, pageSize: 10 });
- } else {
- setIsSearching(false);
- setSearchResults([]);
+ const keyword = value.trim();
+
+ if (keyword) {
+ setSearchPagination((prev) => ({ ...prev, current: 1, keyword }));
+ setIsSearching(true);
+ void searchRun({ keyword, pageNum: 1, pageSize: 10 });
+ return;
}
+
+ resetSearch();
}}
/>
+
{isSearching ? (
-
setIsSearching(false)} style={{ marginBottom: 16 }}>
- 返回故事列表
+
+ Back To Stories
{
- history.push(`/story?keyword=${searchPagination.keyword}&page=${page}`);
- },
+ onChange: (page) =>
+ history.push(`/story?keyword=${encodeURIComponent(searchPagination.keyword || '')}&page=${page}`),
}}
renderItem={(item: StoryItem) => (
history.push(`/story/${item.storyInstanceId}`)}>
-
-
+ history.push(`/timeline/${item.storyInstanceId}`)}
+ >
+
+
}
description={
-
+
}
/>
@@ -343,7 +499,7 @@ export const BasicList: FC = () => {
) : (
{
isMobile
? [ ]
: [
- {
- e.preventDefault();
- showEditModal(item);
- }}
+ type="link"
+ disabled={!judgePermission(item.permissionType, 'edit')}
+ onClick={() => showEditModal(item)}
>
- 编辑
- ,
- // 增加授权操作,可以授权给其他用户
- ,
+ {
- e.preventDefault();
+ type="link"
+ disabled={!judgePermission(item.permissionType, 'auth')}
+ onClick={() => {
setCurrent(item);
setAuthorizeModelOpen(true);
}}
>
- 授权
- ,
- ,
+ {
- e.preventDefault();
- deleteItem(item.instanceId ?? '');
- }}
+ type="link"
+ disabled={!judgePermission(item.permissionType, 'delete')}
+ onClick={() => deleteItem(item.instanceId ?? '')}
>
- 删除
- ,
- {
- e.preventDefault();
- const shareLink = `${window.location.origin}/share/${item.shareId}`;
- navigator.clipboard.writeText(shareLink);
- message.success('分享链接已复制到剪贴板');
- }}
- >
- 分享
- ,
+ Delete
+
,
+
openShareStudio(item)}>
+ Studio
+ ,
+
void shareStory(item)}>
+ {item.shareId ? 'Copy Link' : 'Preview'}
+ ,
]
}
>
}
title={
-
{
- history.push(`/timeline/${item.instanceId}`);
- }}
+ openTimeline(item)}
>
- {item.title}
-
+ {item.title || 'Untitled story'}
+
}
description={item.description}
/>
@@ -420,25 +566,19 @@ export const BasicList: FC = () => {
-
+
+
{
- if (flag) {
- run();
- }
+ if (flag) void run();
setAuthorizeModelOpen(false);
- setCurrent({});
+ setCurrent(undefined);
}}
/>
);
};
+
export default BasicList;
diff --git a/src/pages/story/service.ts b/src/pages/story/service.ts
index 8c5c1e1..692c6c6 100644
--- a/src/pages/story/service.ts
+++ b/src/pages/story/service.ts
@@ -1,6 +1,6 @@
import { request } from '@umijs/max';
-import {StoryItem, StoryItemTimeQueryParams, StoryType} from './data.d';
-import {CommonListResponse, CommonResponse} from "@/types/common";
+import { StoryItem, StoryItemTimeQueryParams, StoryType } from './data.d';
+import { CommonListResponse, CommonResponse } from '@/types/common';
import { STORY_API, FILE_API } from '@/services/config/apiUrls';
type ParamsType = {
@@ -11,10 +11,83 @@ type ParamsType = {
current?: number;
} & Partial & Partial & Partial;
-export async function queryTimelineList(
- params: ParamsType,
-): Promise<{ data: StoryType[] }> {
- return await request(STORY_API.LIST, {
+export type StorySharePublishPayload = {
+ storyId: string;
+ heroMomentId: string;
+ title?: string;
+ description?: string;
+ quote?: string;
+ featuredMomentIds?: string[];
+};
+
+export type StorySharePublishResult = {
+ storyId: string;
+ shareId: string;
+ heroMomentId: string;
+ publicPath: string;
+ publishedAt?: string;
+};
+
+export type StoryShareConfig = {
+ shareId?: string;
+ storyId: string;
+ heroMomentId?: string;
+ title?: string;
+ description?: string;
+ quote?: string;
+ featuredMomentIds: string[];
+ published?: boolean;
+ updatedAt?: string;
+};
+
+export type TimelineArchiveBucket = {
+ key: string;
+ title: string;
+ count: number;
+ subtitle: string;
+ storyInstanceId?: string;
+ storyShareId?: string;
+ coverInstanceId?: string;
+ coverSrc?: string;
+ sampleMomentTitle?: string;
+};
+
+export type TimelineArchiveMoment = {
+ key: string;
+ storyInstanceId?: string;
+ storyShareId?: string;
+ storyTitle: string;
+ storyTime?: string;
+ itemInstanceId?: string;
+ itemTitle: string;
+ itemDescription: string;
+ itemTime: string;
+ location?: string;
+ tags: string[];
+ coverInstanceId?: string;
+ coverSrc?: string;
+ mediaCount: number;
+ hasVideo: boolean;
+ sortValue: number;
+};
+
+export type TimelineArchiveSummary = {
+ storiesIndexed: number;
+ shareableStories: number;
+ videoMoments: number;
+ locations: TimelineArchiveBucket[];
+ tags: TimelineArchiveBucket[];
+};
+
+export type TimelineArchiveExplore = {
+ type: 'location' | 'tag';
+ value?: string;
+ bucket?: TimelineArchiveBucket;
+ moments: TimelineArchiveMoment[];
+};
+
+export async function queryTimelineList(params: ParamsType): Promise<{ data: StoryType[] }> {
+ return request(STORY_API.LIST, {
params,
});
}
@@ -36,7 +109,7 @@ export async function addStory(params: ParamsType): Promise<{ data: { list: Stor
}
export async function updateStory(params: ParamsType): Promise<{ data: { list: StoryType[] } }> {
- return await request(STORY_API.DETAIL(params.instanceId!), {
+ return request(STORY_API.DETAIL(params.instanceId!), {
method: 'PUT',
data: {
...params,
@@ -44,11 +117,13 @@ export async function updateStory(params: ParamsType): Promise<{ data: { list: S
},
});
}
+
export async function queryStoryDetail(itemId: string): Promise<{ data: StoryType }> {
return request(STORY_API.DETAIL(itemId), {
method: 'GET',
});
}
+
export async function addStoryItem(params: FormData): Promise {
return request(STORY_API.ITEM, {
method: 'POST',
@@ -57,6 +132,7 @@ export async function addStoryItem(params: FormData): Promise {
getResponse: true,
});
}
+
export async function updateStoryItem(params: FormData): Promise {
return request(STORY_API.ITEM, {
method: 'PUT',
@@ -66,10 +142,12 @@ export async function updateStoryItem(params: FormData): Promise {
});
}
-export async function queryStoryItem(params: ParamsType): Promise<{ data: CommonListResponse }> {
+export async function queryStoryItem(
+ params: ParamsType,
+): Promise<{ data: CommonListResponse }> {
return request(STORY_API.ITEM_LIST, {
method: 'GET',
- params: params,
+ params,
});
}
@@ -78,6 +156,7 @@ export async function queryStoryItemDetail(itemId: string): Promise<{ data: Stor
method: 'GET',
});
}
+
export async function countStoryItem(storyInstanceId: string): Promise<{ data: StoryItem }> {
return request(STORY_API.ITEM_COUNT(storyInstanceId), {
method: 'GET',
@@ -89,16 +168,26 @@ export async function queryStoryItemImages(itemId: string): Promise<{ data: stri
method: 'GET',
});
}
+
export async function removeStoryItem(instanceId: string): Promise> {
return request(STORY_API.ITEM_DETAIL(instanceId), {
method: 'DELETE',
});
}
-export async function searchStoryItems(params: { keyword: string; pageNum: number; pageSize: number }) {
+export async function searchStoryItems(params: {
+ keyword: string;
+ pageSize: number;
+ page?: number;
+ pageNum?: number;
+}) {
return request(STORY_API.ITEM_SEARCH, {
method: 'GET',
- params,
+ params: {
+ keyword: params.keyword,
+ pageNum: params.pageNum ?? params.page ?? 1,
+ pageSize: params.pageSize,
+ },
});
}
@@ -110,7 +199,11 @@ export async function fetchImage(imageInstanceId: string): Promise {
});
}
-export async function authorizeStoryPermission(params: {userId: string, storyInstanceId: string, permissionType: number}) {
+export async function authorizeStoryPermission(params: {
+ userId: string;
+ storyInstanceId: string;
+ permissionType: number;
+}) {
return request(STORY_API.PERMISSION_AUTHORIZE, {
method: 'POST',
data: params,
@@ -123,7 +216,11 @@ export async function getStoryPermissions(storyId: string) {
});
}
-export async function inviteUser(params: {userId: string, storyInstanceId: string, permissionType: number}) {
+export async function inviteUser(params: {
+ userId: string;
+ storyInstanceId: string;
+ permissionType: number;
+}) {
return request(STORY_API.PERMISSION_INVITE, {
method: 'POST',
data: params,
@@ -142,37 +239,21 @@ export async function rejectInvite(inviteId: string) {
});
}
-export async function updatePermission(params: {permissionId: string, permissionType: number}) {
- return request(STORY_API.PERMISSION, {
- method: 'PUT',
- data: params
- });
+export async function updatePermission(params: { permissionId: string; permissionType: number }) {
+ return request(STORY_API.PERMISSION, {
+ method: 'PUT',
+ data: params,
+ });
}
export async function removePermission(permissionId: string) {
- return request(`${STORY_API.PERMISSION}/${permissionId}`, {
- method: 'DELETE'
- });
+ return request(`${STORY_API.PERMISSION}/${permissionId}`, {
+ method: 'DELETE',
+ });
}
-/**
- * 更新时间线节点排序
- *
- * 功能描述:
- * 批量更新节点的排序值,用于拖拽排序后保存结果。
- *
- * @param orderData - 排序数据数组,包含节点ID和新排序值
- * @returns API响应
- *
- * @example
- * const orderData = [
- * { instanceId: 'item-1', sortOrder: 0 },
- * { instanceId: 'item-2', sortOrder: 1 },
- * ];
- * await updateStoryItemOrder(orderData);
- */
export async function updateStoryItemOrder(
- orderData: Array<{ instanceId: string; sortOrder: number }>
+ orderData: Array<{ instanceId: string; sortOrder: number }>,
): Promise> {
return request(`${STORY_API.ITEM}/order`, {
method: 'PUT',
@@ -180,21 +261,8 @@ export async function updateStoryItemOrder(
});
}
-/**
- * 批量删除时间线节点
- *
- * 功能描述:
- * 根据节点ID列表批量删除时间线节点。
- * 删除操作为软删除,数据可恢复。
- *
- * @param instanceIds - 要删除的节点ID数组
- * @returns API响应
- *
- * @example
- * await batchDeleteStoryItems(['item-1', 'item-2', 'item-3']);
- */
export async function batchDeleteStoryItems(
- instanceIds: string[]
+ instanceIds: string[],
): Promise> {
return request(`${STORY_API.ITEM}/batch-delete`, {
method: 'POST',
@@ -202,22 +270,78 @@ export async function batchDeleteStoryItems(
});
}
-/**
- * 批量修改时间线节点时间
- *
- * 功能描述:
- * 批量修改多个节点的时间信息。
- *
- * @param instanceIds - 要修改的节点ID数组
- * @param storyItemTime - 新的时间值
- * @returns API响应
- */
export async function batchUpdateStoryItemTime(
instanceIds: string[],
- storyItemTime: string
+ storyItemTime: string,
): Promise> {
return request(`${STORY_API.ITEM}/batch-time`, {
method: 'PUT',
data: { instanceIds, storyItemTime },
});
}
+
+export async function publishStoryShare(
+ payload: StorySharePublishPayload,
+): Promise<{ data: StorySharePublishResult }> {
+ return request(STORY_API.ITEM_SHARE_PUBLISH, {
+ method: 'POST',
+ data: payload,
+ });
+}
+
+export async function unpublishStoryShare(storyId: string): Promise> {
+ return request(STORY_API.ITEM_SHARE_STORY(storyId), {
+ method: 'DELETE',
+ });
+}
+
+export async function queryStoryShareConfig(storyId: string): Promise<{ data: StoryShareConfig }> {
+ return request(STORY_API.ITEM_SHARE_STORY(storyId), {
+ method: 'GET',
+ });
+}
+
+export async function queryTimelineOverview() {
+ return request(`${STORY_API.BASE}/analytics/overview`, {
+ method: 'GET',
+ });
+}
+
+export async function queryTimelineYearlyReport(year?: number) {
+ return request(`${STORY_API.BASE}/analytics/yearly-report`, {
+ method: 'GET',
+ params: year ? { year } : undefined,
+ });
+}
+
+export async function queryTimelineTopLocations(limit = 10) {
+ return request(`${STORY_API.BASE}/analytics/top-locations`, {
+ method: 'GET',
+ params: { limit },
+ });
+}
+
+export async function queryTimelineTopTags(limit = 10) {
+ return request(`${STORY_API.BASE}/analytics/top-tags`, {
+ method: 'GET',
+ params: { limit },
+ });
+}
+
+export async function queryTimelineArchiveSummary(locationLimit = 8, tagLimit = 12) {
+ return request<{ data: TimelineArchiveSummary }>(`${STORY_API.BASE}/analytics/archive/summary`, {
+ method: 'GET',
+ params: { locationLimit, tagLimit },
+ });
+}
+
+export async function queryTimelineArchiveExplore(params: {
+ type: 'location' | 'tag';
+ value?: string;
+ limit?: number;
+}) {
+ return request<{ data: TimelineArchiveExplore }>(`${STORY_API.BASE}/analytics/archive/explore`, {
+ method: 'GET',
+ params,
+ });
+}
diff --git a/src/pages/story/utils/utils.ts b/src/pages/story/utils/utils.ts
index 44a0214..f3645e1 100644
--- a/src/pages/story/utils/utils.ts
+++ b/src/pages/story/utils/utils.ts
@@ -1,10 +1,7 @@
-// judge user permission
-/**
- * current permissionType: 1 - creator, 2 - admin, 3 - editor, 4 - viewer
- * @param permissionType
- * @param actionType
- */
-export function judgePermission(permissionType: number | null, actionType: string): boolean {
+export function judgePermission(
+ permissionType: number | null | undefined,
+ actionType: string,
+): boolean {
switch (actionType) {
case 'delete':
return permissionType === 1;
diff --git a/src/pages/user/register/index.tsx b/src/pages/user/register/index.tsx
index 7afa924..5dfcec1 100644
--- a/src/pages/user/register/index.tsx
+++ b/src/pages/user/register/index.tsx
@@ -1,310 +1,126 @@
import { registerUser } from '@/services/user/api';
-import { CommonResponse } from '@/types/common';
-import { history, Link, useRequest } from '@umijs/max';
-import { Button, Form, Input, message, Popover, Progress, Select, Space } from 'antd';
-import type { Store } from 'antd/es/form/interface';
-import type { FC } from 'react';
-import { useEffect, useState } from 'react';
-import useStyles from './style.style';
import { useIsMobile } from '@/hooks/useIsMobile';
+import type { CommonResponse } from '@/types/common';
+import { history, Link, useRequest } from '@umijs/max';
+import { Button, Form, Input, message, Space } from 'antd';
+import type { FC } from 'react';
+import useStyles from './style.style';
-const FormItem = Form.Item;
-const { Option } = Select;
+interface RegisterFormValues {
+ username: string;
+ nickname: string;
+ password: string;
+ confirm: string;
+ email?: string;
+ phone?: string;
+}
-const passwordProgressMap: {
- ok: 'success';
- pass: 'normal';
- poor: 'exception';
-} = {
- ok: 'success',
- pass: 'normal',
- poor: 'exception',
-};
const Register: FC = () => {
const { styles } = useStyles();
const isMobile = useIsMobile();
- const [count, setCount]: [number, any] = useState(0);
- const [open, setVisible]: [boolean, any] = useState(false);
- const [prefix, setPrefix]: [string, any] = useState('86');
- const [popover, setPopover]: [boolean, any] = useState(false);
- const confirmDirty = false;
- let interval: number | undefined;
+ const [form] = Form.useForm();
- const passwordStatusMap = {
- ok: (
-
- 强度:强
-
- ),
- pass: (
-
- 强度:中
-
- ),
- poor: (
-
- 强度:太短
-
- ),
- };
-
- const [form] = Form.useForm();
- useEffect(
- () => () => {
- clearInterval(interval);
+ const { loading: submitting, run: submitRegister } = useRequest(
+ async (values: RegisterFormValues) => {
+ return registerUser({
+ username: values.username,
+ nickname: values.nickname,
+ password: values.password,
+ email: values.email || '',
+ phone: values.phone || '',
+ });
},
- [interval],
- );
- const onGetCaptcha = () => {
- let counts = 59;
- setCount(counts);
- interval = window.setInterval(() => {
- counts -= 1;
- setCount(counts);
- if (counts === 0) {
- clearInterval(interval);
- }
- }, 1000);
- };
- const getPasswordStatus = () => {
- const value = form.getFieldValue('password');
- if (value && value.length > 9) {
- return 'ok';
- }
- if (value && value.length > 5) {
- return 'pass';
- }
- return 'poor';
- };
- const { loading: submitting, run: register } = useRequest(
- (params) => registerUser(params, { skipErrorHandler: true }),
{
manual: true,
- onSuccess: (data, params) => {
- console.log('注册成功 - data:', data, 'params:', params);
- const response = data as CommonResponse;
- if (response?.code === 200) {
- message.success('注册成功!');
- const formValues = params[0] as any;
- history.push({
- pathname: `/user/register-result?account=${formValues?.email || formValues?.username || ''}`,
- });
- } else {
- message.error(response?.message || '注册失败,请重试');
+ onSuccess: (response, params) => {
+ const result = response as CommonResponse;
+ if (result.code !== 200) {
+ message.error(result.message || 'עʧܣԺ');
+ return;
}
+
+ const formValues = params[0];
+ message.success('עɹ');
+ history.push({
+ pathname: `/user/register-result?account=${formValues.email || formValues.username}`,
+ });
},
},
);
- const onFinish = (values: Store) => {
- // 将表单数据映射为后端需要的格式
- const registerParams = {
- username: values.username,
- nickname: values.nickname,
- password: values.password,
- email: values.email || '',
- phone: values.phone || '',
- };
- register(registerParams);
- };
- const checkConfirm = (_: any, value: string) => {
- const promise = Promise;
- if (value && value !== form.getFieldValue('password')) {
- return promise.reject('两次输入的密码不匹配!');
- }
- return promise.resolve();
- };
- const checkPassword = (_: any, value: string) => {
- const promise = Promise;
- // 没有值的情况
- if (!value) {
- setVisible(!!value);
- return promise.reject('请输入密码!');
- }
- // 有值的情况
- if (!open) {
- setVisible(!!value);
- }
- setPopover(!popover);
- if (value.length < 6) {
- return promise.reject('');
- }
- if (value && confirmDirty) {
- form.validateFields(['confirm']);
- }
- return promise.resolve();
- };
- const changePrefix = (value: string) => {
- setPrefix(value);
- };
- const renderPasswordProgress = () => {
- const value = form.getFieldValue('password');
- const passwordStatus = getPasswordStatus();
- return value && value.length ? (
-
-
100 ? 100 : value.length * 10}
- showInfo={false}
- />
-
- ) : null;
+ const handleFinish = async (values: RegisterFormValues) => {
+ await submitRegister(values);
};
+
return (
-
);
};
+
export default Register;
diff --git a/src/pages/user/register/style.style.ts b/src/pages/user/register/style.style.ts
index cad6b68..8164ea1 100644
--- a/src/pages/user/register/style.style.ts
+++ b/src/pages/user/register/style.style.ts
@@ -37,10 +37,17 @@ const useStyles = createStyles(({ token }) => {
transition: 'color 0.3s',
color: token.colorError,
},
+ 'progress-poor > .progress': {
+ '.ant-progress-bg': { backgroundColor: token.colorError },
+ },
'progress-pass > .progress': {
'.ant-progress-bg': { backgroundColor: token.colorWarning },
},
+ 'progress-ok > .progress': {
+ '.ant-progress-bg': { backgroundColor: token.colorSuccess },
+ },
};
});
export default useStyles;
+
diff --git a/src/services/config/apiUrls.ts b/src/services/config/apiUrls.ts
index 44ac27b..0068cd0 100644
--- a/src/services/config/apiUrls.ts
+++ b/src/services/config/apiUrls.ts
@@ -22,6 +22,8 @@ export const STORY_API = {
ITEM_COUNT: (storyInstanceId: string) => `${API_PREFIX}/story/item/count/${storyInstanceId}`,
ITEM_IMAGES: (itemId: string) => `${API_PREFIX}/story/item/images/${itemId}`,
ITEM_SEARCH: `${API_PREFIX}/story/item/search`,
+ ITEM_SHARE_PUBLISH: `${API_PREFIX}/story/item/share/publish`,
+ ITEM_SHARE_STORY: (storyId: string) => `${API_PREFIX}/story/item/share/${storyId}`,
// 权限相关
PERMISSION: `${API_PREFIX}/story/permission`,
diff --git a/src/services/file/api.ts b/src/services/file/api.ts
index c490534..a5d6f06 100644
--- a/src/services/file/api.ts
+++ b/src/services/file/api.ts
@@ -1,15 +1,14 @@
-import {request} from "@@/exports";
-import {CommonListResponse, CommonResponse} from "@/types/common";
-import {ImageItem} from "@/pages/gallery/typings";
+import { request } from '@@/exports';
+import { CommonListResponse, CommonResponse } from '@/types/common';
+import { ImageItem } from '@/pages/gallery/typings';
import { STORY_API, FILE_API } from '@/services/config/apiUrls';
-// 查询storyItem图片列表
export async function queryStoryItemImages(itemId: string): Promise<{ data: string[] }> {
return request(STORY_API.ITEM_IMAGES(itemId), {
method: 'GET',
});
}
-// 获取图片
+
export async function fetchImage(imageInstanceId: string): Promise {
return request(FILE_API.IMAGE(imageInstanceId), {
method: 'GET',
@@ -17,6 +16,7 @@ export async function fetchImage(imageInstanceId: string): Promise {
getResponse: true,
});
}
+
export async function fetchImageLowRes(imageInstanceId: string): Promise {
return request(FILE_API.IMAGE_LOW_RES(imageInstanceId), {
method: 'GET',
@@ -24,13 +24,12 @@ export async function fetchImageLowRes(imageInstanceId: string): Promise {
getResponse: true,
});
}
+
export async function getImagesList(
params: {
- // query
- /** 当前的页码 */
current?: number;
- /** 页面的容量 */
pageSize?: number;
+ keyword?: string;
},
options?: { [key: string]: any },
) {
@@ -51,6 +50,7 @@ export async function deleteImage(params: { instanceId: string }) {
},
});
}
+
export async function uploadImage(params: FormData) {
return request>(FILE_API.UPLOAD_IMAGE, {
method: 'POST',
diff --git a/src/services/user/api.ts b/src/services/user/api.ts
index a0d17dd..0d13f05 100644
--- a/src/services/user/api.ts
+++ b/src/services/user/api.ts
@@ -1,23 +1,29 @@
-import { CommonResponse } from "@/types/common";
-import {request} from "@@/exports";
+import { request } from '@@/exports';
import { AUTH_API } from '@/services/config/apiUrls';
+import type { UserLoginParams, UserLoginResult, UserRegisterParams } from '@/services/user/typing';
+import type { CommonResponse } from '@/types/common';
-
-export async function registerUser(params: UserRegisterParams, options?: { skipErrorHandler?: boolean }): Promise> {
+export async function registerUser(
+ params: UserRegisterParams,
+ options?: { skipErrorHandler?: boolean },
+): Promise> {
return request(AUTH_API.REGISTER, {
method: 'POST',
data: params,
- getResponse: true,
+ ...(options || {}),
});
}
-export async function loginUser(params: UserLoginParams): Promise> {
+
+export async function loginUser(
+ params: UserLoginParams,
+): Promise> {
return request(AUTH_API.LOGIN, {
method: 'POST',
data: params,
- // getResponse: true,
});
}
-export async function logoutUser() {
+
+export async function logoutUser(): Promise> {
return request(AUTH_API.LOGOUT, {
method: 'POST',
});
diff --git a/src/types.ts b/src/types.ts
index a68fa28..a0242b7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,20 +1,26 @@
-
export enum NotificationType {
- FRIEND_REQUEST = 'FRIEND_REQUEST',
- FRIEND_ACCEPTED = 'FRIEND_ACCEPTED',
- NEW_COMMENT = 'NEW_COMMENT',
- NEW_LIKE = 'NEW_LIKE',
- SYSTEM = 'SYSTEM',
+ FRIEND_REQUEST = 'FRIEND_REQUEST',
+ FRIEND_ACCEPTED = 'FRIEND_ACCEPTED',
+ NEW_COMMENT = 'NEW_COMMENT',
+ NEW_REACTION = 'NEW_REACTION',
+ NEW_LIKE = 'NEW_LIKE',
+ SYSTEM = 'SYSTEM',
}
export interface Notification {
- id: number;
- senderId: string;
- senderName: string;
- senderAvatar: string;
- type: NotificationType;
- content: string;
- targetId: string;
- targetType: string;
- createTime: string; // ISO 8601 date string
+ id: number;
+ type: NotificationType;
+ content: string;
+ read: boolean;
+ createdAt?: string;
+ createTime?: string;
+ senderId?: string;
+ senderName?: string;
+ senderAvatar?: string;
+ relatedId?: string;
+ relatedType?: string;
+ entityId?: string;
+ entityType?: string;
+ targetId?: string;
+ targetType?: string;
}
diff --git a/src/types/albums.d.ts b/src/types/albums.d.ts
index fa2b8cd..cca2fd6 100644
--- a/src/types/albums.d.ts
+++ b/src/types/albums.d.ts
@@ -1,74 +1,60 @@
-/**
- * Album Management Type Definitions
- * Feature: personal-user-enhancements
- */
+export {};
-declare namespace API {
- /**
- * Album entity
- */
- interface Album {
- id: string;
- userId: string;
- name: string;
- description?: string;
- coverPhotoId?: string;
- coverPhotoUrl?: string;
- photoCount: number;
- photos: AlbumPhoto[];
- createdAt: Date;
- updatedAt: Date;
- }
+declare global {
+ namespace API {
+ interface Album {
+ id: string;
+ userId: string;
+ name: string;
+ description?: string;
+ coverPhotoId?: string;
+ coverPhotoUrl?: string;
+ photoCount: number;
+ photos: AlbumPhoto[];
+ createdAt: Date;
+ updatedAt: Date;
+ }
- /**
- * Photo in an album
- */
- interface AlbumPhoto {
- id: string;
- photoId: string;
- albumId: string;
- order: number;
- photoUrl: string;
- thumbnailUrl: string;
- addedAt: Date;
- }
+ interface AlbumPhoto {
+ id: string;
+ photoId: string;
+ albumId: string;
+ order: number;
+ photoUrl: string;
+ thumbnailUrl: string;
+ addedAt: Date;
+ }
- /**
- * Create album DTO
- */
- interface CreateAlbumDTO {
- name: string;
- description?: string;
- coverPhotoId?: string;
- }
+ interface CreateAlbumDTO {
+ name: string;
+ description?: string;
+ coverPhotoId?: string;
+ }
- /**
- * Update album DTO
- */
- interface UpdateAlbumDTO {
- name?: string;
- description?: string;
- coverPhotoId?: string;
- }
+ interface UpdateAlbumDTO {
+ name?: string;
+ description?: string;
+ coverPhotoId?: string;
+ }
- /**
- * Add photos to album DTO
- */
- interface AddPhotosDTO {
- photoIds: string[];
- }
+ interface AddPhotosDTO {
+ photoIds: string[];
+ }
- /**
- * Remove photos from album DTO
- */
- interface RemovePhotosDTO {
- photoIds: string[];
- }
+ interface RemovePhotosDTO {
+ photoIds: string[];
+ }
- /**
- * Reorder photos DTO
- */
- interface ReorderPhotosDTO {
- order: string[]; // Array of photo IDs in new order
+ interface ReorderPhotosDTO {
+ order: string[];
+ }
}
}
+
+export type Album = API.Album;
+export type AlbumPhoto = API.AlbumPhoto;
+export type CreateAlbumDTO = API.CreateAlbumDTO;
+export type UpdateAlbumDTO = API.UpdateAlbumDTO;
+export type AddPhotosDTO = API.AddPhotosDTO;
+export type RemovePhotosDTO = API.RemovePhotosDTO;
+export type ReorderPhotosDTO = API.ReorderPhotosDTO;
diff --git a/src/types/index.ts b/src/types/index.ts
index 2096796..2b39b97 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,34 +1 @@
-/**
- * Central type exports
- * Feature: personal-user-enhancements
- */
-
-export enum NotificationType {
- FRIEND_REQUEST = 'FRIEND_REQUEST',
- FRIEND_ACCEPTED = 'FRIEND_ACCEPTED',
- NEW_COMMENT = 'NEW_COMMENT',
- NEW_REACTION = 'NEW_REACTION',
- NEW_LIKE = 'NEW_LIKE',
- SYSTEM = 'SYSTEM',
-}
-
-export interface Notification {
- id: number;
- type: NotificationType;
- content: string;
- read: boolean;
- createdAt: string;
- entityType?: string;
- entityId?: string;
-}
-
-// Re-export other types if needed
-export * from './albums.d';
-export * from './collections.d';
-export * from './common.d';
-export * from './image.d';
-export * from './preferences.d';
-export * from './profile.d';
-export * from './social.d';
-export * from './statistics.d';
-export * from './sync.d';
+export { NotificationType, type Notification } from '../types';
diff --git a/src/types/sync.d.ts b/src/types/sync.d.ts
index 22a6252..d35a1ec 100644
--- a/src/types/sync.d.ts
+++ b/src/types/sync.d.ts
@@ -1,11 +1,13 @@
-/**
- * Offline Editing and Sync Type Definitions
- * Feature: personal-user-enhancements
- */
-
export type SyncStatus = 'synced' | 'pending' | 'syncing' | 'error';
-export type SyncEntityType = 'story' | 'album' | 'photo';
-export type SyncOperation = 'create' | 'update' | 'delete';
+export type SyncEntityType = 'story' | 'story-item' | 'album' | 'photo';
+export type SyncOperation =
+ | 'create'
+ | 'update'
+ | 'delete'
+ | 'add-photos'
+ | 'remove-photos'
+ | 'reorder-photos'
+ | 'set-cover';
export interface ChangeRecord {
id: string;
@@ -43,14 +45,19 @@ export interface Resolution {
}
export interface OfflineStory {
- id: string;
- userId: string;
- title: string;
- content: string;
- photos: string[];
- createdAt: Date;
- updatedAt: Date;
+ id?: string | number;
+ instanceId?: string;
+ userId?: string;
+ title?: string;
+ description?: string;
+ content?: string;
+ coverImage?: string;
+ createTime?: string;
+ updateTime?: string;
+ itemCount?: number;
+ permissionType?: number | null;
syncStatus: SyncStatus;
+ [key: string]: any;
}
export interface OfflineAlbum {
@@ -58,190 +65,27 @@ export interface OfflineAlbum {
userId: string;
name: string;
description?: string;
- photoIds: string[];
+ coverPhotoId?: string;
+ coverPhotoUrl?: string;
+ photoCount: number;
+ photos: API.AlbumPhoto[];
+ photoIds?: string[];
createdAt: Date;
updatedAt: Date;
syncStatus: SyncStatus;
}
-declare namespace API {
- /**
- * Sync status
- */
- type SyncStatus = 'synced' | 'pending' | 'syncing' | 'error';
-
- /**
- * Entity type for sync operations
- */
- type SyncEntityType = 'story' | 'album' | 'photo';
-
- /**
- * Operation type for sync
- */
- type SyncOperation = 'create' | 'update' | 'delete';
-
- /**
- * Change record for offline operations
- */
- interface ChangeRecord {
- id: string;
- userId: string;
- entityType: SyncEntityType;
- entityId: string;
- operation: SyncOperation;
- data: any;
- timestamp: Date;
- synced: boolean;
- syncedAt?: Date;
- error?: string;
- }
-
- /**
- * Sync result
- */
- interface SyncResult {
- success: boolean;
- syncedCount: number;
- failedCount: number;
- conflicts: Conflict[];
- }
-
- /**
- * Sync conflict
- */
- interface Conflict {
- changeId: string;
- entityType: SyncEntityType;
- entityId: string;
- localVersion: any;
- serverVersion: any;
- conflictType: 'modified' | 'deleted';
- }
-
- /**
- * Conflict resolution
- */
- interface Resolution {
- conflictId: string;
- strategy: 'keep-local' | 'keep-server' | 'merge';
- mergedData?: any;
- }
-
- /**
- * Offline story (local storage)
- */
- interface OfflineStory {
- id: string;
- userId: string;
- title: string;
- content: string;
- photos: string[];
- createdAt: Date;
- updatedAt: Date;
- syncStatus: SyncStatus;
- }
-
- /**
- * Offline album (local storage)
- */
- interface OfflineAlbum {
- id: string;
- userId: string;
- name: string;
- description?: string;
- photoIds: string[];
- createdAt: Date;
- updatedAt: Date;
- syncStatus: SyncStatus;
- }
-
- /**
- * Sync status
- */
- type SyncStatus = 'synced' | 'pending' | 'syncing' | 'error';
-
- /**
- * Entity type for sync operations
- */
- type SyncEntityType = 'story' | 'album' | 'photo';
-
- /**
- * Operation type for sync
- */
- type SyncOperation = 'create' | 'update' | 'delete';
-
- /**
- * Change record for offline operations
- */
- interface ChangeRecord {
- id: string;
- userId: string;
- entityType: SyncEntityType;
- entityId: string;
- operation: SyncOperation;
- data: any;
- timestamp: Date;
- synced: boolean;
- syncedAt?: Date;
- error?: string;
- }
-
- /**
- * Sync result
- */
- interface SyncResult {
- success: boolean;
- syncedCount: number;
- failedCount: number;
- conflicts: Conflict[];
- }
-
- /**
- * Sync conflict
- */
- interface Conflict {
- changeId: string;
- entityType: SyncEntityType;
- entityId: string;
- localVersion: any;
- serverVersion: any;
- conflictType: 'modified' | 'deleted';
- }
-
- /**
- * Conflict resolution
- */
- interface Resolution {
- conflictId: string;
- strategy: 'keep-local' | 'keep-server' | 'merge';
- mergedData?: any;
- }
-
- /**
- * Offline story (local storage)
- */
- interface OfflineStory {
- id: string;
- userId: string;
- title: string;
- content: string;
- photos: string[];
- createdAt: Date;
- updatedAt: Date;
- syncStatus: SyncStatus;
- }
-
- /**
- * Offline album (local storage)
- */
- interface OfflineAlbum {
- id: string;
- userId: string;
- name: string;
- description?: string;
- photoIds: string[];
- createdAt: Date;
- updatedAt: Date;
- syncStatus: SyncStatus;
+declare global {
+ namespace API {
+ type SyncStatus = import('./sync').SyncStatus;
+ type SyncEntityType = import('./sync').SyncEntityType;
+ type SyncOperation = import('./sync').SyncOperation;
+ type ChangeRecord = import('./sync').ChangeRecord;
+ type SyncResult = import('./sync').SyncResult;
+ type Conflict = import('./sync').Conflict;
+ type Resolution = import('./sync').Resolution;
+ type OfflineStory = import('./sync').OfflineStory;
+ type OfflineAlbum = import('./sync').OfflineAlbum;
}
}
+
diff --git a/src/utils/performance.ts b/src/utils/performance.ts
index 12ba2af..0ae52ec 100644
--- a/src/utils/performance.ts
+++ b/src/utils/performance.ts
@@ -1,3 +1,5 @@
+import React from 'react';
+
/**
* Performance monitoring utilities
* Provides tools for measuring and tracking application performance
@@ -211,3 +213,4 @@ export function throttle any>(
}
};
}
+
diff --git a/src/utils/pwa.ts b/src/utils/pwa.ts
index 670acbb..d010d3b 100644
--- a/src/utils/pwa.ts
+++ b/src/utils/pwa.ts
@@ -1,34 +1,13 @@
-/**
- * PWA 注册和更新管理
- *
- * 功能描述:
- * 注册 Service Worker,处理 PWA 安装和更新提示。
- *
- * 功能特性:
- * - Service Worker 注册
- * - 更新检测和提示
- * - 安装提示
- * - 离线状态检测
- *
- * @author Timeline Team
- * @date 2024
- */
-
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
-// PWA 状态
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let swRegistration: ServiceWorkerRegistration | null = null;
-/**
- * 注册 Service Worker
- */
export async function registerServiceWorker(): Promise {
- if (!('serviceWorker' in navigator)) {
- console.log('Service Worker 不支持');
+ if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
@@ -38,87 +17,72 @@ export async function registerServiceWorker(): Promise {
const newWorker = registration.installing;
- if (newWorker) {
- newWorker.addEventListener('statechange', () => {
- if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
- // 新版本已安装,提示用户刷新
- showUpdateNotification();
- }
- });
+ if (!newWorker) {
+ return;
}
+
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ showUpdateNotification();
+ }
+ });
});
return registration;
} catch (error) {
- console.error('Service Worker 注册失败:', error);
+ console.error('Failed to register service worker', error);
return null;
}
}
-/**
- * 显示更新通知
- */
function showUpdateNotification(): void {
- const result = confirm('发现新版本,是否立即更新?');
- if (result) {
- // 通知 Service Worker 跳过等待
- if (swRegistration?.waiting) {
- swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
- }
- window.location.reload();
+ const shouldRefresh = window.confirm('°汾Ƿˢµݣ');
+ if (!shouldRefresh) {
+ return;
}
+
+ if (swRegistration?.waiting) {
+ swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
+ }
+ window.location.reload();
}
-/**
- * 监听安装提示事件
- */
export function setupInstallPrompt(): void {
- window.addEventListener('beforeinstallprompt', (e) => {
- e.preventDefault();
- deferredPrompt = e as BeforeInstallPromptEvent;
-
- // 显示安装按钮或提示
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.addEventListener('beforeinstallprompt', (event) => {
+ event.preventDefault();
+ deferredPrompt = event as BeforeInstallPromptEvent;
showInstallButton();
});
window.addEventListener('appinstalled', () => {
- console.log('PWA 已安装');
deferredPrompt = null;
hideInstallButton();
});
}
-/**
- * 触发安装提示
- */
export async function promptInstall(): Promise {
if (!deferredPrompt) {
- console.log('安装提示不可用');
return false;
}
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
-
- console.log('用户选择:', outcome);
deferredPrompt = null;
-
return outcome === 'accepted';
} catch (error) {
- console.error('安装提示失败:', error);
+ console.error('Failed to prompt install', error);
return false;
}
}
-/**
- * 显示安装按钮
- */
function showInstallButton(): void {
const installButton = document.getElementById('pwa-install-button');
if (installButton) {
@@ -126,9 +90,6 @@ function showInstallButton(): void {
}
}
-/**
- * 隐藏安装按钮
- */
function hideInstallButton(): void {
const installButton = document.getElementById('pwa-install-button');
if (installButton) {
@@ -136,27 +97,27 @@ function hideInstallButton(): void {
}
}
-/**
- * 检查是否已安装 PWA
- */
export function isPwaInstalled(): boolean {
- return window.matchMedia('(display-mode: standalone)').matches ||
- (window.navigator as any).standalone === true;
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return (
+ window.matchMedia('(display-mode: standalone)').matches ||
+ (window.navigator as Navigator & { standalone?: boolean }).standalone === true
+ );
}
-/**
- * 检查是否支持 PWA
- */
export function isPwaSupported(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
return 'serviceWorker' in navigator && 'PushManager' in window;
}
-/**
- * 请求推送权限
- */
export async function requestPushPermission(): Promise {
- if (!('Notification' in window)) {
- console.log('浏览器不支持通知');
+ if (typeof window === 'undefined' || !('Notification' in window)) {
return false;
}
@@ -164,82 +125,57 @@ export async function requestPushPermission(): Promise {
return permission === 'granted';
}
-/**
- * 订阅推送通知
- */
-export async function subscribeToPush(vapidPublicKey: string): Promise {
+export async function subscribeToPush(
+ vapidPublicKey: string,
+): Promise {
if (!swRegistration) {
- console.log('Service Worker 未注册');
return null;
}
try {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
- applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as unknown as BufferSource,
});
- console.log('推送订阅成功:', subscription);
return subscription;
} catch (error) {
- console.error('推送订阅失败:', error);
+ console.error('Failed to subscribe to push notifications', error);
return null;
}
}
-/**
- * Base64 转 Uint8Array
- */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
- const base64 = (base64String + padding)
- .replace(/-/g, '+')
- .replace(/_/g, '/');
-
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
- for (let i = 0; i < rawData.length; ++i) {
+ for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
+
return outputArray;
}
-/**
- * 监听网络状态
- */
-export function setupNetworkListener(
- onOnline?: () => void,
- onOffline?: () => void
-): void {
+export function setupNetworkListener(onOnline?: () => void, onOffline?: () => void): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
window.addEventListener('online', () => {
- console.log('网络已连接');
onOnline?.();
});
window.addEventListener('offline', () => {
- console.log('网络已断开');
onOffline?.();
});
}
-/**
- * 初始化 PWA
- */
export async function initPwa(): Promise {
- // 注册 Service Worker
await registerServiceWorker();
-
- // 设置安装提示
setupInstallPrompt();
-
- // 监听网络状态
- setupNetworkListener(
- () => console.log('网络已恢复'),
- () => console.log('网络已断开')
- );
-
- console.log('PWA 初始化完成');
+ setupNetworkListener();
}
export default {
diff --git a/tsconfig.json b/tsconfig.json
index 36b8dd1..8dd9ee6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,5 +18,31 @@
}
},
"include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
- "exclude": ["node_modules", "dist", ".umi", ".umi-production", ".umi-test"]
+ "exclude": [
+ "node_modules",
+ "dist",
+ ".umi",
+ ".umi-production",
+ ".umi-test",
+ "mock",
+ "tests",
+ "src/**/__tests__/**",
+ "src/utils/test/**",
+ "src/pages/dashboard/**",
+ "src/pages/form/**",
+ "src/pages/profile/**",
+ "src/pages/result/**",
+ "src/pages/exception/**",
+ "src/pages/table-list/**",
+ "src/utils/offline/**",
+ "src/services/sync/**",
+ "src/components/SyncStatus/**",
+ "src/models/sync.ts",
+ "src/pages/albums/**",
+ "src/pages/account/statistics/**",
+ "src/pages/story/offlineService.ts",
+ "src/services/albums/**",
+ "src/pages/story/components/TimelineItem/**",
+ "src/utils.ts"
+ ]
}