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
This commit is contained in:
2026-05-02 09:54:00 +08:00
parent 7ee25e2437
commit b0d18a845d
12 changed files with 1101 additions and 0 deletions

View File

@@ -119,6 +119,19 @@
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。 - 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。 - 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
## 实体讨论
- Pokemon、物品、材料单、栖息地详情页支持讨论。
- 所有人都可以浏览实体讨论。
- 已注册并完成邮箱验证的用户可以发表评论,并回复顶层评论。
- 讨论回复只支持一层回复,不做无限嵌套。
- 评论作者可以删除自己的评论;删除后正文不再展示,已有回复保留在原位置。
- 被删除实体的讨论会随实体删除一并清理。
- 讨论按创建时间正序展示。
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
- API 对外只返回评论作者的 `id``displayName`
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at``deleted_by_user_id` 等内部删除字段。
## 全局配置数据 ## 全局配置数据
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。 以下配置项都支持创建、编辑、删除、翻译和拖拽排序。
@@ -241,6 +254,7 @@ Pokemon 详情页展示:
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示 - 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示
- 出现的栖息地 - 出现的栖息地
- 最后编辑信息 - 最后编辑信息
- 讨论
- 编辑历史:通过详情页 Tabs 展示 - 编辑历史:通过详情页 Tabs 展示
## 物品 ## 物品
@@ -281,6 +295,7 @@ Pokemon 详情页展示:
- 相关栖息地 - 相关栖息地
- 相关 Pokemon 掉落 - 相关 Pokemon 掉落
- 最后编辑信息 - 最后编辑信息
- 讨论
- 编辑历史 - 编辑历史
## 材料单 ## 材料单
@@ -311,6 +326,7 @@ Pokemon 详情页展示:
- 入手方式 - 入手方式
- 需要材料列表 - 需要材料列表
- 最后编辑信息 - 最后编辑信息
- 讨论
- 编辑历史 - 编辑历史
## 栖息地 ## 栖息地
@@ -352,6 +368,7 @@ Pokemon 出现配置:
- 稀有度 - 稀有度
- 出现的地图列表 - 出现的地图列表
- 最后编辑信息 - 最后编辑信息
- 讨论
- 编辑历史 - 编辑历史
## 每日 CheckList ## 每日 CheckList
@@ -471,6 +488,7 @@ API 暴露边界:
- `GET /api/recipes` - `GET /api/recipes`
- `GET /api/recipes/:id` - `GET /api/recipes/:id`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。 - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。
- `GET /api/discussions/:entityType/:entityId/comments`:读取实体讨论;`entityType` 支持 `pokemon``items``recipes``habitats`
认证 API 认证 API
@@ -491,6 +509,10 @@ API 暴露边界:
- `POST /api/life-posts/:postId/comments` - `POST /api/life-posts/:postId/comments`
- `POST /api/life-posts/:postId/comments/:commentId/replies` - `POST /api/life-posts/:postId/comments/:commentId/replies`
- `DELETE /api/life-comments/:id` - `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 的设置、替换和取消。 - Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction` - `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction` - `DELETE /api/life-posts/:id/reaction`

View File

@@ -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 CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
ON wiki_edit_logs(user_id); 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);

View File

@@ -116,6 +116,28 @@ type LifeCommentPayload = {
body: string; 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 LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type LifeReactionCounts = Record<LifeReactionType, number>; type LifeReactionCounts = Record<LifeReactionType, number>;
@@ -263,6 +285,13 @@ const sortableContentDefinitions: Record<SortableContentType, SortableContentDef
habitats: { table: 'habitats', entityType: 'habitats' } habitats: { table: 'habitats', entityType: 'habitats' }
}; };
const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntityDefinition> = {
pokemon: { table: 'pokemon' },
items: { table: 'items' },
recipes: { table: 'recipes' },
habitats: { table: 'habitats' }
};
function asString(value: QueryValue): string | undefined { function asString(value: QueryValue): string | undefined {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
@@ -1770,6 +1799,224 @@ export async function deleteLifeComment(id: number, userId: number) {
return Boolean(result); 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<string, unknown>): 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<DbClient, 'query'>,
entityType: DiscussionEntityType,
entityId: number
): Promise<boolean> {
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<number, EntityDiscussionComment>();
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<EntityDiscussionComment | null> {
const row = await queryOne<EntityDiscussionCommentRow>(
`
${entityDiscussionCommentProjection('WHERE edc.id = $1')}
`,
[id]
);
return row ? { ...row, replies: [] } : null;
}
export async function listEntityDiscussionComments(
entityTypeValue: string,
entityIdValue: number
): Promise<EntityDiscussionComment[] | null> {
const entityType = cleanDiscussionEntityType(entityTypeValue);
const entityId = requirePositiveInteger(entityIdValue, 'Record is invalid');
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
return null;
}
const rows = await query<EntityDiscussionCommentRow>(
`
${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<string, unknown>,
userId: number
): Promise<EntityDiscussionComment | null> {
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<string, unknown>,
userId: number
): Promise<EntityDiscussionComment | null> {
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<boolean> {
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<void> {
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 { export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type); return Object.hasOwn(configDefinitions, type);
} }
@@ -2355,6 +2602,7 @@ export async function deletePokemon(id: number, userId: number) {
return false; return false;
} }
await deleteEntityDiscussionCommentsForEntity(client, 'pokemon', id);
await deleteEntityTranslations(client, 'pokemon', id); await deleteEntityTranslations(client, 'pokemon', id);
await recordEditLog(client, 'pokemon', id, 'delete', userId); await recordEditLog(client, 'pokemon', id, 'delete', userId);
return true; return true;
@@ -2561,6 +2809,7 @@ export async function deleteHabitat(id: number, userId: number) {
return false; return false;
} }
await deleteEntityDiscussionCommentsForEntity(client, 'habitats', id);
await deleteEntityTranslations(client, 'habitats', id); await deleteEntityTranslations(client, 'habitats', id);
await recordEditLog(client, 'habitats', id, 'delete', userId); await recordEditLog(client, 'habitats', id, 'delete', userId);
return true; return true;
@@ -2915,6 +3164,7 @@ export async function deleteItem(id: number, userId: number) {
return false; return false;
} }
await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
await deleteEntityTranslations(client, 'items', id); await deleteEntityTranslations(client, 'items', id);
await recordEditLog(client, 'items', id, 'delete', userId); await recordEditLog(client, 'items', id, 'delete', userId);
return true; return true;
@@ -3082,6 +3332,7 @@ export async function deleteRecipe(id: number, userId: number) {
return false; return false;
} }
await deleteEntityDiscussionCommentsForEntity(client, 'recipes', id);
await recordEditLog(client, 'recipes', id, 'delete', userId); await recordEditLog(client, 'recipes', id, 'delete', userId);
return true; return true;
}); });

