diff --git a/DESIGN.md b/DESIGN.md index 2297df2..efe3621 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -377,6 +377,7 @@ Life Post 可配置: - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 +- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 当前没有图片上传、转发、置顶或单独审核流程。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 @@ -409,7 +410,7 @@ API 暴露边界: - `/items/:id/edit` - `/recipes/new` - `/recipes/:id/edit` -- Life 使用信息流内联发布与编辑,不使用路由驱动 Modal。 +- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。 - 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。 - 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。 @@ -428,7 +429,7 @@ API 暴露边界: - `GET /api/items/:id` - `GET /api/recipes` - `GET /api/recipes/:id` -- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取。 +- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索。 认证 API: diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 4989997..f8c09bb 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1482,21 +1482,26 @@ async function getLifeCommentById(id: number): Promise { export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise { const cursor = decodeLifePostCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); + const search = asString(paramsQuery.search)?.trim(); const params: unknown[] = []; - let cursorClause = ''; + const conditions: string[] = []; + + if (search) { + params.push(`%${search}%`); + conditions.push(`lp.body ILIKE $${params.length}`); + } if (cursor) { params.push(cursor.createdAt, cursor.id); - cursorClause = ` - WHERE (lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer) - `; + conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`); } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; params.push(limit + 1); const rows = await query( ` ${lifePostProjection()} - ${cursorClause} + ${whereClause} ORDER BY lp.created_at DESC, lp.id DESC LIMIT $${params.length} `, diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 7ffdd63..51f6dbc 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -229,6 +229,10 @@ const messages = { composerPrompt: 'What would you like to share?', bodyLabel: 'Post', bodyPlaceholder: 'Share a thought, tip, or discovery...', + newPost: 'New Post', + search: 'Search Life', + searchPlaceholder: 'Search post content...', + searchEmpty: 'No posts match your search', comments: 'Comments', commentsCount: '{count} comments', comment: 'Comment', @@ -572,6 +576,10 @@ const messages = { composerPrompt: '想分享什么?', bodyLabel: '动态内容', bodyPlaceholder: '分享一段想法、心得或发现……', + newPost: 'New Post', + search: '搜索动态', + searchPlaceholder: '搜索动态内容……', + searchEmpty: '没有匹配的动态', comments: '评论', commentsCount: '{count} 条评论', comment: '评论', diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index fc6cf7e..21dcdf6 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -30,6 +30,7 @@ export const iconReactionHelpful: AppIcon = 'mdi:lightbulb-on-outline'; export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline'; export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline'; export const iconSave: AppIcon = 'mdi:content-save-outline'; +export const iconSearch: AppIcon = 'mdi:magnify'; export const iconSuccess: AppIcon = 'mdi:check-circle-outline'; export const iconTranslate: AppIcon = 'mdi:translate'; export const iconWarning: AppIcon = 'mdi:alert-outline'; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 11fd07a..c74b0a2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -197,6 +197,7 @@ export interface LifePostsPage { export interface LifePostsParams { cursor?: string | null; limit?: number; + search?: string; } export interface LifeComment { @@ -465,7 +466,7 @@ export const api = { dailyChecklist: () => getJson('/api/daily-checklist'), lifePosts: (params: LifePostsParams = {}) => getJson( - `/api/life-posts${buildQuery({ cursor: params.cursor ?? undefined, limit: params.limit })}` + `/api/life-posts${buildQuery({ cursor: params.cursor ?? undefined, limit: params.limit, search: params.search?.trim() })}` ), createLifePost: (payload: LifePostPayload) => sendJson('/api/life-posts', 'POST', payload), updateLifePost: (id: string | number, payload: LifePostPayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 3be1452..f6d0854 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1179,6 +1179,27 @@ button:disabled, justify-content: flex-start; } +.life-toolbar { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; +} + +.life-toolbar__search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; +} + +.life-toolbar__actions { + display: flex; + justify-content: flex-end; +} + +.life-toolbar .ui-button { + min-height: 44px; +} + .life-composer, .life-post { display: grid; @@ -2844,6 +2865,16 @@ button:disabled, justify-content: flex-start; } + .life-toolbar, + .life-toolbar__search { + grid-template-columns: 1fr; + } + + .life-toolbar__actions, + .life-toolbar .ui-button { + width: 100%; + } + .life-post__metrics { justify-content: flex-start; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index e1b7979..3979835 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -2,10 +2,13 @@ import { Icon } from '@iconify/vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; +import FilterPanel from '../components/FilterPanel.vue'; +import Modal from '../components/Modal.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import StatusMessage from '../components/StatusMessage.vue'; import { + iconAdd, iconCancel, iconComment, iconDelete, @@ -16,7 +19,8 @@ import { iconReactionLike, iconReactionThanks, iconReply, - iconSave + iconSave, + iconSearch } from '../icons'; import { api, @@ -36,8 +40,11 @@ const loading = ref(true); const loadingMore = ref(false); const authReady = ref(false); const busy = ref(false); +const searchDraft = ref(''); +const submittedSearch = ref(''); const body = ref(''); const editingPostId = ref(null); +const postModalOpen = ref(false); const formError = ref(''); const loadError = ref(''); const commentBodies = ref>({}); @@ -71,6 +78,8 @@ const reactionOptions = [ const canPost = computed(() => currentUser.value?.emailVerified === true); const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length)); const isEditing = computed(() => editingPostId.value !== null); +const searchQuery = computed(() => submittedSearch.value.trim()); +const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost'))); const submitLabel = computed(() => { if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing'); return isEditing.value ? t('pages.life.update') : t('pages.life.publish'); @@ -99,13 +108,14 @@ async function loadCurrentUser() { async function loadPosts() { const requestId = ++postsRequestId; loading.value = true; + loadingMore.value = false; loadError.value = ''; nextCursor.value = null; hasMorePosts.value = false; loadMorePaused.value = false; try { - const page = await api.lifePosts({ limit: lifePostPageSize }); + const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value }); if (requestId !== postsRequestId) { return; } @@ -139,7 +149,7 @@ async function loadMorePosts() { loadError.value = ''; try { - const page = await api.lifePosts({ cursor, limit: lifePostPageSize }); + const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value }); if (requestId !== postsRequestId) { return; } @@ -172,6 +182,36 @@ function payload() { }; } +function submitSearch() { + const nextSearch = searchDraft.value.trim(); + if (nextSearch === submittedSearch.value && !loadError.value) { + return; + } + + submittedSearch.value = nextSearch; + void loadPosts(); +} + +function matchesCurrentSearch(post: LifePost) { + const keyword = searchQuery.value.toLowerCase(); + return keyword === '' || post.body.toLowerCase().includes(keyword); +} + +function openCreatePostModal() { + resetForm(); + postModalOpen.value = true; + void nextTick(() => bodyInput.value?.focus()); +} + +function closePostModal() { + if (busy.value) { + return; + } + + postModalOpen.value = false; + resetForm(); +} + async function submitPost() { if (!body.value.trim()) { formError.value = t('pages.life.bodyRequired'); @@ -188,9 +228,12 @@ async function submitPost() { replacePost(updated); } else { const created = await api.createLifePost(payload()); - posts.value = [created, ...posts.value]; + if (matchesCurrentSearch(created)) { + posts.value = [created, ...posts.value]; + } } resetForm(); + postModalOpen.value = false; } catch (error) { formError.value = error instanceof Error && error.message @@ -251,6 +294,11 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) { } function replacePost(updatedPost: LifePost) { + if (!matchesCurrentSearch(updatedPost)) { + posts.value = posts.value.filter((post) => post.id !== updatedPost.id); + return; + } + posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post)); } @@ -363,6 +411,7 @@ function startEdit(post: LifePost) { editingPostId.value = post.id; body.value = post.body; formError.value = ''; + postModalOpen.value = true; void nextTick(() => bodyInput.value?.focus()); } @@ -371,16 +420,17 @@ async function deletePost(post: LifePost) { return; } - formError.value = ''; + loadError.value = ''; try { await api.deleteLifePost(post.id); posts.value = posts.value.filter((item) => item.id !== post.id); if (editingPostId.value === post.id) { resetForm(); + postModalOpen.value = false; } } catch (error) { - formError.value = error instanceof Error && error.message ? error.message : t('pages.life.deleteFailed'); + loadError.value = error instanceof Error && error.message ? error.message : t('pages.life.deleteFailed'); } } @@ -545,14 +595,35 @@ onUnmounted(() => { + + + +
+ +
+
+ {{ loadError }} -
-
-

{{ t('pages.life.composerTitle') }}

-

{{ t('pages.life.composerPrompt') }}

-
- + @@ -592,7 +663,7 @@ onUnmounted(() => { {{ t('nav.login') }} -
+
@@ -885,7 +956,7 @@ onUnmounted(() => {
-

{{ t('pages.life.empty') }}

+

{{ searchQuery ? t('pages.life.searchEmpty') : t('pages.life.empty') }}