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:
Innei
2025-11-27 00:36:17 +08:00
parent 4356494d01
commit cc977c662d
17 changed files with 1049 additions and 2 deletions

View File

@@ -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) {

View File

@@ -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'),
})

View File

@@ -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,

View File

@@ -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 }[]

View 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',
}),
)
},
}

View File

@@ -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>
)
})

View File

@@ -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)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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]
}

View File

@@ -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>
)
}

View 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}`)
},
})
}

View 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'

View 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
}

View File

@@ -0,0 +1,3 @@
import { CommentsManagement } from '~/modules/comments/components/CommentsManagement'
export const Component = () => <CommentsManagement />

View File

@@ -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",

View File

@@ -158,6 +158,7 @@
"errors.request.generic": "请求失败,请稍后重试。",
"header.plan.badge": "订阅",
"nav.analytics": "数据分析",
"nav.comments": "评论",
"nav.library": "图库",
"nav.overview": "概览",
"nav.photos": "照片",