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:
418
frontend/src/components/EntityDiscussionPanel.vue
Normal file
418
frontend/src/components/EntityDiscussionPanel.vue
Normal 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>
|
||||
@@ -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} 个字符'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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, string | number | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
@@ -499,6 +518,20 @@ export const api = {
|
||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||
sendJson<LifeComment>(`/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<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) =>
|
||||
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
|
||||
@@ -229,6 +231,10 @@ watch(
|
||||
</DetailSection>
|
||||
</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">
|
||||
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
||||
</div>
|
||||
|
||||
@@ -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<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
|
||||
@@ -195,6 +197,10 @@ watch(
|
||||
</DetailSection>
|
||||
</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">
|
||||
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
||||
</div>
|
||||
|
||||
@@ -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<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
const itemCategoryTabs = computed<TabOption[]>(() => {
|
||||
@@ -444,6 +446,10 @@ watch(
|
||||
</DetailSection>
|
||||
</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">
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
</div>
|
||||
|
||||
@@ -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<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
|
||||
@@ -105,6 +107,10 @@ watch(
|
||||
</DetailSection>
|
||||
</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">
|
||||
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user