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