mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +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 type { Context } from 'hono'
|
||||
|
||||
import { CommentReactionDto, CreateCommentDto, ListCommentsQueryDto } from './comment.dto'
|
||||
import { CommentReactionDto, CreateCommentDto, ListAllCommentsQueryDto, ListCommentsQueryDto } from './comment.dto'
|
||||
import { CommentService } from './comment.service'
|
||||
|
||||
@Controller('comments')
|
||||
@@ -20,6 +20,12 @@ export class CommentController {
|
||||
return await this.commentService.listComments(query)
|
||||
}
|
||||
|
||||
@Get('/all')
|
||||
@Roles('admin')
|
||||
async listAllComments(@Query() query: ListAllCommentsQueryDto) {
|
||||
return await this.commentService.listAllComments(query)
|
||||
}
|
||||
|
||||
@Post('/:id/reactions')
|
||||
@Roles('user')
|
||||
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 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({
|
||||
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 { 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 { 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 }> {
|
||||
const tenant = requireTenantContext()
|
||||
const auth = this.requireAuth()
|
||||
@@ -529,6 +668,23 @@ export class CommentService {
|
||||
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[] }) {
|
||||
return {
|
||||
id: model.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { UserMenu } from './UserMenu'
|
||||
const navigationTabs = [
|
||||
{ labelKey: 'nav.overview', path: '/' },
|
||||
{ labelKey: 'nav.photos', path: '/photos' },
|
||||
{ labelKey: 'nav.comments', path: '/comments' },
|
||||
{ labelKey: 'nav.analytics', path: '/analytics' },
|
||||
{ labelKey: 'nav.settings', path: '/settings' },
|
||||
] 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.",
|
||||
"header.plan.badge": "Plan",
|
||||
"nav.analytics": "Analytics",
|
||||
"nav.comments": "Comments",
|
||||
"nav.library": "Library",
|
||||
"nav.overview": "Overview",
|
||||
"nav.photos": "Photos",
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"errors.request.generic": "请求失败,请稍后重试。",
|
||||
"header.plan.badge": "订阅",
|
||||
"nav.analytics": "数据分析",
|
||||
"nav.comments": "评论",
|
||||
"nav.library": "图库",
|
||||
"nav.overview": "概览",
|
||||
"nav.photos": "照片",
|
||||
|
||||
Reference in New Issue
Block a user