diff --git a/apps/web/src/lib/api/comments.ts b/apps/web/src/lib/api/comments.ts index 39e3d119..63b74bef 100644 --- a/apps/web/src/lib/api/comments.ts +++ b/apps/web/src/lib/api/comments.ts @@ -136,6 +136,11 @@ export const commentsApi = { } }, + async count(photoId: string): Promise<{ count: number }> { + const params = new URLSearchParams({ photoId }) + return apiFetch<{ count: number }>(`/api/comments/count?${params.toString()}`) + }, + async toggleReaction(input: ToggleReactionInput): Promise { const data = await apiFetch<{ item: CommentDto }>(`/api/comments/${input.commentId}/reactions`, { method: 'POST', diff --git a/apps/web/src/modules/inspector/InspectorPanel.tsx b/apps/web/src/modules/inspector/InspectorPanel.tsx index 09b01e99..4259d872 100644 --- a/apps/web/src/modules/inspector/InspectorPanel.tsx +++ b/apps/web/src/modules/inspector/InspectorPanel.tsx @@ -1,6 +1,8 @@ import type { PickedExif } from '@afilmory/builder' import { MobileTabGroup, MobileTabItem, SegmentGroup, SegmentItem } from '@afilmory/ui' import { Spring } from '@afilmory/utils' +import { useQuery } from '@tanstack/react-query' +import clsx from 'clsx' import { m } from 'motion/react' import type { FC } from 'react' import { useState } from 'react' @@ -8,6 +10,7 @@ import { useTranslation } from 'react-i18next' import { injectConfig } from '~/config' import { useMobile } from '~/hooks/useMobile' +import { commentsApi } from '~/lib/api/comments' import { ExifPanelContent } from '~/modules/metadata/ExifPanel' import { CommentsPanel } from '~/modules/social/comments' import type { PhotoManifest } from '~/types/photo' @@ -25,6 +28,13 @@ export const InspectorPanel: FC<{ const [activeTab, setActiveTab] = useState('info') const showSocialFeatures = injectConfig.useCloud + const { data: commentCount } = useQuery({ + queryKey: ['comment-count', currentPhoto.id], + queryFn: () => commentsApi.count(currentPhoto.id), + enabled: showSocialFeatures, + }) + + const hasComments = (commentCount?.count ?? 0) > 0 return ( {t('inspector.tab.comments')} + {hasComments &&
}
} /> @@ -126,9 +137,10 @@ export const InspectorPanel: FC<{ activeBgClassName="bg-accent/20" className="text-white/60 hover:text-white/80 data-[state=active]:text-white" label={ -
+
{t('inspector.tab.comments')} + {hasComments &&
}
} /> diff --git a/be/apps/core/src/modules/content/comment/comment.controller.ts b/be/apps/core/src/modules/content/comment/comment.controller.ts index b80708d0..a6198e72 100644 --- a/be/apps/core/src/modules/content/comment/comment.controller.ts +++ b/be/apps/core/src/modules/content/comment/comment.controller.ts @@ -2,7 +2,13 @@ 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, ListAllCommentsQueryDto, ListCommentsQueryDto } from './comment.dto' +import { + CommentReactionDto, + CreateCommentDto, + GetCommentCountQueryDto, + ListAllCommentsQueryDto, + ListCommentsQueryDto, +} from './comment.dto' import { CommentService } from './comment.service' @Controller('comments') @@ -15,6 +21,11 @@ export class CommentController { return await this.commentService.createComment(body, context) } + @Get('/count') + async getCommentCount(@Query() query: GetCommentCountQueryDto) { + return await this.commentService.getCommentCount(query) + } + @Get('/') async listComments(@Query() query: ListCommentsQueryDto) { return await this.commentService.listComments(query) diff --git a/be/apps/core/src/modules/content/comment/comment.dto.ts b/be/apps/core/src/modules/content/comment/comment.dto.ts index 1666a880..ddddb23d 100644 --- a/be/apps/core/src/modules/content/comment/comment.dto.ts +++ b/be/apps/core/src/modules/content/comment/comment.dto.ts @@ -31,3 +31,9 @@ export const CommentReactionSchema = z.object({ }) export class CommentReactionDto extends createZodSchemaDto(CommentReactionSchema) {} + +export const GetCommentCountQuerySchema = z.object({ + photoId: z.string().trim().min(1, 'photoId is required'), +}) + +export class GetCommentCountQueryDto extends createZodSchemaDto(GetCommentCountQuerySchema) {} diff --git a/be/apps/core/src/modules/content/comment/comment.service.ts b/be/apps/core/src/modules/content/comment/comment.service.ts index 332f48e5..eba28ab8 100644 --- a/be/apps/core/src/modules/content/comment/comment.service.ts +++ b/be/apps/core/src/modules/content/comment/comment.service.ts @@ -34,8 +34,8 @@ export interface UserViewModel { website?: string | null } -interface ViewerContext { - userId: string | null +interface AuthUser { + id?: string role?: string } @@ -44,6 +44,13 @@ interface CommentResponseItem extends CommentViewModel { viewerReactions: string[] } +type AuthContextValue = + | { + user?: AuthUser + session?: unknown + } + | undefined + @injectable() export class CommentService { constructor( @@ -62,7 +69,11 @@ export class CommentService { users: Record }> { const tenant = requireTenantContext() - const auth = this.requireAuth() + const authUser = this.getAuthUser() + const userId = authUser?.id + if (!userId) { + throw new BizException(ErrorCode.AUTH_UNAUTHORIZED) + } const db = this.dbAccessor.get() await this.ensurePhotoExists(tenant.tenant.id, dto.photoId) @@ -70,7 +81,7 @@ export class CommentService { const moderationInput: CommentModerationHookInput = { tenantId: tenant.tenant.id, - userId: auth.userId, + userId, photoId: dto.photoId, parentId: parent?.id, content: dto.content.trim(), @@ -93,7 +104,7 @@ export class CommentService { tenantId: tenant.tenant.id, photoId: dto.photoId, parentId: parent?.id ?? null, - userId: auth.userId, + userId, content: dto.content.trim(), status, userAgent: moderationInput.userAgent ?? null, @@ -126,7 +137,7 @@ export class CommentService { .limit(1) if (fullParent) { - const parentReactions = await this.fetchReactionAggregations(tenant.tenant.id, [parent.id], auth.userId) + const parentReactions = await this.fetchReactionAggregations(tenant.tenant.id, [parent.id], userId) relations[parent.id] = this.toResponse({ ...fullParent, reactionCounts: parentReactions.counts.get(parent.id) ?? {}, @@ -136,7 +147,7 @@ export class CommentService { } // Fetch user info - const userIds = [auth.userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean) + const userIds = [userId, ...Object.values(relations).map((r) => r.userId)].filter(Boolean) const users = await this.fetchUsersWithProfiles(userIds) // Emit event asynchronously @@ -147,7 +158,7 @@ export class CommentService { record.id, tenant.tenant.id, dto.photoId, - auth.userId, + userId, parent?.id ?? null, dto.content.trim(), record.createdAt, @@ -167,7 +178,10 @@ export class CommentService { nextCursor: string | null }> { const tenant = requireTenantContext() - const viewer = this.getViewer() + const authUser = this.getAuthUser() + const viewerUserId = authUser?.id ?? null + const role = authUser?.role + const isAdmin = role === 'admin' || role === 'superadmin' const db = this.dbAccessor.get() const filters = [ @@ -177,12 +191,12 @@ export class CommentService { ] let statusCondition - if (viewer.isAdmin) { + if (isAdmin) { statusCondition = inArray(comments.status, ['approved', 'pending']) - } else if (viewer.userId) { + } else if (viewerUserId) { statusCondition = or( eq(comments.status, 'approved'), - and(eq(comments.status, 'pending'), eq(comments.userId, viewer.userId)), + and(eq(comments.status, 'pending'), eq(comments.userId, viewerUserId)), ) } else { statusCondition = eq(comments.status, 'approved') @@ -220,7 +234,7 @@ export class CommentService { 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 reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewerUserId) const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null @@ -256,7 +270,7 @@ export class CommentService { const parentReactions = await this.fetchReactionAggregations( tenant.tenant.id, parentRows.map((p) => p.id), - viewer.userId, + viewerUserId, ) for (const parent of parentRows) { @@ -290,14 +304,10 @@ export class CommentService { nextCursor: string | null }> { const tenant = requireTenantContext() - const viewer = this.getViewer() + const authUser = this.getAuthUser() + const viewerUserId = authUser?.id ?? null 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 @@ -341,7 +351,7 @@ export class CommentService { 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 reactions = await this.fetchReactionAggregations(tenant.tenant.id, commentIds, viewerUserId) const nextCursor = hasMore && items.length > 0 ? items.at(-1)!.id : null @@ -377,7 +387,7 @@ export class CommentService { const parentReactions = await this.fetchReactionAggregations( tenant.tenant.id, parentRows.map((p) => p.id), - viewer.userId, + viewerUserId, ) for (const parent of parentRows) { @@ -406,7 +416,11 @@ export class CommentService { async toggleReaction(commentId: string, body: CommentReactionDto): Promise<{ item: CommentResponseItem }> { const tenant = requireTenantContext() - const auth = this.requireAuth() + const authUser = this.getAuthUser() + const userId = authUser?.id + if (!userId) { + throw new BizException(ErrorCode.AUTH_UNAUTHORIZED) + } const db = this.dbAccessor.get() const comment = await this.getCommentById(commentId, tenant.tenant.id) @@ -418,7 +432,7 @@ export class CommentService { and( eq(commentReactions.tenantId, tenant.tenant.id), eq(commentReactions.commentId, comment.id), - eq(commentReactions.userId, auth.userId), + eq(commentReactions.userId, userId), eq(commentReactions.reaction, body.reaction), ), ) @@ -430,12 +444,12 @@ export class CommentService { await db.insert(commentReactions).values({ tenantId: tenant.tenant.id, commentId: comment.id, - userId: auth.userId, + userId, reaction: body.reaction, }) } - const aggregation = await this.fetchReactionAggregations(tenant.tenant.id, [comment.id], auth.userId) + const aggregation = await this.fetchReactionAggregations(tenant.tenant.id, [comment.id], userId) const item = this.toResponse({ ...comment, reactionCounts: aggregation.counts.get(comment.id) ?? {}, @@ -446,7 +460,13 @@ export class CommentService { async softDelete(commentId: string): Promise { const tenant = requireTenantContext() - const auth = this.requireAuth() + const authUser = this.getAuthUser() + const userId = authUser?.id + if (!userId) { + throw new BizException(ErrorCode.AUTH_UNAUTHORIZED) + } + const { role } = authUser! + const isAdmin = role === 'admin' || role === 'superadmin' const db = this.dbAccessor.get() const [record] = await db @@ -463,8 +483,8 @@ export class CommentService { throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '评论不存在' }) } - const isAdmin = auth.role === 'admin' || auth.role === 'superadmin' - const isOwner = auth.userId === record.userId + const { userId: authorId } = record + const isOwner = userId === authorId if (!isAdmin && !isOwner) { throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '无权删除该评论' }) @@ -479,28 +499,44 @@ export class CommentService { .where(eq(comments.id, record.id)) } - private requireAuth(): { userId: string; role?: string } { - const authContext = HttpContext.getValue('auth') as - | { user?: { id?: string; role?: string }; session?: unknown } - | undefined - if (!authContext?.user || !authContext.session) { - throw new BizException(ErrorCode.AUTH_UNAUTHORIZED) + async getCommentCount(query: { photoId: string }): Promise<{ count: number }> { + const tenant = requireTenantContext() + const authUser = this.getAuthUser() + const viewerUserId = authUser?.id ?? null + const role = authUser?.role + const isAdmin = role === 'admin' || role === 'superadmin' + const db = this.dbAccessor.get() + + const filters = [ + eq(comments.tenantId, tenant.tenant.id), + eq(comments.photoId, query.photoId), + isNull(comments.deletedAt), + ] + + let statusCondition + if (isAdmin) { + statusCondition = inArray(comments.status, ['approved', 'pending']) + } else if (viewerUserId) { + statusCondition = or( + eq(comments.status, 'approved'), + and(eq(comments.status, 'pending'), eq(comments.userId, viewerUserId)), + ) + } else { + statusCondition = eq(comments.status, 'approved') } - const userId = (authContext.user as { id?: string }).id - if (!userId) { - throw new BizException(ErrorCode.AUTH_UNAUTHORIZED) - } - return { userId, role: (authContext.user as { role?: string }).role } + filters.push(statusCondition) + + const [result] = await db + .select({ count: sql`count(*)` }) + .from(comments) + .where(and(...filters)) + + return { count: Number(result?.count ?? 0) } } - private getViewer(): ViewerContext & { isAdmin: boolean } { - const authContext = HttpContext.getValue('auth') as - | { user?: { id?: string; role?: string }; session?: unknown } - | undefined - const userId = authContext?.user?.id ?? null - const role = authContext?.user?.role - const isAdmin = role === 'admin' || role === 'superadmin' - return { userId, role, isAdmin } + private getAuthUser(): AuthUser | undefined { + const authContext = HttpContext.getValue('auth') as AuthContextValue + return authContext?.user } private async ensurePhotoExists(tenantId: string, photoId: string): Promise {