From 6782ddd101764d041a3d52c0ab9d46718f5721d2 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 17:34:32 +0800 Subject: [PATCH] feat(life): replace multiple tags with single category for posts Add default category support and enforce one category per Life Post Update UI filters, forms, and translations to reflect category semantics --- DESIGN.md | 19 +-- backend/db/schema.sql | 22 +++ backend/src/queries.ts | 204 ++++++++++++++----------- frontend/src/services/api.ts | 26 ++-- frontend/src/views/AdminView.vue | 32 +++- frontend/src/views/LifeView.vue | 95 +++++++----- frontend/src/views/UserProfileView.vue | 6 +- system-wordings.ts | 32 ++-- 8 files changed, 264 insertions(+), 172 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6f5229c..46800dc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -59,7 +59,7 @@ - 地图 - 栖息地 - 每日 CheckList Task - - Life 标签 + - Life Category - 支持翻译的字段: - `name` - `title` @@ -379,9 +379,10 @@ - 名称 - 用于栖息地中 Pokemon 出现地点。 -### Life 标签 +### Life Category - 名称 +- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。 - 用于 Life Post 分类展示和 Feed 筛选。 ## Pokemon @@ -640,7 +641,7 @@ Life 是社区生活分享信息流,类似轻量社交动态。 Life Post 可配置: - Post 内容正文 -- 标签:使用 Life 标签配置,至少选择 1 个,可多选 +- Category:使用 Life Category 配置,必须且只能选择 1 个 - 创建者、最后编辑者、创建时间、最后编辑时间 - 评论 - 评论回复:仅支持回复顶层评论,不做无限嵌套 @@ -653,17 +654,17 @@ Life Post 可配置: - 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。 - 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。 - 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。 -- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择至少 1 个 Life 标签,可选择多个。 +- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。 - 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。 - 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。 -- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。 +- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。 - 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 -- Feed 使用 Tabs 展示 Life 标签筛选;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。 -- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索、标签和语言筛选可以同时生效。 +- Feed 使用 Tabs 展示 Life Category 筛选;包含 All 和后台配置的 Life Category;点击 Category 后按该 Category 筛选,搜索和 Category 筛选可以同时生效。 +- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索、Category 和语言筛选可以同时生效。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 当前没有图片上传、转发或置顶。 - Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。 @@ -677,7 +678,7 @@ Life Post 可配置: API 暴露边界: - Life Post 作者信息只返回 `id` 和 `displayName`。 -- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。 +- Life Post Category 只返回 `id` 和按当前语言解析后的 `name`。 - Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审;不返回内部错误、AI prompt、模型响应或 retry 细节。 - Life Comment 作者信息只返回 `id` 和 `displayName`。 - Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 @@ -769,7 +770,7 @@ API 暴露边界: - `GET /api/items/:id` - `GET /api/recipes` - `GET /api/recipes/:id` -- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区。 +- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区。 - `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。 - `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。 - `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index ecd2da8..fd4300d 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -523,6 +523,7 @@ CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx CREATE TABLE IF NOT EXISTS life_tags ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, + is_default boolean NOT NULL DEFAULT false, 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, @@ -533,6 +534,7 @@ CREATE TABLE IF NOT EXISTS life_tags ( 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), + category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT, ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ai_moderation_content_hash text, @@ -818,6 +820,9 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon ( PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather) ); +ALTER TABLE life_tags + ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false; + CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id); 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); @@ -825,6 +830,7 @@ CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_or CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id); CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item); CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id); +CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true; 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); @@ -900,12 +906,28 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), + ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT, ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text, ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz, ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0), ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(); +UPDATE life_posts lp +SET category_id = selected.tag_id +FROM ( + SELECT DISTINCT ON (lpt.post_id) lpt.post_id, lpt.tag_id + FROM life_post_tags lpt + JOIN life_tags lt ON lt.id = lpt.tag_id + ORDER BY lpt.post_id, lt.sort_order, lt.id +) selected +WHERE lp.id = selected.post_id + AND lp.category_id IS NULL; + +CREATE INDEX IF NOT EXISTS life_posts_category_idx + ON life_posts(category_id, created_at DESC, id DESC) + WHERE deleted_at IS NULL; + ALTER TABLE life_post_comments ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')), ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 757606f..b5bcbc6 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -55,6 +55,7 @@ type ConfigDefinition = { table: string; entityType: EntityType; hasItemDrop?: boolean; + hasDefault?: boolean; }; type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats'; type SortableContentDefinition = { @@ -178,7 +179,7 @@ type DailyChecklistPayload = { type LifePostPayload = { body: string; - tagIds: number[]; + categoryId: number; languageCode: string | null; }; @@ -250,7 +251,7 @@ type LifePostRow = { updatedAt: Date; author: { id: number; displayName: string } | null; updatedBy: { id: number; displayName: string } | null; - tags: Array<{ id: number; name: string }>; + category: { id: number; name: string } | null; }; type LifePost = Omit & { @@ -459,7 +460,7 @@ const configDefinitions: Record = { 'item-usages': { table: 'item_usages', entityType: 'item-usages' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, maps: { table: 'maps', entityType: 'maps' }, - 'life-tags': { table: 'life_tags', entityType: 'life-tags' } + 'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true } }; const sortableContentDefinitions: Record = { @@ -684,6 +685,11 @@ function optionSelect( return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`); } +function lifeCategoryOptions(locale: string): Promise> { + const name = localizedName('life-tags', 'lc', locale); + return query(`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`); +} + function skillOptions(locale: string): Promise> { const name = localizedName('skills', 's', locale); return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`); @@ -718,9 +724,14 @@ function configOrder(): string { function configSelect(definition: ConfigDefinition, locale: string): string { const name = localizedName(definition.entityType, 'c', locale); const translations = translationsSelect(definition.entityType, 'c.id'); - return definition.hasItemDrop - ? `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations, c.has_item_drop AS "hasItemDrop"` - : `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations`; + const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`]; + if (definition.hasItemDrop) { + columns.push(`c.has_item_drop AS "hasItemDrop"`); + } + if (definition.hasDefault) { + columns.push(`c.is_default AS "isDefault"`); + } + return columns.join(', '); } function validationError(message: string): ValidationError { @@ -2045,7 +2056,7 @@ export async function getOptions(locale = defaultLocale) { itemUsages, acquisitionMethods, maps, - lifeTags + lifeCategories ] = await Promise.all([ optionSelect('pokemon_types', 'pokemon-types', locale), skillOptions(locale), @@ -2055,7 +2066,7 @@ export async function getOptions(locale = defaultLocale) { optionSelect('item_usages', 'item-usages', locale), optionSelect('acquisition_methods', 'acquisition-methods', locale), optionSelect('maps', 'maps', locale), - optionSelect('life_tags', 'life-tags', locale) + lifeCategoryOptions(locale) ]); return { @@ -2068,7 +2079,7 @@ export async function getOptions(locale = defaultLocale) { acquisitionMethods, itemTags: favoriteThings, maps, - lifeTags + lifeCategories }; } @@ -2209,14 +2220,11 @@ function cleanLifePostPayload(payload: Record): LifePostPayload if (body.length > 2000) { throw validationError('server.validation.postTooLong'); } - const tagIds = cleanIds(payload.tagIds); - if (tagIds.length === 0) { - throw validationError('server.validation.lifeTagRequired'); - } + const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired'); return { body, - tagIds, + categoryId, languageCode: cleanModerationLanguageCode(payload.languageCode) }; } @@ -2313,7 +2321,7 @@ function addModerationLanguageCondition( } function lifePostProjection(locale = defaultLocale): string { - const tagName = localizedName('life-tags', 'lt', locale); + const categoryName = localizedName('life-tags', 'lc', locale); return ` SELECT @@ -2332,13 +2340,12 @@ function lifePostProjection(locale = defaultLocale): string { WHEN updated_user.id IS NULL THEN NULL ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name) 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 + CASE + WHEN lc.id IS NULL THEN NULL + ELSE json_build_object('id', lc.id, 'name', ${categoryName}) + END AS category FROM life_posts lp + LEFT JOIN life_tags lc ON lc.id = lp.category_id 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 `; @@ -2447,7 +2454,7 @@ function hydrateLifePost( updatedAt: post.updatedAt, author: post.author, updatedBy: post.updatedBy, - tags: post.tags, + category: post.category, commentPreview: commentPreviewByPost.get(post.id) ?? [], commentCount: commentCountsByPost.get(post.id) ?? 0, reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), @@ -2759,7 +2766,7 @@ async function listLifePostsWithFilters( const cursor = decodeLifePostCursor(paramsQuery.cursor); const limit = cleanLifePostLimit(paramsQuery.limit); const search = asString(paramsQuery.search)?.trim(); - const tagIdValue = asString(paramsQuery.tagId)?.trim(); + const categoryIdValue = asString(paramsQuery.categoryId)?.trim(); const languageCode = cleanModerationLanguageFilter(paramsQuery.language); const params: unknown[] = []; const conditions: string[] = ['lp.deleted_at IS NULL']; @@ -2777,15 +2784,10 @@ async function listLifePostsWithFilters( conditions.push(`lp.body ILIKE $${params.length}`); } - if (tagIdValue) { - const tagId = requirePositiveInteger(tagIdValue, 'server.validation.tagInvalid'); - 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 (categoryIdValue) { + const categoryId = requirePositiveInteger(categoryIdValue, 'server.validation.lifeCategoryInvalid'); + params.push(categoryId); + conditions.push(`lp.category_id = $${params.length}`); } if (cursor) { @@ -3224,35 +3226,40 @@ async function getLifePostById(id: number, userId: number | null = null, locale return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost); } -async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise { - await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]); - - for (const tagId of tagIds) { - await client.query( - ` - INSERT INTO life_post_tags (post_id, tag_id) - VALUES ($1, $2) - `, - [postId, tagId] - ); +async function ensureLifeCategory(client: DbClient, categoryId: number): Promise { + const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]); + if (result.rowCount === 0) { + throw validationError('server.validation.lifeCategoryInvalid'); } } +async function replaceLifePostCategoryLink(client: DbClient, postId: number, categoryId: number): Promise { + await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]); + await client.query( + ` + INSERT INTO life_post_tags (post_id, tag_id) + VALUES ($1, $2) + `, + [postId, categoryId] + ); +} + export async function createLifePost(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanLifePostPayload(payload); const id = await withTransaction(async (client) => { + await ensureLifeCategory(client, cleanPayload.categoryId); const result = await client.query<{ id: number }>( ` - INSERT INTO life_posts (body, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id) - VALUES ($1, 'reviewing', NULL, $2, $2) + INSERT INTO life_posts (body, category_id, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, 'reviewing', NULL, $3, $3) RETURNING id `, - [cleanPayload.body, userId] + [cleanPayload.body, cleanPayload.categoryId, userId] ); const createdId = result.rows[0].id; - await replaceLifePostTags(client, createdId, cleanPayload.tagIds); + await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId); return createdId; }); @@ -3270,24 +3277,26 @@ export async function updateLifePost( const cleanPayload = cleanLifePostPayload(payload); const updatedId = await withTransaction(async (client) => { + await ensureLifeCategory(client, cleanPayload.categoryId); const result = await client.query<{ id: number }>( ` UPDATE life_posts SET body = $1, + category_id = $2, ai_moderation_status = 'reviewing', ai_moderation_language_code = NULL, ai_moderation_content_hash = NULL, ai_moderation_checked_at = NULL, ai_moderation_retry_count = 0, ai_moderation_updated_at = now(), - updated_by_user_id = $2, + updated_by_user_id = $3, updated_at = now() - WHERE id = $3 - AND ($4 = true OR created_by_user_id = $2) + WHERE id = $4 + AND ($5 = true OR created_by_user_id = $3) AND deleted_at IS NULL RETURNING id `, - [cleanPayload.body, userId, id, allowAny] + [cleanPayload.body, cleanPayload.categoryId, userId, id, allowAny] ); const resultId = result.rows[0]?.id ?? null; @@ -3295,7 +3304,7 @@ export async function updateLifePost( return null; } - await replaceLifePostTags(client, resultId, cleanPayload.tagIds); + await replaceLifePostCategoryLink(client, resultId, cleanPayload.categoryId); return resultId; }); @@ -3879,26 +3888,37 @@ export async function createConfig(type: ConfigType, payload: Record { const sortOrder = await nextSortOrder(client, definition.table); - const result = definition.hasItemDrop - ? await client.query<{ id: number }>( - ` - INSERT INTO ${definition.table} (name, has_item_drop, sort_order, created_by_user_id, updated_by_user_id) - VALUES ($1, $2, $3, $4, $4) - RETURNING id - `, - [name, hasItemDrop, sortOrder, userId] - ) - : await client.query<{ id: number }>( - ` - INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id) - VALUES ($1, $2, $3, $3) - RETURNING id - `, - [name, sortOrder, userId] - ); + if (definition.hasDefault && isDefault) { + await client.query( + `UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $1, updated_at = now() WHERE is_default = true`, + [userId] + ); + } + const columns = ['name']; + const values: unknown[] = [name]; + if (definition.hasItemDrop) { + columns.push('has_item_drop'); + values.push(hasItemDrop); + } + if (definition.hasDefault) { + columns.push('is_default'); + values.push(isDefault); + } + columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id'); + values.push(sortOrder, userId, userId); + const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); + const result = await client.query<{ id: number }>( + ` + INSERT INTO ${definition.table} (${columns.join(', ')}) + VALUES (${placeholders}) + RETURNING id + `, + values + ); const createdId = result.rows[0].id; await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']); @@ -3934,25 +3954,37 @@ export async function updateConfig( const name = cleanName(payload.name); const translations = cleanTranslations(payload.translations, ['name']); const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; + const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false; const updated = await withTransaction(async (client) => { - const result = definition.hasItemDrop - ? await client.query( - ` - UPDATE ${definition.table} - SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now() - WHERE id = $4 - `, - [name, hasItemDrop, userId, id] - ) - : await client.query( - ` - UPDATE ${definition.table} - SET name = $1, updated_by_user_id = $2, updated_at = now() - WHERE id = $3 - `, - [name, userId, id] - ); + if (definition.hasDefault && isDefault) { + await client.query( + `UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $2, updated_at = now() WHERE id <> $1 AND is_default = true`, + [id, userId] + ); + } + + const assignments = ['name = $1']; + const values: unknown[] = [name]; + if (definition.hasItemDrop) { + values.push(hasItemDrop); + assignments.push(`has_item_drop = $${values.length}`); + } + if (definition.hasDefault) { + values.push(isDefault); + assignments.push(`is_default = $${values.length}`); + } + values.push(userId); + assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()'); + values.push(id); + const result = await client.query( + ` + UPDATE ${definition.table} + SET ${assignments.join(', ')} + WHERE id = $${values.length} + `, + values + ); if (result.rowCount === 0) { return false; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 930ee42..f2e92ee 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -37,6 +37,10 @@ export interface NamedEntity { translations?: TranslationMap; } +export interface LifeCategory extends NamedEntity { + isDefault: boolean; +} + export interface Skill extends NamedEntity { hasItemDrop: boolean; } @@ -256,7 +260,7 @@ export interface LifePost { updatedAt: string; author: UserSummary | null; updatedBy: UserSummary | null; - tags: NamedEntity[]; + category: NamedEntity | null; commentPreview: LifeComment[]; commentCount: number; reactionCounts: LifeReactionCounts; @@ -273,7 +277,7 @@ export interface LifePostsParams { cursor?: string | null; limit?: number; search?: string; - tagId?: string | number; + categoryId?: string | number; language?: string; } @@ -320,7 +324,7 @@ export interface Options { acquisitionMethods: NamedEntity[]; itemTags: NamedEntity[]; maps: NamedEntity[]; - lifeTags: NamedEntity[]; + lifeCategories: LifeCategory[]; } export interface AuthUser { @@ -558,7 +562,7 @@ export interface DailyChecklistPayload { export interface LifePostPayload { body: string; - tagIds: number[]; + categoryId: number; languageCode?: string | null; } @@ -876,7 +880,7 @@ export const api = { cursor: params.cursor ?? undefined, limit: params.limit, search: params.search?.trim(), - tagId: params.tagId, + categoryId: params.categoryId, language: params.language })}` ), @@ -945,13 +949,13 @@ export const api = { reorderDailyChecklistItems: (ids: number[]) => sendJson('/api/admin/daily-checklist/order', 'PUT', { ids }), deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`), - config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), - createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) => - sendJson(`/api/admin/config/${type}`, 'POST', payload), + config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), + createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) => + sendJson(`/api/admin/config/${type}`, 'POST', payload), reorderConfig: (type: ConfigType, ids: number[]) => - sendJson>(`/api/admin/config/${type}/order`, 'PUT', { ids }), - updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) => - sendJson(`/api/admin/config/${type}/${id}`, 'PUT', payload), + sendJson>(`/api/admin/config/${type}/order`, 'PUT', { ids }), + updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) => + sendJson(`/api/admin/config/${type}/${id}`, 'PUT', payload), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), pokemon: (params: Record) => getJson(`/api/pokemon${buildQuery(params)}`), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 8a59a2b..221aee1 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -40,6 +40,7 @@ import { type Habitat, type Item, type Language, + type LifeCategory, type NamedEntity, type Permission, type PermissionPayload, @@ -69,7 +70,7 @@ type AdminTab = type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; -type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean }; +type EditableConfig = (NamedEntity | Skill | LifeCategory) & { hasItemDrop?: boolean; isDefault?: boolean }; const adminTabIcons: Record = { users: iconProfile, @@ -132,7 +133,7 @@ const adminNavigationGroups = computed(() => { }); const tabs = computed(() => adminNavigationGroups.value.flatMap((group) => group.items)); -const configTypes = computed>(() => [ +const configTypes = computed>(() => [ { key: 'pokemon-types', label: t('config.pokemonTypes') }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true }, { key: 'environments', label: t('config.environments') }, @@ -141,7 +142,7 @@ const configTypes = computed('config'); @@ -162,7 +163,7 @@ const currentUser = ref(null); const busy = ref(false); const contentLoading = ref(false); const message = ref(''); -const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false }); +const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false, isDefault: false }); const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }); const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] }); @@ -375,7 +376,7 @@ async function loadLanguages() { } function resetConfigForm() { - configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false }; + configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false }; } function resetChecklistForm() { @@ -435,7 +436,13 @@ function closeConfigModal() { } function editConfig(item: EditableConfig) { - configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true }; + configForm.value = { + id: item.id, + name: item.baseName ?? item.name, + translations: item.translations ?? {}, + hasItemDrop: item.hasItemDrop === true, + isDefault: item.isDefault === true + }; configModalOpen.value = true; } @@ -709,7 +716,8 @@ async function saveConfig() { const payload = { name: configBaseNameForSave(), translations: configForm.value.translations, - hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined + hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, + isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined }; if (configForm.value.id) { @@ -1270,7 +1278,9 @@ onMounted(() => { >