feat(life): add search and move post composer to modal

Support searching life posts by content
Move post creation and editing to a modal dialog
Add search toolbar and update empty states
This commit is contained in:
2026-05-01 23:48:57 +08:00
parent c03d4271e1
commit 866d7add16
7 changed files with 142 additions and 24 deletions

View File

@@ -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

View File

@@ -1482,21 +1482,26 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise<LifePostsPage> {
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<LifePostRow>(
`
${lifePostProjection()}
${cursorClause}
${whereClause}
ORDER BY lp.created_at DESC, lp.id DESC
LIMIT $${params.length}
`,

View File

@@ -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: '评论',

View File

@@ -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';

View File

@@ -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<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: (params: LifePostsParams = {}) =>
getJson<LifePostsPage>(
`/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<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) =>

View File

@@ -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;
}

View File

@@ -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<number | null>(null);
const postModalOpen = ref(false);
const formError = ref('');
const loadError = ref('');
const commentBodies = ref<Record<number, string>>({});
@@ -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());
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(() => {
<template #kicker>{{ t('pages.life.kicker') }}</template>
</PageHeader>
<FilterPanel class="life-toolbar">
<form class="life-toolbar__search" @submit.prevent="submitSearch">
<div class="field">
<label for="life-search">{{ t('pages.life.search') }}</label>
<input id="life-search" v-model="searchDraft" type="search" :placeholder="t('pages.life.searchPlaceholder')" />
</div>
<button class="ui-button ui-button--ghost" type="submit">
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
{{ t('common.search') }}
</button>
</form>
<div class="life-toolbar__actions">
<button class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.newPost') }}
</button>
</div>
</FilterPanel>
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
<section class="life-composer" :aria-busy="!authReady || busy">
<div class="life-composer__header">
<h2>{{ t('pages.life.composerTitle') }}</h2>
<p>{{ t('pages.life.composerPrompt') }}</p>
</div>
<Modal
:open="postModalOpen"
:title="postModalTitle"
:subtitle="t('pages.life.composerPrompt')"
:close-label="t('common.close')"
@close="closePostModal"
>
<div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
<Skeleton width="42%" />
@@ -579,9 +650,9 @@ onUnmounted(() => {
<Icon :icon="isEditing ? iconSave : iconLife" class="ui-icon" aria-hidden="true" />
{{ submitLabel }}
</button>
<button v-if="isEditing" class="ui-button ui-button--ghost" :disabled="busy" type="button" @click="resetForm">
<button class="ui-button ui-button--ghost" :disabled="busy" type="button" @click="closePostModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.cancelEdit') }}
{{ t('common.cancel') }}
</button>
</div>
</form>
@@ -592,7 +663,7 @@ onUnmounted(() => {
{{ t('nav.login') }}
</RouterLink>
</div>
</section>
</Modal>
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
@@ -885,7 +956,7 @@ onUnmounted(() => {
<div v-if="hasMorePosts" ref="loadMoreSentinel" class="life-feed__sentinel" aria-hidden="true"></div>
</div>
<p v-else class="status">{{ t('pages.life.empty') }}</p>
<p v-else class="status">{{ searchQuery ? t('pages.life.searchEmpty') : t('pages.life.empty') }}</p>
</section>
</section>
</template>