From 433b19eb673e8c29e63d53f8efc0e5757677236d Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 00:16:30 +0800 Subject: [PATCH] feat(life): add tags to life posts and feed filtering Allow users to select tags when creating or editing life posts Add tag tabs to the life feed for filtering posts by tag --- DESIGN.md | 12 +- backend/db/schema.sql | 42 ++++++- backend/src/queries.ts | 153 ++++++++++++++++++------- backend/src/server.ts | 12 +- frontend/src/components/TagsSelect.vue | 113 +++++++++++++++++- frontend/src/i18n.ts | 14 ++- frontend/src/services/api.ts | 14 ++- frontend/src/styles/main.css | 37 +++++- frontend/src/views/AdminView.vue | 3 +- frontend/src/views/LifeView.vue | 77 +++++++++++-- 10 files changed, 411 insertions(+), 66 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index efe3621..d3d2c75 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -59,6 +59,7 @@ - 地图 - 栖息地 - 每日 CheckList Task + - Life 标签 - 支持翻译的字段: - `name` - `title` @@ -166,6 +167,11 @@ - 名称 - 用于栖息地中 Pokemon 出现地点。 +### Life 标签 + +- 名称 +- 用于 Life Post 分类展示和 Feed 筛选。 + ## Pokemon Pokemon 可配置: @@ -361,6 +367,7 @@ Life 是社区生活分享信息流,类似轻量社交动态。 Life Post 可配置: - Post 内容正文 +- 标签:使用 Life 标签配置,可多选 - 创建者、最后编辑者、创建时间、最后编辑时间 - 评论 - 评论回复:仅支持回复顶层评论,不做无限嵌套 @@ -372,12 +379,14 @@ Life Post 可配置: - 信息流按创建时间倒序展示。 - 已注册并完成邮箱验证的用户可以发布 Life Post。 - 作者本人可以编辑、删除自己的 Life Post。 +- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。 - 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。 - 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 +- Feed 顶部展示 Life 标签 Tabs,包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 当前没有图片上传、转发、置顶或单独审核流程。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 @@ -385,6 +394,7 @@ Life Post 可配置: API 暴露边界: - Life Post 作者信息只返回 `id` 和 `displayName`。 +- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。 - Life Comment 作者信息只返回 `id` 和 `displayName`。 - Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 - Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。 @@ -429,7 +439,7 @@ API 暴露边界: - `GET /api/items/:id` - `GET /api/recipes` - `GET /api/recipes/:id` -- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索。 +- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。 认证 API: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 8bf14a4..fa9cdb0 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS entity_translations ( 'items', 'maps', 'habitats', - 'daily-checklist-items' + 'daily-checklist-items', + 'life-tags' ) ), entity_id integer NOT NULL, @@ -65,7 +66,8 @@ ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_c 'items', 'maps', 'habitats', - 'daily-checklist-items' + 'daily-checklist-items', + 'life-tags' ) ); ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check; @@ -119,6 +121,16 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items ( CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx ON daily_checklist_items(sort_order, id); +CREATE TABLE IF NOT EXISTS life_tags ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + CREATE TABLE IF NOT EXISTS life_posts ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000), @@ -134,6 +146,15 @@ ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title; CREATE INDEX IF NOT EXISTS life_posts_created_at_idx ON life_posts(created_at DESC, id DESC); +CREATE TABLE IF NOT EXISTS life_post_tags ( + post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, + tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx + ON life_post_tags(tag_id, post_id); + CREATE TABLE IF NOT EXISTS life_post_comments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, @@ -401,6 +422,12 @@ ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEF ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0); +ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); +ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); + ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); @@ -493,6 +520,16 @@ SET sort_order = ordered.next_sort_order FROM ordered WHERE target.id = ordered.id; +WITH ordered AS ( + SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order + FROM life_tags + WHERE sort_order = 0 +) +UPDATE life_tags target +SET sort_order = ordered.next_sort_order +FROM ordered +WHERE target.id = ordered.id; + WITH ordered AS ( SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order FROM item_categories @@ -568,6 +605,7 @@ CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id); CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id); +CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id); CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id); CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id); CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index f8c09bb..39854e1 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -23,7 +23,8 @@ type EntityType = | 'items' | 'maps' | 'habitats' - | 'daily-checklist-items'; + | 'daily-checklist-items' + | 'life-tags'; type ConfigType = | 'pokemon-types' @@ -33,7 +34,8 @@ type ConfigType = | 'item-categories' | 'item-usages' | 'acquisition-methods' - | 'maps'; + | 'maps' + | 'life-tags'; type ConfigDefinition = { table: string; @@ -107,6 +109,7 @@ type DailyChecklistPayload = { type LifePostPayload = { body: string; + tagIds: number[]; }; type LifeCommentPayload = { @@ -139,6 +142,7 @@ type LifePostRow = { updatedAt: Date; author: { id: number; displayName: string } | null; updatedBy: { id: number; displayName: string } | null; + tags: Array<{ id: number; name: string }>; }; type LifePost = Omit & { @@ -248,7 +252,8 @@ const configDefinitions: Record = { 'item-categories': { table: 'item_categories', entityType: 'item-categories' }, 'item-usages': { table: 'item_usages', entityType: 'item-usages' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, - maps: { table: 'maps', entityType: 'maps' } + maps: { table: 'maps', entityType: 'maps' }, + 'life-tags': { table: 'life_tags', entityType: 'life-tags' } }; const sortableContentDefinitions: Record = { @@ -1068,7 +1073,8 @@ export async function getOptions(locale = defaultLocale) { itemCategories, itemUsages, acquisitionMethods, - maps + maps, + lifeTags ] = await Promise.all([ optionSelect('pokemon_types', 'pokemon-types', locale), skillOptions(locale), @@ -1077,7 +1083,8 @@ export async function getOptions(locale = defaultLocale) { optionSelect('item_categories', 'item-categories', locale), optionSelect('item_usages', 'item-usages', locale), optionSelect('acquisition_methods', 'acquisition-methods', locale), - optionSelect('maps', 'maps', locale) + optionSelect('maps', 'maps', locale), + optionSelect('life_tags', 'life-tags', locale) ]); return { @@ -1089,7 +1096,8 @@ export async function getOptions(locale = defaultLocale) { itemUsages, acquisitionMethods, itemTags: favoriteThings, - maps + maps, + lifeTags }; } @@ -1231,7 +1239,10 @@ function cleanLifePostPayload(payload: Record): LifePostPayload throw validationError('Post is too long'); } - return { body }; + return { + body, + tagIds: cleanIds(payload.tagIds) + }; } function cleanLifeCommentPayload(payload: Record): LifeCommentPayload { @@ -1264,7 +1275,9 @@ function cleanLifeReactionType(value: unknown): LifeReactionType { return value; } -function lifePostProjection(): string { +function lifePostProjection(locale = defaultLocale): string { + const tagName = localizedName('life-tags', 'lt', locale); + return ` SELECT lp.id, @@ -1279,7 +1292,13 @@ function lifePostProjection(): string { CASE WHEN updated_user.id IS NULL THEN NULL ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name) - END AS "updatedBy" + END AS "updatedBy", + COALESCE(( + SELECT json_agg(json_build_object('id', lt.id, 'name', ${tagName}) ORDER BY ${orderByEntity('lt')}) + FROM life_post_tags lpt + JOIN life_tags lt ON lt.id = lpt.tag_id + WHERE lpt.post_id = lp.id + ), '[]'::json) AS tags FROM life_posts lp LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id @@ -1337,6 +1356,7 @@ function hydrateLifePost( updatedAt: post.updatedAt, author: post.author, updatedBy: post.updatedBy, + tags: post.tags, comments: commentsByPost.get(post.id) ?? [], reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), myReaction: myReactionsByPost.get(post.id) ?? null @@ -1479,10 +1499,15 @@ async function getLifeCommentById(id: number): Promise { return row ? { ...row, replies: [] } : null; } -export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise { +export async function listLifePosts( + paramsQuery: QueryParams = {}, + userId: number | null = null, + locale = defaultLocale +): Promise { const cursor = decodeLifePostCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); const search = asString(paramsQuery.search)?.trim(); + const tagIdValue = asString(paramsQuery.tagId)?.trim(); const params: unknown[] = []; const conditions: string[] = []; @@ -1491,6 +1516,17 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe conditions.push(`lp.body ILIKE $${params.length}`); } + if (tagIdValue) { + const tagId = requirePositiveInteger(tagIdValue, 'Tag is invalid'); + params.push(tagId); + conditions.push(`EXISTS ( + SELECT 1 + FROM life_post_tags lpt_filter + WHERE lpt_filter.post_id = lp.id + AND lpt_filter.tag_id = $${params.length} + )`); + } + if (cursor) { params.push(cursor.createdAt, cursor.id); conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`); @@ -1500,7 +1536,7 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe params.push(limit + 1); const rows = await query( ` - ${lifePostProjection()} + ${lifePostProjection(locale)} ${whereClause} ORDER BY lp.created_at DESC, lp.id DESC LIMIT $${params.length} @@ -1521,10 +1557,10 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe }; } -async function getLifePostById(id: number, userId: number | null = null): Promise { +async function getLifePostById(id: number, userId: number | null = null, locale = defaultLocale): Promise { const post = await queryOne( ` - ${lifePostProjection()} + ${lifePostProjection(locale)} WHERE lp.id = $1 `, [id] @@ -1539,36 +1575,66 @@ async function getLifePostById(id: number, userId: number | null = null): Promis return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost); } -export async function createLifePost(payload: Record, userId: number) { - const cleanPayload = cleanLifePostPayload(payload); +async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise { + await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]); - const result = await queryOne<{ id: number }>( - ` - INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id) - VALUES ($1, $2, $2) - RETURNING id - `, - [cleanPayload.body, userId] - ); - - return getLifePostById(result?.id ?? 0, userId); + for (const tagId of tagIds) { + await client.query( + ` + INSERT INTO life_post_tags (post_id, tag_id) + VALUES ($1, $2) + `, + [postId, tagId] + ); + } } -export async function updateLifePost(id: number, payload: Record, userId: number) { +export async function createLifePost(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanLifePostPayload(payload); - const result = await queryOne<{ id: number }>( - ` - UPDATE life_posts - SET body = $1, updated_by_user_id = $2, updated_at = now() - WHERE id = $3 - AND created_by_user_id = $2 - RETURNING id - `, - [cleanPayload.body, userId, id] - ); + const id = await withTransaction(async (client) => { + const result = await client.query<{ id: number }>( + ` + INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $2) + RETURNING id + `, + [cleanPayload.body, userId] + ); - return result ? getLifePostById(result.id, userId) : null; + const createdId = result.rows[0].id; + await replaceLifePostTags(client, createdId, cleanPayload.tagIds); + return createdId; + }); + + return getLifePostById(id, userId, locale); +} + +export async function updateLifePost(id: number, payload: Record, userId: number, locale = defaultLocale) { + const cleanPayload = cleanLifePostPayload(payload); + + const updatedId = await withTransaction(async (client) => { + const result = await client.query<{ id: number }>( + ` + UPDATE life_posts + SET body = $1, updated_by_user_id = $2, updated_at = now() + WHERE id = $3 + AND created_by_user_id = $2 + RETURNING id + `, + [cleanPayload.body, userId, id] + ); + + const resultId = result.rows[0]?.id ?? null; + if (resultId === null) { + return null; + } + + await replaceLifePostTags(client, resultId, cleanPayload.tagIds); + return resultId; + }); + + return updatedId ? getLifePostById(updatedId, userId, locale) : null; } export async function deleteLifePost(id: number, userId: number) { @@ -1585,7 +1651,12 @@ export async function deleteLifePost(id: number, userId: number) { return Boolean(result); } -export async function setLifePostReaction(postId: number, payload: Record, userId: number) { +export async function setLifePostReaction( + postId: number, + payload: Record, + userId: number, + locale = defaultLocale +) { const reactionType = cleanLifeReactionType(payload.reactionType); const result = await queryOne<{ postId: number }>( @@ -1600,10 +1671,10 @@ export async function setLifePostReaction(postId: number, payload: Record( ` DELETE FROM life_post_reactions @@ -1614,7 +1685,7 @@ export async function deleteLifePostReaction(postId: number, userId: number) { [postId, userId] ); - return getLifePostById(postId, userId); + return getLifePostById(postId, userId, locale); } export async function createLifeComment(postId: number, payload: Record, userId: number) { diff --git a/backend/src/server.ts b/backend/src/server.ts index 7c12758..ce09265 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -195,12 +195,14 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque app.get('/api/life-posts', async (request) => { const user = await optionalUser(request); - return listLifePosts(request.query as Record, user?.id ?? null); + return listLifePosts(request.query as Record, user?.id ?? null, requestLocale(request)); }); app.post('/api/life-posts', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reply.code(201).send(await createLifePost(request.body as Record, user.id)) : undefined; + return user + ? reply.code(201).send(await createLifePost(request.body as Record, user.id, requestLocale(request))) + : undefined; }); app.post('/api/life-posts/:postId/comments', async (request, reply) => { @@ -234,7 +236,7 @@ app.put('/api/life-posts/:id', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const post = await updateLifePost(Number(id), request.body as Record, user.id); + const post = await updateLifePost(Number(id), request.body as Record, user.id, requestLocale(request)); return post ? post : reply.code(404).send({ message: 'Not found' }); }); @@ -244,7 +246,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const post = await setLifePostReaction(Number(id), request.body as Record, user.id); + const post = await setLifePostReaction(Number(id), request.body as Record, user.id, requestLocale(request)); return post ? post : reply.code(404).send({ message: 'Not found' }); }); @@ -254,7 +256,7 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const post = await deleteLifePostReaction(Number(id), user.id); + const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request)); return post ? post : reply.code(404).send({ message: 'Not found' }); }); diff --git a/frontend/src/components/TagsSelect.vue b/frontend/src/components/TagsSelect.vue index 51b6606..1ee960c 100644 --- a/frontend/src/components/TagsSelect.vue +++ b/frontend/src/components/TagsSelect.vue @@ -1,6 +1,6 @@