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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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}
|
||||
`,
|
||||
|
||||
@@ -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: '评论',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
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(() => {
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user