View File

@@ -7,6 +7,8 @@ import {
cleanLocale, cleanLocale,
createConfig, createConfig,
createDailyChecklistItem, createDailyChecklistItem,
createEntityDiscussionComment,
createEntityDiscussionReply,
createHabitat, createHabitat,
createItem, createItem,
createLanguage, createLanguage,
@@ -17,6 +19,7 @@ import {
createRecipe, createRecipe,
deleteConfig, deleteConfig,
deleteDailyChecklistItem, deleteDailyChecklistItem,
deleteEntityDiscussionComment,
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage, deleteLanguage,
@@ -31,6 +34,7 @@ import {
getPokemon, getPokemon,
getRecipe, getRecipe,
isConfigType, isConfigType,
listEntityDiscussionComments,
listConfig, listConfig,
listDailyChecklistItems, listDailyChecklistItems,
listHabitats, 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' }); 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<string, unknown>,
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<string, unknown>,
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) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );

View File

@@ -0,0 +1,418 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconCancel, iconComment, iconDelete, iconReply } from '../icons';
import {
api,
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AuthUser,
type DiscussionEntityType,
type EntityDiscussionComment
} from '../services/api';
import Skeleton from './Skeleton.vue';
const props = defineProps<{
entityType: DiscussionEntityType;
entityId: string | number;
}>();
const { locale, t } = useI18n();
const comments = ref<EntityDiscussionComment[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const authReady = ref(false);
const body = ref('');
const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null);
const busyKey = ref('');
const loadError = ref('');
const formError = ref('');
const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null);
const commentMaxLength = 1000;
let requestId = 0;
let removeAuthListener: (() => void) | null = null;
const canComment = computed(() => currentUser.value?.emailVerified === true);
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
}
async function loadDiscussion() {
const nextRequestId = ++requestId;
loading.value = true;
loadError.value = '';
try {
const rows = await api.entityDiscussion(props.entityType, props.entityId);
if (nextRequestId === requestId) {
comments.value = rows;
}
} catch (error) {
if (nextRequestId === requestId) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
} finally {
if (nextRequestId === requestId) {
loading.value = false;
}
}
}
function resetComposer() {
body.value = '';
replyBodies.value = {};
replyTargetId.value = null;
formError.value = '';
commentErrors.value = {};
}
function commentKey(commentId: number) {
return `comment-${commentId}`;
}
function replyBody(commentId: number) {
return replyBodies.value[commentId] ?? '';
}
function setCommentError(key: string, message: string) {
commentErrors.value = { ...commentErrors.value, [key]: message };
}
function clearCommentError(key: string) {
const nextErrors = { ...commentErrors.value };
delete nextErrors[key];
commentErrors.value = nextErrors;
}
function canManageComment(comment: EntityDiscussionComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
}
function commentAuthorName(comment: EntityDiscussionComment) {
return comment.deleted ? t('discussion.deletedComment') : comment.author?.displayName ?? t('discussion.byUnknown');
}
function commentInitial(comment: EntityDiscussionComment) {
return commentAuthorName(comment).slice(0, 1).toUpperCase();
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
function isBusy(key: string) {
return busyKey.value === key;
}
function startReply(comment: EntityDiscussionComment) {
replyTargetId.value = comment.id;
clearCommentError(commentKey(comment.id));
}
function cancelReply(commentId: number) {
replyTargetId.value = null;
replyBodies.value[commentId] = '';
clearCommentError(commentKey(commentId));
}
async function submitComment() {
const nextBody = body.value.trim();
if (!nextBody) {
formError.value = t('discussion.commentRequired');
commentInput.value?.focus();
return;
}
busyKey.value = 'new-comment';
formError.value = '';
try {
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
comments.value = [...comments.value, comment];
body.value = '';
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
} finally {
busyKey.value = '';
}
}
async function submitReply(comment: EntityDiscussionComment) {
const key = commentKey(comment.id);
const nextBody = replyBody(comment.id).trim();
if (!nextBody) {
setCommentError(key, t('discussion.commentRequired'));
return;
}
busyKey.value = key;
clearCommentError(key);
try {
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
comment.replies.push(reply);
cancelReply(comment.id);
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
} finally {
busyKey.value = '';
}
}
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
for (const comment of rows) {
if (comment.id === id) {
comment.deleted = true;
comment.body = '';
comment.author = null;
return true;
}
if (markCommentDeleted(comment.replies, id)) {
return true;
}
}
return false;
}
async function deleteComment(comment: EntityDiscussionComment) {
if (!window.confirm(t('discussion.deleteConfirm'))) {
return;
}
const key = commentKey(comment.id);
clearCommentError(key);
try {
await api.deleteEntityDiscussionComment(comment.id);
markCommentDeleted(comments.value, comment.id);
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.deleteFailed'));
}
}
watch(
() => [props.entityType, props.entityId],
() => {
resetComposer();
comments.value = [];
void loadDiscussion();
}
);
onMounted(() => {
void loadCurrentUser();
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
});
onUnmounted(() => {
removeAuthListener?.();
});
</script>
<template>
<section class="entity-discussion-panel" aria-labelledby="entity-discussion-title">
<div class="entity-discussion-panel__header">
<div>
<h2 id="entity-discussion-title">{{ t('discussion.title') }}</h2>
<p>{{ t('discussion.count', { count: commentTotal }) }}</p>
</div>
</div>
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
</div>
<form v-else-if="canComment" class="entity-discussion-form" @submit.prevent="submitComment">
<div class="field">
<label :for="`entity-discussion-comment-${props.entityType}-${props.entityId}`">{{ t('discussion.comment') }}</label>
<textarea
:id="`entity-discussion-comment-${props.entityType}-${props.entityId}`"
ref="commentInput"
v-model="body"
:maxlength="commentMaxLength"
:placeholder="t('discussion.commentPlaceholder')"
></textarea>
<span class="entity-discussion-form__counter">{{ t('discussion.charactersLeft', { count: charactersLeft }) }}</span>
</div>
<p v-if="formError" class="entity-discussion-form__error" role="alert">{{ formError }}</p>
<button class="ui-button ui-button--primary ui-button--small" :disabled="isBusy('new-comment') || !body.trim()" type="submit">
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ isBusy('new-comment') ? t('discussion.postingComment') : t('discussion.postComment') }}
</button>
</form>
<div v-else class="entity-discussion-auth-note">
<p>{{ currentUser ? t('discussion.verifyPrompt') : t('discussion.loginPrompt') }}</p>
<RouterLink v-if="!currentUser" class="ui-button ui-button--primary ui-button--small" :to="{ path: '/login', query: { redirect: $route.fullPath } }">
{{ t('nav.login') }}
</RouterLink>
</div>
<div v-if="loading" class="entity-discussion-list" :aria-label="t('discussion.loading')">
<article v-for="index in 3" :key="index" class="entity-discussion-comment entity-discussion-comment--skeleton">
<Skeleton variant="box" width="40px" height="40px" />
<div class="entity-discussion-comment__content">
<Skeleton width="148px" />
<Skeleton width="88%" />
<Skeleton width="62%" />
</div>
</article>
</div>
<p v-else-if="loadError" class="entity-discussion-form__error" role="alert">{{ loadError }}</p>
<div v-else-if="comments.length" class="entity-discussion-list">
<article
v-for="comment in comments"
:key="comment.id"
class="entity-discussion-comment"
:class="{ 'is-deleted': comment.deleted }"
>
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="entity-discussion-comment__content">
<div class="entity-discussion-comment__meta">
<strong>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
</div>
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
<div v-if="!comment.deleted" class="entity-discussion-comment__actions">
<button
v-if="canComment"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.reply')"
@click="startReply(comment)"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
</button>
<button
v-if="canManageComment(comment)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(comment)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(comment.id)] }}
</p>
<form
v-if="canComment && replyTargetId === comment.id"
class="entity-discussion-form entity-discussion-form--reply"
@submit.prevent="submitReply(comment)"
>
<div class="field">
<label :for="`entity-discussion-reply-${comment.id}`">{{ t('discussion.reply') }}</label>
<textarea
:id="`entity-discussion-reply-${comment.id}`"
v-model="replyBodies[comment.id]"
:maxlength="commentMaxLength"
:placeholder="t('discussion.replyPlaceholder')"
></textarea>
</div>
<div class="entity-discussion-form__actions">
<button
class="ui-button ui-button--ghost ui-button--small"
:disabled="isBusy(commentKey(comment.id)) || !replyBody(comment.id).trim()"
type="submit"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
{{ isBusy(commentKey(comment.id)) ? t('discussion.postingReply') : t('discussion.postReply') }}
</button>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="cancelReply(comment.id)">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('discussion.cancelReply') }}
</button>
</div>
</form>
<div v-if="comment.replies.length" class="entity-discussion-replies">
<article
v-for="reply in comment.replies"
:key="reply.id"
class="entity-discussion-comment entity-discussion-comment--reply"
:class="{ 'is-deleted': reply.deleted }"
>
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="entity-discussion-comment__content">
<div class="entity-discussion-comment__meta">
<strong>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
</div>
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(reply)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(reply.id)] }}
</p>
</div>
</article>
</div>
</div>
</article>
</div>
<div v-else class="entity-discussion-empty">
<Icon :icon="iconComment" class="entity-discussion-empty__icon" aria-hidden="true" />
<div>
<h3>{{ t('discussion.empty') }}</h3>
<p>{{ t('discussion.emptyHint') }}</p>
</div>
</div>
</section>
</template>

