mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-25 07:15:36 +00:00
feat: add all comments management functionality
- Introduced a new endpoint for listing all comments accessible only to admins. - Enhanced the comments API with new query DTOs and service methods for better data handling. - Implemented a comprehensive comments management interface in the dashboard, including filters and pagination. - Added components for displaying, filtering, and managing comments, including loading states and error handling. - Updated navigation to include a link to the comments management section. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { Body, ContextParam, Controller, Delete, Get, Param, Post, Query } from
|
|||||||
import { Roles } from 'core/guards/roles.decorator'
|
import { Roles } from 'core/guards/roles.decorator'
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
import { CommentReactionDto, CreateCommentDto, ListCommentsQueryDto } from './comment.dto'
|
import { CommentReactionDto, CreateCommentDto, ListAllCommentsQueryDto, ListCommentsQueryDto } from './comment.dto'
|
||||||
import { CommentService } from './comment.service'
|
import { CommentService } from './comment.service'
|
||||||
|
|
||||||
@Controller('comments')
|
@Controller('comments')
|
||||||
@@ -20,6 +20,12 @@ export class CommentController {
|
|||||||
return await this.commentService.listComments(query)
|
return await this.commentService.listComments(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/all')
|
||||||
|
@Roles('admin')
|
||||||
|
async listAllComments(@Query() query: ListAllCommentsQueryDto) {
|
||||||
|
return await this.commentService.listAllComments(query)
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/:id/reactions')
|
@Post('/:id/reactions')
|
||||||
@Roles('user')
|
@Roles('user')
|
||||||
async react(@Param('id') commentId: string, @Body() body: CommentReactionDto) {
|
async react(@Param('id') commentId: string, @Body() body: CommentReactionDto) {
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export const ListCommentsQuerySchema = z.object({
|
|||||||
|
|
||||||
export class ListCommentsQueryDto extends createZodSchemaDto(ListCommentsQuerySchema) {}
|
export class ListCommentsQueryDto extends createZodSchemaDto(ListCommentsQuerySchema) {}
|
||||||
|
|
||||||
|
export const ListAllCommentsQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(50).default(20),
|
||||||
|
cursor: z.string().trim().min(1).optional(),
|
||||||
|
photoId: z.string().trim().min(1).optional(),
|
||||||
|
status: z.enum(['approved', 'pending', 'hidden', 'rejected']).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export class ListAllCommentsQueryDto extends createZodSchemaDto(ListAllCommentsQuerySchema) {}
|
||||||
|
|
||||||
export const CommentReactionSchema = z.object({
|
export const CommentReactionSchema = z.object({
|
||||||
reaction: z.string().trim().min(1, 'reaction is required').max(32, 'reaction too long'),
|
reaction: z.string().trim().min(1, 'reaction is required').max(32, 'reaction too long'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { and, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm'
|
|||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import { inject, injectable } from 'tsyringe'
|
import { inject, injectable } from 'tsyringe'
|
||||||
|
|
||||||
import type { CommentReactionDto, CreateCommentDto, ListCommentsQueryDto } from './comment.dto'
|
import type { CommentReactionDto, CreateCommentDto, ListAllCommentsQueryDto, ListCommentsQueryDto } from './comment.dto'
|
||||||
import type { CommentModerationHook, CommentModerationHookInput } from './comment.moderation'
|
import type { CommentModerationHook, CommentModerationHookInput } from './comment.moderation'
|
||||||
import { COMMENT_MODERATION_HOOK } from './comment.moderation'
|
import { COMMENT_MODERATION_HOOK } from './comment.moderation'
|
||||||
|
|
||||||
@@ -296,6 +296,145 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAllComments(query: ListAllCommentsQueryDto): Promise<{
|
||||||
|
comments: CommentResponseItem[]
|
||||||
|
relations: Record<string, CommentResponseItem>
|
||||||
|
users: Record<string, UserViewModel>
|
||||||
|
nextCursor: string | null
|
||||||
|
}> {
|
||||||
|
const tenant = requireTenantContext()
|
||||||
|
const viewer = this.getViewer()
|
||||||
|
const db = this.dbAccessor.get()
|
||||||
|
|
||||||
|
// Only admin can access this endpoint
|
||||||
|
if (!viewer.isAdmin) {
|
||||||
|
throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '仅管理员可以访问' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = [eq(comments.tenantId, tenant.tenant.id), isNull(comments.deletedAt)]
|
||||||
|
|
||||||
|
// Filter by photoId if provided
|
||||||
|
if (query.photoId) {
|
||||||
|
filters.push(eq(comments.photoId, query.photoId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status if provided
|
||||||
|
if (query.status) {
|
||||||
|
filters.push(eq(comments.status, query.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursorCondition
|
||||||
|
if (query.cursor) {
|
||||||
|
const anchor = await this.findCommentForCursorAll(query.cursor, tenant.tenant.id)
|
||||||
|
cursorCondition = or(
|
||||||
|
gt(comments.createdAt, anchor.createdAt),
|
||||||
|
and(eq(comments.createdAt, anchor.createdAt), gt(comments.id, anchor.id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseWhere = cursorCondition ? and(...filters, cursorCondition) : and(...filters)
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
photoId: comments.photoId,
|
||||||
|
parentId: comments.parentId,
|
||||||
|
userId: comments.userId,
|
||||||
|
content: comments.content,
|
||||||
|
status: comments.status,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
updatedAt: comments.updatedAt,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.where(baseWhere)
|
||||||
|
.orderBy(comments.createdAt, comments.id)
|
||||||
|
.limit(query.limit + 1)
|
||||||
|
|
||||||
|
const hasMore = rows.length > query.limit
|
||||||
|
const items = rows.slice(0, query.limit)
|
||||||
|
const commentIds = items.map((item) => item.id)
|
||||||
|
|
||||||
|
const reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewer.userId)
|
||||||
|
|
||||||
|
const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null
|
||||||
|
|
||||||
|
const commentItems = items.map((item) =>
|
||||||
|
this.toResponse({
|
||||||
|
...item,
|
||||||
|
reactionCounts: reactions.counts.get(item.id) ?? {},
|
||||||
|
viewerReactions: reactions.viewer.get(item.id) ?? [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build relations map (parentId -> parent comment)
|
||||||
|
const relations: Record<string, CommentResponseItem> = {}
|
||||||
|
const parentIds = [...new Set(items.filter((item) => item.parentId).map((item) => item.parentId!))]
|
||||||
|
|
||||||
|
if (parentIds.length > 0) {
|
||||||
|
const parentRows = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
photoId: comments.photoId,
|
||||||
|
parentId: comments.parentId,
|
||||||
|
userId: comments.userId,
|
||||||
|
content: comments.content,
|
||||||
|
status: comments.status,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
updatedAt: comments.updatedAt,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.where(
|
||||||
|
and(eq(comments.tenantId, tenant.tenant.id), inArray(comments.id, parentIds), isNull(comments.deletedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parentReactions = await this.fetchReactionAggregations(
|
||||||
|
tenant.tenant.id,
|
||||||
|
parentRows.map((p) => p.id),
|
||||||
|
viewer.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const parent of parentRows) {
|
||||||
|
relations[parent.id] = this.toResponse({
|
||||||
|
...parent,
|
||||||
|
reactionCounts: parentReactions.counts.get(parent.id) ?? {},
|
||||||
|
viewerReactions: parentReactions.viewer.get(parent.id) ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build users map (userId -> user)
|
||||||
|
const users: Record<string, UserViewModel> = {}
|
||||||
|
const allUserIds = [
|
||||||
|
...new Set([...items.map((item) => item.userId), ...Object.values(relations).map((r) => r.userId)]),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (allUserIds.length > 0) {
|
||||||
|
const userRows = await db
|
||||||
|
.select({
|
||||||
|
id: authUsers.id,
|
||||||
|
name: authUsers.name,
|
||||||
|
image: authUsers.image,
|
||||||
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.where(inArray(authUsers.id, allUserIds))
|
||||||
|
|
||||||
|
for (const user of userRows) {
|
||||||
|
users[user.id] = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments: commentItems,
|
||||||
|
relations,
|
||||||
|
users,
|
||||||
|
nextCursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async toggleReaction(commentId: string, body: CommentReactionDto): Promise<{ item: CommentResponseItem }> {
|
async toggleReaction(commentId: string, body: CommentReactionDto): Promise<{ item: CommentResponseItem }> {
|
||||||
const tenant = requireTenantContext()
|
const tenant = requireTenantContext()
|
||||||
const auth = this.requireAuth()
|
const auth = this.requireAuth()
|
||||||
@@ -529,6 +668,23 @@ export class CommentService {
|
|||||||
return comment
|
return comment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findCommentForCursorAll(commentId: string, tenantId: string) {
|
||||||
|
const db = this.dbAccessor.get()
|
||||||
|
const [comment] = await db
|
||||||
|
.select({
|
||||||
|
id: comments.id,
|
||||||
|
createdAt: comments.createdAt,
|
||||||
|
})
|
||||||
|
.from(comments)
|
||||||
|
.where(and(eq(comments.id, commentId), eq(comments.tenantId, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '无效的游标' })
|
||||||
|
}
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
private toResponse(model: CommentViewModel & { reactionCounts: Record<string, number>; viewerReactions: string[] }) {
|
private toResponse(model: CommentViewModel & { reactionCounts: Record<string, number>; viewerReactions: string[] }) {
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UserMenu } from './UserMenu'
|
|||||||
const navigationTabs = [
|
const navigationTabs = [
|
||||||
{ labelKey: 'nav.overview', path: '/' },
|
{ labelKey: 'nav.overview', path: '/' },
|
||||||
{ labelKey: 'nav.photos', path: '/photos' },
|
{ labelKey: 'nav.photos', path: '/photos' },
|
||||||
|
{ labelKey: 'nav.comments', path: '/comments' },
|
||||||
{ labelKey: 'nav.analytics', path: '/analytics' },
|
{ labelKey: 'nav.analytics', path: '/analytics' },
|
||||||
{ labelKey: 'nav.settings', path: '/settings' },
|
{ labelKey: 'nav.settings', path: '/settings' },
|
||||||
] as const satisfies readonly { labelKey: I18nKeys; path: string }[]
|
] as const satisfies readonly { labelKey: I18nKeys; path: string }[]
|
||||||
|
|||||||
32
be/apps/dashboard/src/modules/comments/api.ts
Normal file
32
be/apps/dashboard/src/modules/comments/api.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { coreApi } from '~/lib/api-client'
|
||||||
|
import { camelCaseKeys } from '~/lib/case'
|
||||||
|
|
||||||
|
import type { CommentsListResponse, ListAllCommentsQueryDto, ListCommentsQueryDto } from './types'
|
||||||
|
|
||||||
|
export const commentsApi = {
|
||||||
|
list: async (query: ListCommentsQueryDto): Promise<CommentsListResponse> => {
|
||||||
|
return camelCaseKeys(
|
||||||
|
await coreApi('/comments', {
|
||||||
|
method: 'GET',
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
listAll: async (query: ListAllCommentsQueryDto): Promise<CommentsListResponse> => {
|
||||||
|
return camelCaseKeys(
|
||||||
|
await coreApi('/comments/all', {
|
||||||
|
method: 'GET',
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (commentId: string): Promise<{ id: string; deleted: boolean }> => {
|
||||||
|
return camelCaseKeys(
|
||||||
|
await coreApi(`/comments/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Loader2, MessageSquare } from 'lucide-react'
|
||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { useAllCommentsQuery } from '../hooks'
|
||||||
|
import type { CommentResponseItem, CommentStatus, UserViewModel } from '../types'
|
||||||
|
import { CommentItem } from './CommentItem'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface AllCommentsListProps {
|
||||||
|
filters?: {
|
||||||
|
photoId?: string
|
||||||
|
status?: CommentStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedComment {
|
||||||
|
comment: CommentResponseItem
|
||||||
|
user: UserViewModel
|
||||||
|
parentComment?: CommentResponseItem
|
||||||
|
parentUser?: UserViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoGroup {
|
||||||
|
photoId: string
|
||||||
|
comments: GroupedComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const LoadingState = memo(function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-text-tertiary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ErrorState = memo(function ErrorState({ message }: { message: string }) {
|
||||||
|
return <div className="rounded-lg border border-red/20 bg-red/5 p-4 text-sm text-red">加载评论失败: {message}</div>
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmptyState = memo(function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-12 text-text-tertiary">
|
||||||
|
<MessageSquare className="h-8 w-8 opacity-50" />
|
||||||
|
<p className="text-sm">暂无评论</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const PhotoGroupHeader = memo(function PhotoGroupHeader({ photoId }: { photoId: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-1 py-2">
|
||||||
|
<span className="text-xs font-medium text-text-tertiary">照片</span>
|
||||||
|
<code className="rounded bg-fill px-2 py-0.5 font-mono text-xs text-text-secondary">{photoId}</code>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const LoadMoreButton = memo(function LoadMoreButton({
|
||||||
|
isLoading,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-text-secondary transition-all duration-200 hover:bg-fill/50 hover:text-text disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>加载更多</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const AllCommentsList = memo(function AllCommentsList({ filters }: AllCommentsListProps) {
|
||||||
|
const { data, isLoading, isError, error, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||||
|
useAllCommentsQuery(filters)
|
||||||
|
|
||||||
|
// Group comments by photoId for better organization
|
||||||
|
const groupedComments = useMemo(() => {
|
||||||
|
if (!data?.pages[0]?.comments.length) return []
|
||||||
|
|
||||||
|
const allComments: CommentResponseItem[] = []
|
||||||
|
const allRelations: Record<string, CommentResponseItem> = {}
|
||||||
|
const allUsers: Record<string, UserViewModel> = {}
|
||||||
|
|
||||||
|
for (const page of data.pages) {
|
||||||
|
allComments.push(...page.comments)
|
||||||
|
Object.assign(allRelations, page.relations)
|
||||||
|
Object.assign(allUsers, page.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by photoId
|
||||||
|
const groups = new Map<string, GroupedComment[]>()
|
||||||
|
|
||||||
|
for (const comment of allComments) {
|
||||||
|
const user = allUsers[comment.userId]
|
||||||
|
if (!user) continue
|
||||||
|
|
||||||
|
const parentComment = comment.parentId ? allRelations[comment.parentId] : undefined
|
||||||
|
const parentUser = parentComment ? allUsers[parentComment.userId] : undefined
|
||||||
|
|
||||||
|
const grouped: GroupedComment = { comment, user, parentComment, parentUser }
|
||||||
|
|
||||||
|
if (!groups.has(comment.photoId)) {
|
||||||
|
groups.set(comment.photoId, [])
|
||||||
|
}
|
||||||
|
groups.get(comment.photoId)!.push(grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array and sort by most recent
|
||||||
|
const result: PhotoGroup[] = []
|
||||||
|
for (const [photoId, comments] of groups) {
|
||||||
|
result.push({ photoId, comments })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingState />
|
||||||
|
if (isError) return <ErrorState message={error?.message || '未知错误'} />
|
||||||
|
if (groupedComments.length === 0) return <EmptyState />
|
||||||
|
|
||||||
|
// If filtering by specific photoId, show flat list
|
||||||
|
if (filters?.photoId) {
|
||||||
|
const flatComments = groupedComments.flatMap((g) => g.comments)
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{flatComments.map(({ comment, user, parentComment, parentUser }) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
user={user}
|
||||||
|
parentComment={parentComment}
|
||||||
|
parentUser={parentUser}
|
||||||
|
depth={comment.parentId ? 1 : 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPage && <LoadMoreButton isLoading={isFetchingNextPage} onClick={() => fetchNextPage()} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise show grouped by photo
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{groupedComments.map((group) => (
|
||||||
|
<div key={group.photoId} className="space-y-2">
|
||||||
|
<PhotoGroupHeader photoId={group.photoId} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.comments.map(({ comment, user, parentComment, parentUser }) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
user={user}
|
||||||
|
parentComment={parentComment}
|
||||||
|
parentUser={parentUser}
|
||||||
|
depth={comment.parentId ? 1 : 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPage && <LoadMoreButton isLoading={isFetchingNextPage} onClick={() => fetchNextPage()} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { clsxm } from '@afilmory/utils'
|
||||||
|
import { Trash2, User } from 'lucide-react'
|
||||||
|
import { m } from 'motion/react'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
import { useDeleteCommentMutation } from '../hooks'
|
||||||
|
import type { CommentResponseItem, UserViewModel } from '../types'
|
||||||
|
import { CommentStatusBadge } from './CommentStatusBadge'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
comment: CommentResponseItem
|
||||||
|
user: UserViewModel
|
||||||
|
parentComment?: CommentResponseItem
|
||||||
|
parentUser?: UserViewModel
|
||||||
|
depth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const CommentUserAvatar = memo(function CommentUserAvatar({
|
||||||
|
user,
|
||||||
|
size = 'md',
|
||||||
|
}: {
|
||||||
|
user: UserViewModel
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}) {
|
||||||
|
const sizeClasses = size === 'sm' ? 'h-6 w-6' : 'h-8 w-8'
|
||||||
|
const iconSize = size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'
|
||||||
|
|
||||||
|
if (user.image) {
|
||||||
|
return <img src={user.image} alt={user.name} className={clsxm(sizeClasses, 'rounded-full bg-fill object-cover')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsxm(sizeClasses, 'flex items-center justify-center rounded-full bg-fill')}>
|
||||||
|
<User className={clsxm(iconSize, 'text-text-tertiary')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const CommentHeader = memo(function CommentHeader({
|
||||||
|
user,
|
||||||
|
createdAt,
|
||||||
|
status,
|
||||||
|
parentUser,
|
||||||
|
}: {
|
||||||
|
user: UserViewModel
|
||||||
|
createdAt: string
|
||||||
|
status: CommentResponseItem['status']
|
||||||
|
parentUser?: UserViewModel
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="text-sm font-medium text-text">{user.name}</span>
|
||||||
|
{parentUser && (
|
||||||
|
<span className="text-xs text-text-tertiary">
|
||||||
|
Replied to <span className="text-text-secondary">@{parentUser.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-text-tertiary">·</span>
|
||||||
|
<time className="text-xs text-text-tertiary">{formatDate(createdAt)}</time>
|
||||||
|
{status !== 'approved' && <CommentStatusBadge status={status} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const CommentReactions = memo(function CommentReactions({ reactions }: { reactions: Record<string, number> }) {
|
||||||
|
const entries = Object.entries(reactions)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{entries.map(([reaction, count]) => (
|
||||||
|
<span key={reaction} className="inline-flex items-center gap-1 rounded-full bg-fill px-2 py-0.5 text-xs">
|
||||||
|
<span>{reaction}</span>
|
||||||
|
<span className="text-text-tertiary">{count}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const CommentActions = memo(function CommentActions({ commentId, isHidden }: { commentId: string; isHidden: boolean }) {
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const deleteMutation = useDeleteCommentMutation()
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!confirm('确定要删除这条评论吗?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(commentId)
|
||||||
|
} catch {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}, [commentId, deleteMutation])
|
||||||
|
|
||||||
|
if (isHidden) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-text-tertiary opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-red/10 hover:text-red disabled:opacity-50"
|
||||||
|
title="删除评论"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CommentItem = memo(function CommentItem({
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
parentComment: _parentComment,
|
||||||
|
parentUser,
|
||||||
|
depth = 0,
|
||||||
|
}: CommentItemProps) {
|
||||||
|
const isHidden = comment.status === 'hidden'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className={clsxm(
|
||||||
|
'group flex items-start gap-3 rounded border border-fill-tertiary bg-background p-3 transition-colors hover:bg-background-hover',
|
||||||
|
depth > 0 && 'ml-6',
|
||||||
|
isHidden && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="shrink-0 pt-0.5">
|
||||||
|
<CommentUserAvatar user={user} size="md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<CommentHeader user={user} createdAt={comment.createdAt} status={comment.status} parentUser={parentUser} />
|
||||||
|
|
||||||
|
{/* Comment Content */}
|
||||||
|
<p className={clsxm('text-sm leading-relaxed text-text-secondary', isHidden && 'line-through')}>
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CommentReactions reactions={comment.reactionCounts} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<CommentActions commentId={comment.id} isHidden={isHidden} />
|
||||||
|
</m.div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffMins < 1) return '刚刚'
|
||||||
|
if (diffMins < 60) return `${diffMins} 分钟前`
|
||||||
|
if (diffHours < 24) return `${diffHours} 小时前`
|
||||||
|
if (diffDays < 7) return `${diffDays} 天前`
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useCommentsQuery } from '../hooks'
|
||||||
|
import type { CommentResponseItem, UserViewModel } from '../types'
|
||||||
|
import { CommentItem } from './CommentItem'
|
||||||
|
|
||||||
|
interface CommentListProps {
|
||||||
|
photoId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentList({ photoId }: CommentListProps) {
|
||||||
|
const { data, isLoading, isError, error, hasNextPage, fetchNextPage, isFetchingNextPage } = useCommentsQuery(photoId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-text-tertiary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-red/20 bg-red/5 p-4 text-sm text-red">
|
||||||
|
加载评论失败: {error?.message || '未知错误'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.pages[0]?.comments.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-text-tertiary">
|
||||||
|
<p className="text-sm">暂无评论</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all pages
|
||||||
|
const allComments: CommentResponseItem[] = []
|
||||||
|
const allRelations: Record<string, CommentResponseItem> = {}
|
||||||
|
const allUsers: Record<string, UserViewModel> = {}
|
||||||
|
|
||||||
|
for (const page of data.pages) {
|
||||||
|
allComments.push(...page.comments)
|
||||||
|
Object.assign(allRelations, page.relations)
|
||||||
|
Object.assign(allUsers, page.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{allComments.map((comment) => {
|
||||||
|
const user = allUsers[comment.userId]
|
||||||
|
const parentComment = comment.parentId ? allRelations[comment.parentId] : undefined
|
||||||
|
const parentUser = parentComment ? allUsers[parentComment.userId] : undefined
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
user={user}
|
||||||
|
parentComment={parentComment}
|
||||||
|
parentUser={parentUser}
|
||||||
|
depth={comment.parentId ? 1 : 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Load More Button */}
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-background-hover px-4 py-2 text-sm text-text transition-colors hover:bg-background-hover/80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>加载更多</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { CheckCircle2, CircleDashed, EyeOff, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { CommentStatus } from '../types'
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
CommentStatus,
|
||||||
|
{
|
||||||
|
icon: React.JSX.Element
|
||||||
|
label: string
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
approved: {
|
||||||
|
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
|
||||||
|
label: '已批准',
|
||||||
|
className: 'text-green',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
icon: <CircleDashed className="h-3.5 w-3.5" />,
|
||||||
|
label: '待审核',
|
||||||
|
className: 'text-yellow',
|
||||||
|
},
|
||||||
|
hidden: {
|
||||||
|
icon: <EyeOff className="h-3.5 w-3.5" />,
|
||||||
|
label: '已隐藏',
|
||||||
|
className: 'text-text-tertiary',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||||
|
label: '已拒绝',
|
||||||
|
className: 'text-red',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentStatusBadgeProps {
|
||||||
|
status: CommentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentStatusBadge({ status }: CommentStatusBadgeProps) {
|
||||||
|
const config = STATUS_CONFIG[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-xs ${config.className}`}>
|
||||||
|
{config.icon}
|
||||||
|
<span>{config.label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
|
||||||
|
import { memo, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||||
|
|
||||||
|
import type { CommentStatus } from '../types'
|
||||||
|
|
||||||
|
interface CommentsFiltersProps {
|
||||||
|
photoIdFilter: string
|
||||||
|
statusFilter: CommentStatus | 'all'
|
||||||
|
onPhotoIdChange: (value: string) => void
|
||||||
|
onStatusChange: (value: CommentStatus | 'all') => void
|
||||||
|
onClearAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommentsFilters = memo(function CommentsFilters({
|
||||||
|
photoIdFilter,
|
||||||
|
statusFilter,
|
||||||
|
onPhotoIdChange,
|
||||||
|
onStatusChange,
|
||||||
|
onClearAll,
|
||||||
|
}: CommentsFiltersProps) {
|
||||||
|
const handlePhotoIdChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onPhotoIdChange(e.target.value)
|
||||||
|
},
|
||||||
|
[onPhotoIdChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
onStatusChange(value as CommentStatus | 'all')
|
||||||
|
},
|
||||||
|
[onStatusChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClearPhotoId = useCallback(() => {
|
||||||
|
onPhotoIdChange('')
|
||||||
|
}, [onPhotoIdChange])
|
||||||
|
|
||||||
|
const hasActiveFilters = photoIdFilter || statusFilter !== 'all'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearBorderPanel className="bg-background-tertiary">
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-text">筛选条件</h3>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Photo ID Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="photoId" className="block text-sm text-text">
|
||||||
|
照片 ID
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="photoId"
|
||||||
|
value={photoIdFilter}
|
||||||
|
onChange={handlePhotoIdChange}
|
||||||
|
placeholder="输入照片 ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="status" className="block text-sm font-medium text-text">
|
||||||
|
状态
|
||||||
|
</label>
|
||||||
|
<Select value={statusFilter} onValueChange={handleStatusChange}>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="approved">已批准</SelectItem>
|
||||||
|
<SelectItem value="pending">待审核</SelectItem>
|
||||||
|
<SelectItem value="hidden">已隐藏</SelectItem>
|
||||||
|
<SelectItem value="rejected">已拒绝</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-text-secondary">当前筛选:</span>
|
||||||
|
{photoIdFilter && <FilterTag label={`照片: ${photoIdFilter}`} onClear={handleClearPhotoId} />}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<FilterTag label={`状态: ${getStatusLabel(statusFilter)}`} onClear={() => onStatusChange('all')} />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="text-xs text-text-tertiary transition-colors hover:text-text"
|
||||||
|
>
|
||||||
|
清除全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LinearBorderPanel>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const FilterTag = memo(function FilterTag({ label, onClear }: { label: string; onClear: () => void }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2 py-1 text-xs text-accent">
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="ml-1 inline-flex items-center justify-center transition-colors hover:text-accent/80"
|
||||||
|
>
|
||||||
|
<i className="i-lucide-x" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStatusLabel(status: CommentStatus): string {
|
||||||
|
const labels: Record<CommentStatus, string> = {
|
||||||
|
approved: '已批准',
|
||||||
|
pending: '待审核',
|
||||||
|
hidden: '已隐藏',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
}
|
||||||
|
return labels[status]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||||
|
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||||
|
|
||||||
|
import { ALL_COMMENTS_QUERY_KEY } from '../hooks'
|
||||||
|
import type { CommentStatus } from '../types'
|
||||||
|
import { AllCommentsList } from './AllCommentsList'
|
||||||
|
import { CommentsFilters } from './CommentsFilters'
|
||||||
|
|
||||||
|
export function CommentsManagement() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [photoIdFilter, setPhotoIdFilter] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<CommentStatus | 'all'>('all')
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ALL_COMMENTS_QUERY_KEY })
|
||||||
|
}, [queryClient])
|
||||||
|
|
||||||
|
const handleClearAll = useCallback(() => {
|
||||||
|
setPhotoIdFilter('')
|
||||||
|
setStatusFilter('all')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
photoId: photoIdFilter || undefined,
|
||||||
|
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
}),
|
||||||
|
[photoIdFilter, statusFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainPageLayout
|
||||||
|
title="评论管理"
|
||||||
|
description="查看和管理照片评论"
|
||||||
|
actions={<RefreshButton onClick={handleRefresh} />}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CommentsFilters
|
||||||
|
photoIdFilter={photoIdFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onPhotoIdChange={setPhotoIdFilter}
|
||||||
|
onStatusChange={setStatusFilter}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinearBorderPanel className="bg-background-tertiary">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="mb-4 text-base font-semibold text-text">所有评论</h2>
|
||||||
|
<AllCommentsList filters={filters} />
|
||||||
|
</div>
|
||||||
|
</LinearBorderPanel>
|
||||||
|
</div>
|
||||||
|
</MainPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefreshButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-text-secondary transition-all duration-200 hover:bg-fill/50 hover:text-text"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
<span>刷新</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
be/apps/dashboard/src/modules/comments/hooks.ts
Normal file
54
be/apps/dashboard/src/modules/comments/hooks.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { commentsApi } from './api'
|
||||||
|
import type { CommentStatus } from './types'
|
||||||
|
|
||||||
|
export const COMMENTS_QUERY_KEY = ['comments'] as const
|
||||||
|
export const ALL_COMMENTS_QUERY_KEY = ['comments', 'all'] as const
|
||||||
|
|
||||||
|
export function useCommentsQuery(photoId: string, options?: { enabled?: boolean }) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: [...COMMENTS_QUERY_KEY, photoId],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
commentsApi.list({
|
||||||
|
photoId,
|
||||||
|
limit: 20,
|
||||||
|
cursor: pageParam,
|
||||||
|
}),
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllCommentsQuery(filters?: { photoId?: string; status?: CommentStatus }) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: [...ALL_COMMENTS_QUERY_KEY, filters],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
commentsApi.listAll({
|
||||||
|
limit: 20,
|
||||||
|
cursor: pageParam,
|
||||||
|
photoId: filters?.photoId,
|
||||||
|
status: filters?.status,
|
||||||
|
}),
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCommentMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: commentsApi.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: COMMENTS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ALL_COMMENTS_QUERY_KEY })
|
||||||
|
toast.success('评论已删除')
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(`删除失败: ${error.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
9
be/apps/dashboard/src/modules/comments/index.ts
Normal file
9
be/apps/dashboard/src/modules/comments/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './api'
|
||||||
|
export * from './components/AllCommentsList'
|
||||||
|
export * from './components/CommentItem'
|
||||||
|
export * from './components/CommentList'
|
||||||
|
export * from './components/CommentsFilters'
|
||||||
|
export * from './components/CommentsManagement'
|
||||||
|
export * from './components/CommentStatusBadge'
|
||||||
|
export * from './hooks'
|
||||||
|
export * from './types'
|
||||||
53
be/apps/dashboard/src/modules/comments/types.ts
Normal file
53
be/apps/dashboard/src/modules/comments/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type CommentStatus = 'approved' | 'pending' | 'hidden' | 'rejected'
|
||||||
|
|
||||||
|
export interface UserViewModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentViewModel {
|
||||||
|
id: string
|
||||||
|
photoId: string
|
||||||
|
parentId: string | null
|
||||||
|
userId: string
|
||||||
|
content: string
|
||||||
|
status: CommentStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentResponseItem extends CommentViewModel {
|
||||||
|
reactionCounts: Record<string, number>
|
||||||
|
viewerReactions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentsListResponse {
|
||||||
|
comments: CommentResponseItem[]
|
||||||
|
relations: Record<string, CommentResponseItem>
|
||||||
|
users: Record<string, UserViewModel>
|
||||||
|
nextCursor: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentDto {
|
||||||
|
photoId: string
|
||||||
|
content: string
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListCommentsQueryDto {
|
||||||
|
photoId: string
|
||||||
|
limit?: number
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAllCommentsQueryDto {
|
||||||
|
limit?: number
|
||||||
|
cursor?: string
|
||||||
|
photoId?: string
|
||||||
|
status?: CommentStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReactionDto {
|
||||||
|
reaction: string
|
||||||
|
}
|
||||||
3
be/apps/dashboard/src/pages/(main)/comments/index.tsx
Normal file
3
be/apps/dashboard/src/pages/(main)/comments/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { CommentsManagement } from '~/modules/comments/components/CommentsManagement'
|
||||||
|
|
||||||
|
export const Component = () => <CommentsManagement />
|
||||||
@@ -158,6 +158,7 @@
|
|||||||
"errors.request.generic": "Request failed. Please try again later.",
|
"errors.request.generic": "Request failed. Please try again later.",
|
||||||
"header.plan.badge": "Plan",
|
"header.plan.badge": "Plan",
|
||||||
"nav.analytics": "Analytics",
|
"nav.analytics": "Analytics",
|
||||||
|
"nav.comments": "Comments",
|
||||||
"nav.library": "Library",
|
"nav.library": "Library",
|
||||||
"nav.overview": "Overview",
|
"nav.overview": "Overview",
|
||||||
"nav.photos": "Photos",
|
"nav.photos": "Photos",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
"errors.request.generic": "请求失败,请稍后重试。",
|
"errors.request.generic": "请求失败,请稍后重试。",
|
||||||
"header.plan.badge": "订阅",
|
"header.plan.badge": "订阅",
|
||||||
"nav.analytics": "数据分析",
|
"nav.analytics": "数据分析",
|
||||||
|
"nav.comments": "评论",
|
||||||
"nav.library": "图库",
|
"nav.library": "图库",
|
||||||
"nav.overview": "概览",
|
"nav.overview": "概览",
|
||||||
"nav.photos": "照片",
|
"nav.photos": "照片",
|
||||||
|
|||||||
Reference in New Issue
Block a user