From b0d18a845d3f1f5c0d3d638bdf48bfb9fbbb3069 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 09:54:00 +0800 Subject: [PATCH] feat(discussion): add discussion feature for game entities Create entity_discussion_comments table and API endpoints Add discussion tabs to Pokemon, Item, Recipe, and Habitat detail views Support top-level comments, single-level replies, and deletion --- DESIGN.md | 22 + backend/db/schema.sql | 31 ++ backend/src/queries.ts | 251 +++++++++++ backend/src/server.ts | 58 +++ .../src/components/EntityDiscussionPanel.vue | 418 ++++++++++++++++++ frontend/src/i18n.ts | 54 +++ frontend/src/services/api.ts | 33 ++ frontend/src/styles/main.css | 210 +++++++++ frontend/src/views/HabitatDetail.vue | 6 + frontend/src/views/ItemDetail.vue | 6 + frontend/src/views/PokemonDetail.vue | 6 + frontend/src/views/RecipeDetail.vue | 6 + 12 files changed, 1101 insertions(+) create mode 100644 frontend/src/components/EntityDiscussionPanel.vue diff --git a/DESIGN.md b/DESIGN.md index 44874d3..8ba3e20 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,6 +119,19 @@ - 详情页展示最后编辑者、最后编辑时间和编辑历史面板。 - 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。 +## 实体讨论 + +- Pokemon、物品、材料单、栖息地详情页支持讨论。 +- 所有人都可以浏览实体讨论。 +- 已注册并完成邮箱验证的用户可以发表评论,并回复顶层评论。 +- 讨论回复只支持一层回复,不做无限嵌套。 +- 评论作者可以删除自己的评论;删除后正文不再展示,已有回复保留在原位置。 +- 被删除实体的讨论会随实体删除一并清理。 +- 讨论按创建时间正序展示。 +- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 +- API 对外只返回评论作者的 `id` 和 `displayName`。 +- API 不返回邮箱、token/hash、内部调试字段、`deleted_at`、`deleted_by_user_id` 等内部删除字段。 + ## 全局配置数据 以下配置项都支持创建、编辑、删除、翻译和拖拽排序。 @@ -241,6 +254,7 @@ Pokemon 详情页展示: - 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示 - 出现的栖息地 - 最后编辑信息 +- 讨论 - 编辑历史:通过详情页 Tabs 展示 ## 物品 @@ -281,6 +295,7 @@ Pokemon 详情页展示: - 相关栖息地 - 相关 Pokemon 掉落 - 最后编辑信息 +- 讨论 - 编辑历史 ## 材料单 @@ -311,6 +326,7 @@ Pokemon 详情页展示: - 入手方式 - 需要材料列表 - 最后编辑信息 +- 讨论 - 编辑历史 ## 栖息地 @@ -352,6 +368,7 @@ Pokemon 出现配置: - 稀有度 - 出现的地图列表 - 最后编辑信息 +- 讨论 - 编辑历史 ## 每日 CheckList @@ -471,6 +488,7 @@ API 暴露边界: - `GET /api/recipes` - `GET /api/recipes/:id` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。 +- `GET /api/discussions/:entityType/:entityId/comments`:读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。 认证 API: @@ -491,6 +509,10 @@ API 暴露边界: - `POST /api/life-posts/:postId/comments` - `POST /api/life-posts/:postId/comments/:commentId/replies` - `DELETE /api/life-comments/:id` +- 实体讨论评论的创建、回复,以及作者本人对评论的删除。 + - `POST /api/discussions/:entityType/:entityId/comments` + - `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies` + - `DELETE /api/discussions/comments/:id` - Life Reaction 的设置、替换和取消。 - `PUT /api/life-posts/:id/reaction` - `DELETE /api/life-posts/:id/reaction` diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 9fddcf1..5b01cac 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -639,3 +639,34 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx ON wiki_edit_logs(user_id); + +CREATE TABLE IF NOT EXISTS entity_discussion_comments ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')), + entity_id integer NOT NULL, + parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE, + body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE entity_discussion_comments DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check; +ALTER TABLE entity_discussion_comments ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK ( + entity_type IN ('pokemon', 'items', 'recipes', 'habitats') +); + +ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_at timestamptz; +ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +CREATE INDEX IF NOT EXISTS entity_discussion_comments_entity_idx + ON entity_discussion_comments(entity_type, entity_id, created_at, id); + +CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx + ON entity_discussion_comments(parent_comment_id, created_at, id); + +CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx + ON entity_discussion_comments(created_by_user_id); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 76edd5b..95b11cf 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -116,6 +116,28 @@ type LifeCommentPayload = { body: string; }; +type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats'; +type DiscussionEntityDefinition = { + table: string; +}; +type EntityDiscussionCommentPayload = { + body: string; +}; +type EntityDiscussionCommentRow = { + id: number; + entityType: DiscussionEntityType; + entityId: number; + parentCommentId: number | null; + body: string; + deleted: boolean; + createdAt: Date; + updatedAt: Date; + author: { id: number; displayName: string } | null; +}; +type EntityDiscussionComment = EntityDiscussionCommentRow & { + replies: EntityDiscussionComment[]; +}; + type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type LifeReactionCounts = Record; @@ -263,6 +285,13 @@ const sortableContentDefinitions: Record = { + pokemon: { table: 'pokemon' }, + items: { table: 'items' }, + recipes: { table: 'recipes' }, + habitats: { table: 'habitats' } +}; + function asString(value: QueryValue): string | undefined { return Array.isArray(value) ? value[0] : value; } @@ -1770,6 +1799,224 @@ export async function deleteLifeComment(id: number, userId: number) { return Boolean(result); } +function cleanDiscussionEntityType(value: unknown): DiscussionEntityType { + if (typeof value !== 'string' || !Object.hasOwn(discussionEntityDefinitions, value)) { + throw validationError('Entity type is invalid'); + } + + return value as DiscussionEntityType; +} + +function cleanEntityDiscussionCommentPayload(payload: Record): EntityDiscussionCommentPayload { + const body = cleanName(payload.body, 'Please enter a comment'); + if (body.length > 1000) { + throw validationError('Comment is too long'); + } + + return { body }; +} + +async function entityDiscussionExists( + client: Pick, + entityType: DiscussionEntityType, + entityId: number +): Promise { + const definition = discussionEntityDefinitions[entityType]; + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS (SELECT 1 FROM ${definition.table} WHERE id = $1) AS exists`, + [entityId] + ); + + return result.rows[0]?.exists === true; +} + +function entityDiscussionCommentProjection(whereClause: string): string { + return ` + SELECT + edc.id, + edc.entity_type AS "entityType", + edc.entity_id AS "entityId", + edc.parent_comment_id AS "parentCommentId", + CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body, + edc.deleted_at IS NOT NULL AS deleted, + edc.created_at AS "createdAt", + edc.updated_at AS "updatedAt", + CASE + WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL + ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) + END AS author + FROM entity_discussion_comments edc + LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id + ${whereClause} + `; +} + +function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): EntityDiscussionComment[] { + const comments = new Map(); + const topLevelComments: EntityDiscussionComment[] = []; + + for (const row of rows) { + comments.set(row.id, { ...row, replies: [] }); + } + + for (const comment of comments.values()) { + if (comment.parentCommentId === null) { + topLevelComments.push(comment); + continue; + } + + const parent = comments.get(comment.parentCommentId); + if (parent?.parentCommentId === null) { + parent.replies.push(comment); + } else { + topLevelComments.push(comment); + } + } + + return topLevelComments; +} + +async function getEntityDiscussionCommentById(id: number): Promise { + const row = await queryOne( + ` + ${entityDiscussionCommentProjection('WHERE edc.id = $1')} + `, + [id] + ); + + return row ? { ...row, replies: [] } : null; +} + +export async function listEntityDiscussionComments( + entityTypeValue: string, + entityIdValue: number +): Promise { + const entityType = cleanDiscussionEntityType(entityTypeValue); + const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid'); + + if (!(await entityDiscussionExists(pool, entityType, entityId))) { + return null; + } + + const rows = await query( + ` + ${entityDiscussionCommentProjection('WHERE edc.entity_type = $1 AND edc.entity_id = $2')} + ORDER BY edc.created_at, edc.id + `, + [entityType, entityId] + ); + + return buildEntityDiscussionCommentTree(rows); +} + +export async function createEntityDiscussionComment( + entityTypeValue: string, + entityIdValue: number, + payload: Record, + userId: number +): Promise { + const entityType = cleanDiscussionEntityType(entityTypeValue); + const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid'); + const cleanPayload = cleanEntityDiscussionCommentPayload(payload); + + const id = await withTransaction(async (client) => { + if (!(await entityDiscussionExists(client, entityType, entityId))) { + return null; + } + + const result = await client.query<{ id: number }>( + ` + INSERT INTO entity_discussion_comments (entity_type, entity_id, body, created_by_user_id) + VALUES ($1, $2, $3, $4) + RETURNING id + `, + [entityType, entityId, cleanPayload.body, userId] + ); + + return result.rows[0].id; + }); + + return id ? getEntityDiscussionCommentById(id) : null; +} + +export async function createEntityDiscussionReply( + entityTypeValue: string, + entityIdValue: number, + commentIdValue: number, + payload: Record, + userId: number +): Promise { + const entityType = cleanDiscussionEntityType(entityTypeValue); + const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid'); + const commentId = requirePositiveInteger(commentIdValue, 'Comment is invalid'); + const cleanPayload = cleanEntityDiscussionCommentPayload(payload); + + const id = await withTransaction(async (client) => { + if (!(await entityDiscussionExists(client, entityType, entityId))) { + return null; + } + + const result = await client.query<{ id: number }>( + ` + INSERT INTO entity_discussion_comments ( + entity_type, + entity_id, + parent_comment_id, + body, + created_by_user_id + ) + SELECT edc.entity_type, edc.entity_id, edc.id, $4, $5 + FROM entity_discussion_comments edc + WHERE edc.entity_type = $1 + AND edc.entity_id = $2 + AND edc.id = $3 + AND edc.parent_comment_id IS NULL + AND edc.deleted_at IS NULL + RETURNING id + `, + [entityType, entityId, commentId, cleanPayload.body, userId] + ); + + return result.rows[0]?.id ?? null; + }); + + return id ? getEntityDiscussionCommentById(id) : null; +} + +export async function deleteEntityDiscussionComment(id: number, userId: number): Promise { + const commentId = requirePositiveInteger(id, 'Comment is invalid'); + const result = await queryOne<{ id: number }>( + ` + UPDATE entity_discussion_comments + SET deleted_at = now(), + deleted_by_user_id = $2, + updated_at = now() + WHERE id = $1 + AND created_by_user_id = $2 + AND deleted_at IS NULL + RETURNING id + `, + [commentId, userId] + ); + + return Boolean(result); +} + +async function deleteEntityDiscussionCommentsForEntity( + client: DbClient, + entityType: DiscussionEntityType, + entityId: number +): Promise { + await client.query( + ` + DELETE FROM entity_discussion_comments + WHERE entity_type = $1 + AND entity_id = $2 + `, + [entityType, entityId] + ); +} + export function isConfigType(type: string): type is ConfigType { return Object.hasOwn(configDefinitions, type); } @@ -2355,6 +2602,7 @@ export async function deletePokemon(id: number, userId: number) { return false; } + await deleteEntityDiscussionCommentsForEntity(client, 'pokemon', id); await deleteEntityTranslations(client, 'pokemon', id); await recordEditLog(client, 'pokemon', id, 'delete', userId); return true; @@ -2561,6 +2809,7 @@ export async function deleteHabitat(id: number, userId: number) { return false; } + await deleteEntityDiscussionCommentsForEntity(client, 'habitats', id); await deleteEntityTranslations(client, 'habitats', id); await recordEditLog(client, 'habitats', id, 'delete', userId); return true; @@ -2915,6 +3164,7 @@ export async function deleteItem(id: number, userId: number) { return false; } + await deleteEntityDiscussionCommentsForEntity(client, 'items', id); await deleteEntityTranslations(client, 'items', id); await recordEditLog(client, 'items', id, 'delete', userId); return true; @@ -3082,6 +3332,7 @@ export async function deleteRecipe(id: number, userId: number) { return false; } + await deleteEntityDiscussionCommentsForEntity(client, 'recipes', id); await recordEditLog(client, 'recipes', id, 'delete', userId); return true; }); diff --git a/backend/src/server.ts b/backend/src/server.ts index ce09265..7f7d331 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,8 @@ import { cleanLocale, createConfig, createDailyChecklistItem, + createEntityDiscussionComment, + createEntityDiscussionReply, createHabitat, createItem, createLanguage, @@ -17,6 +19,7 @@ import { createRecipe, deleteConfig, deleteDailyChecklistItem, + deleteEntityDiscussionComment, deleteHabitat, deleteItem, deleteLanguage, @@ -31,6 +34,7 @@ import { getPokemon, getRecipe, isConfigType, + listEntityDiscussionComments, listConfig, listDailyChecklistItems, listHabitats, @@ -280,6 +284,60 @@ app.delete('/api/life-comments/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); +app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => { + const { entityType, entityId } = request.params as { entityType: string; entityId: string }; + const comments = await listEntityDiscussionComments(entityType, Number(entityId)); + return comments ? comments : reply.code(404).send({ message: 'Not found' }); +}); + +app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + + const { entityType, entityId } = request.params as { entityType: string; entityId: string }; + const comment = await createEntityDiscussionComment( + entityType, + Number(entityId), + request.body as Record, + user.id + ); + return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' }); +}); + +app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + + const { entityType, entityId, commentId } = request.params as { + entityType: string; + entityId: string; + commentId: string; + }; + const comment = await createEntityDiscussionReply( + entityType, + Number(entityId), + Number(commentId), + request.body as Record, + user.id + ); + return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' }); +}); + +app.delete('/api/discussions/comments/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + + const { id } = request.params as { id: string }; + const deleted = await deleteEntityDiscussionComment(Number(id), user.id); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/pokemon', async (request) => listPokemon(request.query as Record, requestLocale(request)) ); diff --git a/frontend/src/components/EntityDiscussionPanel.vue b/frontend/src/components/EntityDiscussionPanel.vue new file mode 100644 index 0000000..b715903 --- /dev/null +++ b/frontend/src/components/EntityDiscussionPanel.vue @@ -0,0 +1,418 @@ + + + diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 0893304..a9c1cd7 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -437,6 +437,33 @@ const messages = { update: 'Edit', delete: 'Delete', empty: 'No edit history' + }, + discussion: { + title: 'Discussion', + count: '{count} comments', + comment: 'Comment', + commentPlaceholder: 'Write a comment...', + replyPlaceholder: 'Write a reply...', + postComment: 'Post comment', + postingComment: 'Posting comment', + reply: 'Reply', + postReply: 'Post reply', + postingReply: 'Posting reply', + cancelReply: 'Cancel reply', + deleteComment: 'Delete comment', + deleteConfirm: 'Delete this comment?', + deletedComment: 'Comment deleted', + commentRequired: 'Please enter a comment.', + commentFailed: 'Comment failed', + replyFailed: 'Reply failed', + deleteFailed: 'Delete failed', + loading: 'Loading discussion', + empty: 'No discussion yet', + emptyHint: 'Start a new discussion now.', + loginPrompt: 'Log in with a verified email to comment.', + verifyPrompt: 'Complete email verification to comment.', + byUnknown: 'Community member', + charactersLeft: '{count} characters left' } }, 'zh-CN': { @@ -871,6 +898,33 @@ const messages = { update: '编辑', delete: '删除', empty: '暂无编辑历史' + }, + discussion: { + title: '讨论', + count: '{count} 条评论', + comment: '评论', + commentPlaceholder: '写下评论……', + replyPlaceholder: '写下回复……', + postComment: '发表评论', + postingComment: '评论中', + reply: '回复', + postReply: '发布回复', + postingReply: '回复中', + cancelReply: '取消回复', + deleteComment: '删除评论', + deleteConfirm: '确认删除这条评论?', + deletedComment: '评论已删除', + commentRequired: '请输入评论内容。', + commentFailed: '评论失败', + replyFailed: '回复失败', + deleteFailed: '删除失败', + loading: '正在加载讨论', + empty: '暂无讨论', + emptyHint: '现在发起新的讨论。', + loginPrompt: '使用已验证邮箱登录后即可评论。', + verifyPrompt: '完成邮箱验证后即可评论。', + byUnknown: '社区成员', + charactersLeft: '还可以输入 {count} 个字符' } } }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a7a4927..9754038 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -336,6 +336,25 @@ export interface LifeCommentPayload { body: string; } +export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats'; + +export interface EntityDiscussionComment { + id: number; + entityType: DiscussionEntityType; + entityId: number; + parentCommentId: number | null; + body: string; + deleted: boolean; + createdAt: string; + updatedAt: string; + author: UserSummary | null; + replies: EntityDiscussionComment[]; +} + +export interface EntityDiscussionCommentPayload { + body: string; +} + export function buildQuery(params: Record): string { const search = new URLSearchParams(); @@ -499,6 +518,20 @@ export const api = { createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) => sendJson(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload), deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`), + entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) => + getJson(`/api/discussions/${entityType}/${entityId}/comments`), + createEntityDiscussionComment: ( + entityType: DiscussionEntityType, + entityId: string | number, + payload: EntityDiscussionCommentPayload + ) => sendJson(`/api/discussions/${entityType}/${entityId}/comments`, 'POST', payload), + createEntityDiscussionReply: ( + entityType: DiscussionEntityType, + entityId: string | number, + commentId: string | number, + payload: EntityDiscussionCommentPayload + ) => sendJson(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload), + deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`), createDailyChecklistItem: (payload: DailyChecklistPayload) => sendJson('/api/admin/daily-checklist', 'POST', payload), updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 229d7ce..a06ef2e 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -2664,6 +2664,216 @@ button:disabled, overflow-wrap: anywhere; } +.entity-discussion-panel { + display: grid; + gap: 16px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.entity-discussion-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.entity-discussion-panel__header h2, +.entity-discussion-empty h3 { + margin: 0; + color: var(--ink); + font-family: var(--font-display); + font-weight: 950; + line-height: 1.12; +} + +.entity-discussion-panel__header h2 { + font-size: 21px; +} + +.entity-discussion-panel__header p, +.entity-discussion-empty p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.entity-discussion-skeleton, +.entity-discussion-form, +.entity-discussion-list { + display: grid; + gap: 12px; +} + +.entity-discussion-form textarea { + min-height: 106px; + resize: vertical; +} + +.entity-discussion-form--reply { + margin-top: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.entity-discussion-form__counter { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.entity-discussion-form__error { + margin: 0; + color: var(--danger); + font-size: 13px; + font-weight: 850; +} + +.entity-discussion-form__actions, +.entity-discussion-auth-note { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.entity-discussion-auth-note { + justify-content: space-between; + padding: 12px; + border: 1px dashed var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.entity-discussion-auth-note p { + margin: 0; + color: var(--ink-soft); + font-size: 14px; + font-weight: 800; +} + +.entity-discussion-comment { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 10px; + min-width: 0; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} + +.entity-discussion-comment:last-child { + border-bottom: 0; +} + +.entity-discussion-comment--skeleton { + align-items: start; +} + +.entity-discussion-comment__avatar { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border: 2px solid var(--line-strong); + border-radius: 50%; + background: var(--pokemon-blue); + box-shadow: 0 2px 0 var(--line-strong); + color: #ffffff; + font-size: 14px; + font-weight: 950; +} + +.entity-discussion-comment.is-deleted .entity-discussion-comment__avatar { + background: var(--muted); +} + +.entity-discussion-comment__content { + min-width: 0; + display: grid; + gap: 7px; +} + +.entity-discussion-comment__meta { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.entity-discussion-comment__meta strong { + color: var(--ink); + font-size: 14px; + font-weight: 950; +} + +.entity-discussion-comment.is-deleted .entity-discussion-comment__meta strong { + color: var(--muted); +} + +.entity-discussion-comment__meta time { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.entity-discussion-comment__body { + margin: 0; + color: var(--ink-soft); + font-size: 15px; + font-weight: 700; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.entity-discussion-comment__actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.entity-discussion-replies { + display: grid; + gap: 0; + margin-top: 6px; + padding-left: 12px; + border-left: 2px solid var(--line); +} + +.entity-discussion-comment--reply { + grid-template-columns: 34px minmax(0, 1fr); + padding: 10px 0; +} + +.entity-discussion-comment--reply .entity-discussion-comment__avatar { + width: 34px; + height: 34px; + font-size: 12px; +} + +.entity-discussion-empty { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border: 1px dashed var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.entity-discussion-empty__icon { + width: 34px; + height: 34px; + flex: 0 0 auto; + color: var(--pokemon-blue); +} + .row-list { display: grid; gap: 0; diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index 7eff835..9316cc9 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import DetailSection from '../components/DetailSection.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue'; +import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue'; import EntityChips from '../components/EntityChips.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; @@ -22,6 +23,7 @@ const weathers = ['晴天', '阴天', '雨天']; const showEditor = computed(() => route.name === 'habitat-edit'); const detailTabs = computed(() => [ { value: 'details', label: t('common.details') }, + { value: 'discussion', label: t('discussion.title') }, { value: 'history', label: t('history.editHistory') } ]); @@ -229,6 +231,10 @@ watch( +
+ +
+
diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index ec6b6bd..7f60635 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import DetailSection from '../components/DetailSection.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue'; +import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue'; import EntityChips from '../components/EntityChips.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; @@ -20,6 +21,7 @@ const detailTab = ref('details'); const showEditor = computed(() => route.name === 'item-edit'); const detailTabs = computed(() => [ { value: 'details', label: t('common.details') }, + { value: 'discussion', label: t('discussion.title') }, { value: 'history', label: t('history.editHistory') } ]); @@ -195,6 +197,10 @@ watch( +
+ +
+
diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 15ae570..76fd0ca 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import DetailSection from '../components/DetailSection.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue'; +import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue'; import EntityChips from '../components/EntityChips.vue'; import PageHeader from '../components/PageHeader.vue'; import PokemonStatsPanel from '../components/PokemonStatsPanel.vue'; @@ -112,6 +113,7 @@ const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => ski const showEditor = computed(() => route.name === 'pokemon-edit'); const detailTabs = computed(() => [ { value: 'details', label: t('common.details') }, + { value: 'discussion', label: t('discussion.title') }, { value: 'history', label: t('history.editHistory') } ]); const itemCategoryTabs = computed(() => { @@ -444,6 +446,10 @@ watch( +
+ +
+
diff --git a/frontend/src/views/RecipeDetail.vue b/frontend/src/views/RecipeDetail.vue index 04488ae..657cd82 100644 --- a/frontend/src/views/RecipeDetail.vue +++ b/frontend/src/views/RecipeDetail.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import DetailSection from '../components/DetailSection.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue'; +import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue'; import EntityChips from '../components/EntityChips.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; @@ -20,6 +21,7 @@ const detailTab = ref('details'); const showEditor = computed(() => route.name === 'recipe-edit'); const detailTabs = computed(() => [ { value: 'details', label: t('common.details') }, + { value: 'discussion', label: t('discussion.title') }, { value: 'history', label: t('history.editHistory') } ]); @@ -105,6 +107,10 @@ watch( +
+ +
+