From 0ca6f779ecffcdec842b171a792b5e05ce7dc04a Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 00:47:42 +0800 Subject: [PATCH] feat(life): enhance search, empty states, and reaction controls Add clear button to search input and improve empty state UI Add split button for reactions and close picker on outside click Add retry button for paused feed pagination --- DESIGN.md | 2 +- frontend/src/i18n.ts | 12 ++ frontend/src/styles/main.css | 230 ++++++++++++++++++++++++++++++-- frontend/src/views/LifeView.vue | 156 +++++++++++++++++----- 4 files changed, 349 insertions(+), 51 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f5b0473..9685f59 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -385,7 +385,7 @@ Life Post 可配置: - 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 -- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 +- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - Feed 顶部展示 Life 标签 Tabs,包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f25c32c..728a527 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -236,7 +236,9 @@ const messages = { searchTags: 'Search tags', search: 'Search Life', searchPlaceholder: 'Search post content...', + clearSearch: 'Clear search', searchEmpty: 'No posts match your search', + searchEmptyHint: 'Try another keyword or clear the search.', comments: 'Comments', commentsCount: '{count} comments', comment: 'Comment', @@ -249,6 +251,8 @@ const messages = { reactionHelpful: 'Helpful', reactionFun: 'Fun', reactionThanks: 'Thanks', + chooseReaction: 'Choose reaction', + reactionMenu: 'Reaction menu', removeReaction: 'Remove reaction', reactionFailed: 'Reaction failed', commentPlaceholder: 'Write a comment...', @@ -273,7 +277,9 @@ const messages = { updating: 'Updating', cancelEdit: 'Cancel edit', empty: 'No posts yet', + emptyHint: 'Verified members can start the first Life post.', loading: 'Loading Life feed', + retryFeed: 'Retry loading', loginPrompt: 'Log in with a verified email to post.', verifyPrompt: 'Complete email verification to post.', editPost: 'Edit post', @@ -588,7 +594,9 @@ const messages = { searchTags: '搜索标签', search: '搜索动态', searchPlaceholder: '搜索动态内容……', + clearSearch: '清除搜索', searchEmpty: '没有匹配的动态', + searchEmptyHint: '换个关键词或清除搜索。', comments: '评论', commentsCount: '{count} 条评论', comment: '评论', @@ -601,6 +609,8 @@ const messages = { reactionHelpful: '有帮助', reactionFun: '有趣', reactionThanks: '感谢', + chooseReaction: '选择互动', + reactionMenu: '互动菜单', removeReaction: '取消互动', reactionFailed: '互动失败', commentPlaceholder: '写下评论……', @@ -625,7 +635,9 @@ const messages = { updating: '更新中', cancelEdit: '取消编辑', empty: '暂无动态', + emptyHint: '已验证成员可以发布第一条 Life 动态。', loading: '正在加载 Life 动态', + retryFeed: '重试加载', loginPrompt: '使用已验证邮箱登录后即可发布。', verifyPrompt: '完成邮箱验证后即可发布。', editPost: '编辑动态', diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index de1e5a7..5a5e475 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1189,13 +1189,51 @@ button:disabled, .life-toolbar { grid-template-columns: minmax(0, 1fr) auto; align-items: end; + gap: 16px; } .life-toolbar__search { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(220px, 1fr) auto; align-items: end; gap: 10px; + min-width: 0; +} + +.life-toolbar__field { + min-width: 0; +} + +.life-search-control { + position: relative; +} + +.life-search-control input { + padding-right: 48px; +} + +.life-search-control__clear { + position: absolute; + top: 0; + right: 0; + width: 44px; + min-height: 44px; + display: inline-grid; + place-items: center; + border-radius: 0 var(--radius-control) var(--radius-control) 0; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.life-search-control__clear:hover { + background: color-mix(in srgb, var(--pokemon-blue) 9%, transparent); + color: var(--pokemon-blue-deep); +} + +.life-search-control__clear .ui-icon { + width: 18px; + height: 18px; } .life-toolbar__actions { @@ -1251,10 +1289,25 @@ button:disabled, gap: 14px; } +.life-feed { + display: grid; +} + +.life-feed__list { + width: min(100%, 920px); + justify-self: center; +} + .life-feed__sentinel { min-height: 1px; } +.life-feed__retry { + display: flex; + justify-content: center; + padding: 4px 0 8px; +} + .life-form__counter { justify-self: end; } @@ -1288,6 +1341,8 @@ button:disabled, } .life-post { + gap: 16px; + padding: 18px; box-shadow: var(--shadow-soft); } @@ -1345,8 +1400,10 @@ button:disabled, } .life-post__body { + max-width: 72ch; margin: 0; color: var(--ink); + font-size: 16px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; @@ -1381,8 +1438,8 @@ button:disabled, flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 8px; - padding-top: 8px; + gap: 10px 14px; + padding-top: 10px; border-top: 1px solid var(--line); } @@ -1411,6 +1468,10 @@ button:disabled, color: var(--ink-soft); font-weight: 900; cursor: pointer; + transition: + background 0.14s ease, + border-color 0.14s ease, + color 0.14s ease; } .life-post__engagement-button:hover, @@ -1435,11 +1496,49 @@ button:disabled, position: relative; } +.life-reaction-control { + display: inline-flex; + align-items: stretch; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); +} + .life-reaction-trigger { position: relative; + min-width: 96px; + justify-content: flex-start; + padding: 7px 10px; +} + +.life-reaction-trigger__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.life-reaction-menu-button { width: 44px; - justify-content: center; - padding: 7px; + min-height: 44px; + display: inline-grid; + place-items: center; + border-left: 1px solid var(--line); + background: transparent; + color: var(--ink-soft); + cursor: pointer; +} + +.life-reaction-menu-button:hover, +.life-reaction-menu-button[aria-expanded="true"] { + background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft)); + color: var(--pokemon-blue-deep); +} + +.life-post__engagement-button:disabled, +.life-reaction-menu-button:disabled { + cursor: not-allowed; + opacity: 0.54; } .life-reaction-picker { @@ -1447,10 +1546,10 @@ button:disabled, z-index: 10; top: calc(100% + 6px); left: 0; - width: max-content; + width: min(280px, calc(100vw - 48px)); max-width: calc(100vw - 48px); - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 8px; border: 2px solid var(--line-strong); @@ -1461,20 +1560,29 @@ button:disabled, .life-reaction-option { position: relative; - width: 44px; min-height: 44px; display: inline-flex; align-items: center; - justify-content: center; - padding: 7px; + justify-content: flex-start; + gap: 8px; + min-width: 0; + padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-control); background: var(--surface-soft); color: var(--ink-soft); + font-size: 14px; font-weight: 900; cursor: pointer; } +.life-reaction-option span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .life-reaction-option:hover, .life-reaction-option.is-active { border-color: color-mix(in srgb, var(--pokemon-blue) 50%, var(--line)); @@ -1564,6 +1672,8 @@ button:disabled, .life-comments { display: grid; gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); } .life-comments__header { @@ -1596,6 +1706,7 @@ button:disabled, .life-comment-form { display: grid; gap: 8px; + max-width: 760px; } .life-comment-form textarea { @@ -1700,11 +1811,12 @@ button:disabled, } .life-comment__link-button { - min-height: 32px; + min-height: 44px; display: inline-flex; align-items: center; gap: 5px; - padding: 3px 0; + padding: 8px 10px; + border-radius: var(--radius-control); background: transparent; color: var(--pokemon-blue); font-size: 13px; @@ -1713,8 +1825,8 @@ button:disabled, } .life-comment__link-button:hover { + background: color-mix(in srgb, var(--pokemon-blue) 9%, transparent); color: var(--pokemon-blue-deep); - text-decoration: underline; } .life-comment__link-button .ui-icon { @@ -1726,6 +1838,49 @@ button:disabled, margin: 0; } +.life-empty { + width: min(100%, 680px); + justify-self: center; + display: grid; + justify-items: center; + gap: 12px; + padding: 28px 20px; + border: 2px solid var(--line-strong); + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--shadow-control); + text-align: center; +} + +.life-empty__icon { + width: 38px; + height: 38px; + color: var(--pokemon-blue); +} + +.life-empty__copy { + display: grid; + gap: 4px; +} + +.life-empty__copy h2, +.life-empty__copy p { + margin: 0; +} + +.life-empty__copy h2 { + color: var(--ink); + font-family: var(--font-display); + font-size: 22px; + font-weight: 950; + line-height: 1.15; +} + +.life-empty__copy p { + color: var(--muted); + font-weight: 800; +} + .reorderable-row { position: relative; flex-wrap: wrap; @@ -1823,12 +1978,19 @@ button:disabled, } @media (prefers-reduced-motion: reduce) { + .life-page .ui-button, + .life-post__engagement-button, + .life-reaction-menu-button, + .life-reaction-option, + .life-reaction-tooltip, + .life-search-control__clear, .reorderable-row, .reorderable-list-move, .drag-handle { transition: none; } + .life-page .ui-button:hover, .reorderable-row.is-dragging, .drag-handle:active { transform: none; @@ -2910,7 +3072,47 @@ button:disabled, width: 100%; } + .life-feed__list { + width: 100%; + } + + .life-post { + padding: 16px; + } + + .life-post__engagement { + align-items: stretch; + } + + .life-post__engagement-actions, .life-post__metrics { + width: 100%; + } + + .life-post__engagement-actions { + align-items: stretch; + } + + .life-reactions, + .life-reaction-control { + min-width: 0; + flex: 1 1 auto; + } + + .life-reaction-trigger { + flex: 1 1 auto; + } + + .life-reaction-picker { + width: min(100%, calc(100vw - 64px)); + grid-template-columns: 1fr; + } + + .life-post__metrics { + justify-content: flex-start; + } + + .life-reaction-summary { justify-content: flex-start; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 0275f1f..58b863c 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -12,6 +12,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue'; import { iconAdd, iconCancel, + iconChevronDown, iconComment, iconDelete, iconEdit, @@ -22,7 +23,8 @@ import { iconReactionThanks, iconReply, iconSave, - iconSearch + iconSearch, + iconWarning } from '../icons'; import { api, @@ -65,6 +67,8 @@ const reactionErrors = ref>({}); const bodyInput = ref(null); const loadMoreSentinel = ref(null); const lifePostPageSize = 20; +const bodyMaxLength = 2000; +const commentMaxLength = 1000; const skeletonPostCount = 3; const loadingMoreSkeletonCount = 2; let removeAuthListener: (() => void) | null = null; @@ -83,7 +87,7 @@ const reactionOptions = [ ] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>; const canPost = computed(() => currentUser.value?.emailVerified === true); -const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length)); +const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length)); const isEditing = computed(() => editingPostId.value !== null); const searchQuery = computed(() => submittedSearch.value.trim()); const selectedFeedTagId = computed(() => { @@ -222,6 +226,25 @@ function submitSearch() { void loadPosts(); } +function clearSearch() { + const hadSubmittedSearch = Boolean(submittedSearch.value); + if (!searchDraft.value && !hadSubmittedSearch) { + return; + } + + searchDraft.value = ''; + submittedSearch.value = ''; + + if (hadSubmittedSearch) { + void loadPosts(); + } +} + +function retryLoadMore() { + loadMorePaused.value = false; + void loadMorePosts(); +} + function matchesCurrentFilters(post: LifePost) { const keyword = searchQuery.value.toLowerCase(); const tagId = selectedFeedTagId.value; @@ -391,6 +414,10 @@ function canUseReactions() { return canPost.value && reactionBusyPostId.value === null; } +function closeReactionPicker() { + reactionPickerPostId.value = null; +} + function toggleReactionPicker(postId: number) { if (!canUseReactions()) { return; @@ -400,6 +427,22 @@ function toggleReactionPicker(postId: number) { reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId; } +function closeReactionPickerFromDocument(event: MouseEvent) { + if (reactionPickerPostId.value === null || !(event.target instanceof Element)) { + return; + } + + if (!event.target.closest('.life-reactions')) { + closeReactionPicker(); + } +} + +function closeReactionPickerFromKeyboard(event: KeyboardEvent) { + if (event.key === 'Escape' && reactionPickerPostId.value !== null) { + closeReactionPicker(); + } +} + function handleReactionContextMenu(event: MouseEvent, postId: number) { event.preventDefault(); toggleReactionPicker(postId); @@ -616,6 +659,8 @@ watch(locale, () => { }); onMounted(() => { + document.addEventListener('click', closeReactionPickerFromDocument); + document.addEventListener('keydown', closeReactionPickerFromKeyboard); void loadCurrentUser(); void loadLifeTags(); void loadPosts(); @@ -626,6 +671,8 @@ onMounted(() => { }); onUnmounted(() => { + document.removeEventListener('click', closeReactionPickerFromDocument); + document.removeEventListener('keydown', closeReactionPickerFromKeyboard); disconnectFeedObserver(); removeAuthListener?.(); }); @@ -638,10 +685,21 @@ onUnmounted(() => { -