View File

@@ -437,6 +437,33 @@ const messages = {
update: 'Edit', update: 'Edit',
delete: 'Delete', delete: 'Delete',
empty: 'No edit history' 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': { 'zh-CN': {
@@ -871,6 +898,33 @@ const messages = {
update: '编辑', update: '编辑',
delete: '删除', delete: '删除',
empty: '暂无编辑历史' 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} 个字符'
} }
} }
}; };

View File

@@ -336,6 +336,25 @@ export interface LifeCommentPayload {
body: string; 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, string | number | undefined>): string { export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams(); const search = new URLSearchParams();
@@ -499,6 +518,20 @@ export const api = {
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) => createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload), sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`), deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
createEntityDiscussionComment: (
entityType: DiscussionEntityType,
entityId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments`, 'POST', payload),
createEntityDiscussionReply: (
entityType: DiscussionEntityType,
entityId: string | number,
commentId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
createDailyChecklistItem: (payload: DailyChecklistPayload) => createDailyChecklistItem: (payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload), sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>

View File

@@ -2664,6 +2664,216 @@ button:disabled,
overflow-wrap: anywhere; 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 { .row-list {
display: grid; display: grid;
gap: 0; gap: 0;

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
@@ -22,6 +23,7 @@ const weathers = ['晴天', '阴天', '雨天'];
const showEditor = computed(() => route.name === 'habitat-edit'); const showEditor = computed(() => route.name === 'habitat-edit');
const detailTabs = computed<TabOption[]>(() => [ const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') }, { value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
@@ -229,6 +231,10 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="habitats" :entity-id="habitat.id" />
</div>
<div v-else class="detail-tab-panel"> <div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" /> <EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
@@ -20,6 +21,7 @@ const detailTab = ref('details');
const showEditor = computed(() => route.name === 'item-edit'); const showEditor = computed(() => route.name === 'item-edit');
const detailTabs = computed<TabOption[]>(() => [ const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') }, { value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
@@ -195,6 +197,10 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />
</div>
<div v-else class="detail-tab-panel"> <div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="item" :history="item.editHistory" /> <EditHistoryPanel :entity="item" :history="item.editHistory" />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import PokemonStatsPanel from '../components/PokemonStatsPanel.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 showEditor = computed(() => route.name === 'pokemon-edit');
const detailTabs = computed<TabOption[]>(() => [ const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') }, { value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
const itemCategoryTabs = computed<TabOption[]>(() => { const itemCategoryTabs = computed<TabOption[]>(() => {
@@ -444,6 +446,10 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" />
</div>
<div v-else class="detail-tab-panel"> <div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" /> <EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
@@ -20,6 +21,7 @@ const detailTab = ref('details');
const showEditor = computed(() => route.name === 'recipe-edit'); const showEditor = computed(() => route.name === 'recipe-edit');
const detailTabs = computed<TabOption[]>(() => [ const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') }, { value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
@@ -105,6 +107,10 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="recipes" :entity-id="recipe.id" />
</div>
<div v-else class="detail-tab-panel"> <div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" /> <EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
</div> </div>