Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef3a55359 | ||
|
|
4d25bf00ba | ||
|
|
2d1dc3673a | ||
|
|
f20140f4ea | ||
|
|
0b16613be4 | ||
|
|
4991dae691 | ||
|
|
7e91c81d86 | ||
|
|
0f86e5b9ee | ||
|
|
00b79681a0 | ||
|
|
bfb018b766 | ||
|
|
740bfc76fa | ||
|
|
dacad1587a | ||
|
|
ff77b46f79 | ||
|
|
3e06cfde76 | ||
|
|
e7c9ee5159 | ||
|
|
c71e6c6587 | ||
|
|
2910dd6ccf |
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 开发环境 API 地址
|
||||
VITE_API_BASE_URL=https://withyou.littlelan.cn/api/v1
|
||||
80
.gitea/workflows/build.yml
Normal file
80
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Admin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: code.littlelan.cn
|
||||
IMAGE_NAME: carrot_bbs/admin
|
||||
|
||||
jobs:
|
||||
build-and-push-web:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.GIT_USERNAME }}
|
||||
password: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/admin:buildcache
|
||||
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/admin:buildcache,mode=max
|
||||
|
||||
- name: Show image tags
|
||||
run: |
|
||||
echo "Built image tags:"
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo "Digest: ${{ steps.meta.outputs.digest }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Admin CI Summary" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "**Web image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "**Tags:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "**Digest:** ${{ steps.meta.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
16
Dockerfile.web
Normal file
16
Dockerfile.web
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
19
nginx.conf
Normal file
19
nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
44
src/api/channels.ts
Normal file
44
src/api/channels.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import apiClient from './client'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// 频道信息
|
||||
export interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 创建/更新频道请求
|
||||
export interface ChannelUpsertRequest {
|
||||
name: string
|
||||
slug: string
|
||||
description?: string
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// 频道管理 API
|
||||
export const channelsApi = {
|
||||
// 获取频道列表(全部)
|
||||
getChannels: () =>
|
||||
apiClient.get<ApiResponse<Channel[]>>('/admin/channels'),
|
||||
|
||||
// 创建频道
|
||||
createChannel: (data: ChannelUpsertRequest) =>
|
||||
apiClient.post<ApiResponse<Channel>>('/admin/channels', data),
|
||||
|
||||
// 更新频道
|
||||
updateChannel: (id: string, data: ChannelUpsertRequest) =>
|
||||
apiClient.put<ApiResponse<Channel>>(`/admin/channels/${id}`, data),
|
||||
|
||||
// 删除频道
|
||||
deleteChannel: (id: string) =>
|
||||
apiClient.delete<ApiResponse<void>>(`/admin/channels/${id}`),
|
||||
}
|
||||
|
||||
export default channelsApi
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080/api/v1',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://withyou.littlelan.cn/api/v1',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Comment, ApiResponse, PaginatedResponse } from '@/types'
|
||||
import type { Comment, ApiResponse, PaginatedResponse, CommentImage } from '@/types'
|
||||
|
||||
// 评论查询参数
|
||||
export interface CommentQueryParams {
|
||||
@@ -24,6 +24,8 @@ export interface CommentDetail extends Comment {
|
||||
post?: {
|
||||
id: string
|
||||
title: string
|
||||
content?: string
|
||||
images?: CommentImage[]
|
||||
}
|
||||
ip_address?: string
|
||||
device_info?: string
|
||||
@@ -31,23 +33,24 @@ export interface CommentDetail extends Comment {
|
||||
|
||||
// 评论管理API
|
||||
export const commentsApi = {
|
||||
// 获取评论列表(分页、搜索、筛选)
|
||||
getComments: (params: CommentQueryParams) =>
|
||||
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }),
|
||||
|
||||
// 获取评论详情
|
||||
getCommentById: (id: string) =>
|
||||
apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`),
|
||||
|
||||
// 删除评论
|
||||
deleteComment: (id: string) =>
|
||||
apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`),
|
||||
|
||||
// 批量删除评论
|
||||
batchDeleteComments: (ids: string[]) =>
|
||||
apiClient.post<ApiResponse<void>>('/admin/comments/batch-delete', { ids }),
|
||||
|
||||
// 获取帖子的评论列表
|
||||
moderateComment: (id: string, status: 'published' | 'rejected', reason?: string) =>
|
||||
apiClient.put<ApiResponse<void>>(`/admin/comments/${id}/moderate`, { status, reason }),
|
||||
|
||||
batchModerateComments: (ids: string[], status: 'published' | 'rejected') =>
|
||||
apiClient.post<ApiResponse<void>>('/admin/comments/batch-moderate', { ids, status }),
|
||||
|
||||
getCommentsByPost: (postId: string, params?: Omit<CommentQueryParams, 'post_id'>) =>
|
||||
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { default as apiClient } from './client'
|
||||
export { authApi } from './auth'
|
||||
export { usersApi } from './users'
|
||||
export type { UserQueryParams, UserDetail } from './users'
|
||||
export type { UserQueryParams, UserDetail, UserDevice } from './users'
|
||||
export { rolesApi } from './roles'
|
||||
export type { Permission, RoleInfo, RoleDetail } from './roles'
|
||||
export { postsApi } from './posts'
|
||||
@@ -10,5 +10,13 @@ export { commentsApi } from './comments'
|
||||
export type { CommentQueryParams, CommentDetail } from './comments'
|
||||
export { groupsApi } from './groups'
|
||||
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
|
||||
export { channelsApi } from './channels'
|
||||
export type { Channel, ChannelUpsertRequest } from './channels'
|
||||
export { dashboardApi } from './dashboard'
|
||||
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } from './dashboard'
|
||||
export { subjectsApi, materialsApi } from './materials'
|
||||
export type { MaterialSubject, MaterialFile, MaterialFileType, SubjectUpsertRequest, MaterialCreateRequest, MaterialUpdateRequest, MaterialQueryParams } from './materials'
|
||||
export { reportsApi } from './reports'
|
||||
export type { ReportListItem, ReportDetail, ReportQueryParams, ReportStatus, ReportTargetType, HandleReportRequest, BatchHandleRequest, BatchHandleResponse } from './reports'
|
||||
export { verificationsApi } from './verifications'
|
||||
export type { VerificationListItem, VerificationDetail, VerificationQueryParams, VerificationStatus, UserIdentity, ReviewVerificationRequest } from './verifications'
|
||||
|
||||
135
src/api/materials.ts
Normal file
135
src/api/materials.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import apiClient from './client'
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types'
|
||||
|
||||
// =====================================================
|
||||
// 类型定义
|
||||
// =====================================================
|
||||
|
||||
// 文件类型
|
||||
export type MaterialFileType = 'pdf' | 'word' | 'ppt'
|
||||
|
||||
// 学科
|
||||
export interface MaterialSubject {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
description: string
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
material_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 学习资料
|
||||
export interface MaterialFile {
|
||||
id: string
|
||||
subject_id: string
|
||||
title: string
|
||||
description: string
|
||||
file_type: MaterialFileType
|
||||
file_size: number
|
||||
file_url: string
|
||||
file_name: string
|
||||
download_count: number
|
||||
author_id: string
|
||||
tags: string[]
|
||||
status: 'active' | 'inactive'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
subject?: MaterialSubject
|
||||
}
|
||||
|
||||
// 学科创建/更新请求
|
||||
export interface SubjectUpsertRequest {
|
||||
name: string
|
||||
icon?: string
|
||||
color?: string
|
||||
description?: string
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// 资料创建请求
|
||||
export interface MaterialCreateRequest {
|
||||
subject_id: string
|
||||
title: string
|
||||
description?: string
|
||||
file_type: MaterialFileType
|
||||
file_size?: number
|
||||
file_url: string
|
||||
file_name?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// 资料更新请求
|
||||
export interface MaterialUpdateRequest {
|
||||
subject_id?: string
|
||||
title?: string
|
||||
description?: string
|
||||
file_type?: MaterialFileType
|
||||
file_size?: number
|
||||
file_url?: string
|
||||
file_name?: string
|
||||
tags?: string[]
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
// 资料查询参数
|
||||
export interface MaterialQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
subject_id?: string
|
||||
file_type?: MaterialFileType
|
||||
status?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// API 接口
|
||||
// =====================================================
|
||||
|
||||
// 学科管理 API
|
||||
export const subjectsApi = {
|
||||
// 获取学科列表
|
||||
getSubjects: () =>
|
||||
apiClient.get<ApiResponse<{ list: MaterialSubject[] }>>('/admin/materials/subjects'),
|
||||
|
||||
// 创建学科
|
||||
createSubject: (data: SubjectUpsertRequest) =>
|
||||
apiClient.post<ApiResponse<MaterialSubject>>('/admin/materials/subjects', data),
|
||||
|
||||
// 更新学科
|
||||
updateSubject: (id: string, data: SubjectUpsertRequest) =>
|
||||
apiClient.put<ApiResponse<MaterialSubject>>(`/admin/materials/subjects/${id}`, data),
|
||||
|
||||
// 删除学科
|
||||
deleteSubject: (id: string) =>
|
||||
apiClient.delete<ApiResponse<void>>(`/admin/materials/subjects/${id}`),
|
||||
}
|
||||
|
||||
// 学习资料管理 API
|
||||
export const materialsApi = {
|
||||
// 获取资料列表
|
||||
getMaterials: (params: MaterialQueryParams) =>
|
||||
apiClient.get<ApiResponse<PaginatedResponse<MaterialFile>>>('/admin/materials', { params }),
|
||||
|
||||
// 获取资料详情
|
||||
getMaterialById: (id: string) =>
|
||||
apiClient.get<ApiResponse<MaterialFile>>(`/admin/materials/${id}`),
|
||||
|
||||
// 创建资料
|
||||
createMaterial: (data: MaterialCreateRequest) =>
|
||||
apiClient.post<ApiResponse<MaterialFile>>('/admin/materials', data),
|
||||
|
||||
// 更新资料
|
||||
updateMaterial: (id: string, data: MaterialUpdateRequest) =>
|
||||
apiClient.put<ApiResponse<MaterialFile>>(`/admin/materials/${id}`, data),
|
||||
|
||||
// 删除资料
|
||||
deleteMaterial: (id: string) =>
|
||||
apiClient.delete<ApiResponse<void>>(`/admin/materials/${id}`),
|
||||
}
|
||||
|
||||
export default materialsApi
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Post, ApiResponse, PaginatedResponse } from '@/types'
|
||||
import type { Post, PostImage, ApiResponse, PaginatedResponse } from '@/types'
|
||||
|
||||
// 帖子查询参数
|
||||
export interface PostQueryParams {
|
||||
@@ -16,7 +16,7 @@ export interface PostQueryParams {
|
||||
export interface PostDetail extends Post {
|
||||
group_id?: string
|
||||
group_name?: string
|
||||
images?: string[]
|
||||
images?: PostImage[]
|
||||
tags?: string[]
|
||||
is_pinned: boolean
|
||||
is_featured: boolean
|
||||
|
||||
106
src/api/reports.ts
Normal file
106
src/api/reports.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 管理端举报 API
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
// 举报状态
|
||||
export type ReportStatus = 'pending' | 'processing' | 'resolved' | 'rejected';
|
||||
|
||||
// 举报目标类型
|
||||
export type ReportTargetType = 'post' | 'comment' | 'message';
|
||||
|
||||
// 举报列表项
|
||||
export interface ReportListItem {
|
||||
id: string;
|
||||
reporter_id: string;
|
||||
reporter?: {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
target_type: ReportTargetType;
|
||||
target_id: string;
|
||||
reason: string;
|
||||
description?: string;
|
||||
status: ReportStatus;
|
||||
handled_by?: string;
|
||||
handler?: {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
handled_at?: string;
|
||||
result?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 举报详情
|
||||
export interface ReportDetail extends ReportListItem {
|
||||
target_content?: {
|
||||
type: ReportTargetType;
|
||||
id: string;
|
||||
author?: {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
content?: string;
|
||||
title?: string;
|
||||
images?: string[];
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
export interface ReportQueryParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
target_type?: ReportTargetType;
|
||||
status?: ReportStatus;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// 处理举报请求
|
||||
export interface HandleReportRequest {
|
||||
action: 'approve' | 'reject';
|
||||
result?: string;
|
||||
}
|
||||
|
||||
// 批量处理请求
|
||||
export interface BatchHandleRequest {
|
||||
ids: string[];
|
||||
action: 'approve' | 'reject';
|
||||
result?: string;
|
||||
}
|
||||
|
||||
// 批量处理响应
|
||||
export interface BatchHandleResponse {
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
failed_ids?: string[];
|
||||
}
|
||||
|
||||
// 举报API
|
||||
export const reportsApi = {
|
||||
// 获取举报列表
|
||||
getReports: (params: ReportQueryParams) =>
|
||||
apiClient.get('/admin/reports', { params }),
|
||||
|
||||
// 获取举报详情
|
||||
getReportById: (id: string) =>
|
||||
apiClient.get<ReportDetail>(`/admin/reports/${id}`),
|
||||
|
||||
// 处理举报
|
||||
handleReport: (id: string, data: HandleReportRequest) =>
|
||||
apiClient.put(`/admin/reports/${id}/handle`, data),
|
||||
|
||||
// 批量处理
|
||||
batchHandle: (data: BatchHandleRequest) =>
|
||||
apiClient.post<BatchHandleResponse>('/admin/reports/batch-handle', data),
|
||||
};
|
||||
|
||||
export default reportsApi;
|
||||
@@ -17,6 +17,20 @@ export interface UserDetail extends User {
|
||||
following_count: number
|
||||
}
|
||||
|
||||
// 用户设备(含 JPush RegistrationID)
|
||||
export interface UserDevice {
|
||||
id: number
|
||||
user_id: string
|
||||
device_id: string
|
||||
device_type: string // ios | android | web
|
||||
push_token?: string // JPush RegistrationID
|
||||
is_active: boolean
|
||||
device_name?: string
|
||||
last_used_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 用户管理API
|
||||
export const usersApi = {
|
||||
// 获取用户列表(分页、搜索、筛选)
|
||||
@@ -27,6 +41,10 @@ export const usersApi = {
|
||||
getUserById: (id: string) =>
|
||||
apiClient.get<ApiResponse<UserDetail>>(`/admin/users/${id}`),
|
||||
|
||||
// 获取用户设备列表(含 registration_id / push_token)
|
||||
getUserDevices: (id: string) =>
|
||||
apiClient.get<ApiResponse<UserDevice[]>>(`/admin/users/${id}/devices`),
|
||||
|
||||
// 更新用户状态(封禁/解封)
|
||||
updateUserStatus: (id: string, status: 'active' | 'banned') =>
|
||||
apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }),
|
||||
|
||||
53
src/api/verifications.ts
Normal file
53
src/api/verifications.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import apiClient from './client'
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types'
|
||||
|
||||
export type UserIdentity = 'general' | 'student' | 'teacher' | 'staff' | 'alumni' | 'external'
|
||||
export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected'
|
||||
|
||||
export interface VerificationListItem {
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
identity: UserIdentity
|
||||
status: VerificationStatus
|
||||
real_name: string
|
||||
student_id?: string
|
||||
employee_id?: string
|
||||
department?: string
|
||||
created_at: string
|
||||
reviewed_at?: string
|
||||
reviewer_name?: string
|
||||
}
|
||||
|
||||
export interface VerificationDetail extends VerificationListItem {
|
||||
proof_materials: string
|
||||
reviewed_by?: string
|
||||
reject_reason?: string
|
||||
}
|
||||
|
||||
export interface VerificationQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: VerificationStatus
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface ReviewVerificationRequest {
|
||||
approve: boolean
|
||||
reject_reason?: string
|
||||
}
|
||||
|
||||
export const verificationsApi = {
|
||||
getVerifications: (params: VerificationQueryParams) =>
|
||||
apiClient.get<ApiResponse<PaginatedResponse<VerificationListItem>>>('/admin/verifications', { params }),
|
||||
|
||||
getVerificationById: (id: string) =>
|
||||
apiClient.get<ApiResponse<VerificationDetail>>(`/admin/verifications/${id}`),
|
||||
|
||||
reviewVerification: (id: string, data: ReviewVerificationRequest) =>
|
||||
apiClient.put<ApiResponse<VerificationDetail>>(`/admin/verifications/${id}/review`, data),
|
||||
}
|
||||
|
||||
export default verificationsApi
|
||||
@@ -10,15 +10,30 @@ import {
|
||||
Users as UsersIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
Flag,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean
|
||||
onCollapse: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
interface MenuItem {
|
||||
title: string
|
||||
path?: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
roles: string[]
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
@@ -55,23 +70,131 @@ const menuItems = [
|
||||
icon: UsersIcon,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
||||
},
|
||||
{
|
||||
title: '举报管理',
|
||||
path: '/reports',
|
||||
icon: Flag,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR],
|
||||
},
|
||||
{
|
||||
title: '身份认证',
|
||||
path: '/verifications',
|
||||
icon: ShieldCheck,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||
},
|
||||
{
|
||||
title: '频道管理',
|
||||
path: '/channels',
|
||||
icon: Layers,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||
},
|
||||
{
|
||||
title: '学习资料',
|
||||
icon: FolderOpen,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||
children: [
|
||||
{
|
||||
title: '学科管理',
|
||||
path: '/materials/subjects',
|
||||
icon: BookOpen,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||
},
|
||||
{
|
||||
title: '资料管理',
|
||||
path: '/materials',
|
||||
icon: FileText,
|
||||
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
||||
const { hasAnyRole, roles } = useAuthStore()
|
||||
const { hasAnyRole } = useAuthStore()
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([])
|
||||
|
||||
// DEBUG: 输出角色信息
|
||||
console.log('[Sidebar DEBUG] roles:', roles)
|
||||
console.log('[Sidebar DEBUG] ROLES constants:', ROLES)
|
||||
// 切换展开状态
|
||||
const toggleExpand = (title: string) => {
|
||||
setExpandedItems((prev) =>
|
||||
prev.includes(title) ? prev.filter((t) => t !== title) : [...prev, title]
|
||||
)
|
||||
}
|
||||
|
||||
// 过滤出当前用户有权访问的菜单
|
||||
const visibleMenuItems = menuItems.filter(item => {
|
||||
const hasAccess = hasAnyRole(item.roles)
|
||||
console.log(`[Sidebar DEBUG] Menu "${item.title}" - required roles:`, item.roles, '- hasAccess:', hasAccess)
|
||||
return hasAccess
|
||||
})
|
||||
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
||||
return items
|
||||
.filter((item) => hasAnyRole(item.roles))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterMenuItems(item.children) : undefined,
|
||||
}))
|
||||
.filter((item) => item.path || (item.children && item.children.length > 0))
|
||||
}
|
||||
|
||||
console.log('[Sidebar DEBUG] visibleMenuItems count:', visibleMenuItems.length)
|
||||
const visibleMenuItems = filterMenuItems(menuItems)
|
||||
|
||||
// 渲染单个菜单项
|
||||
const renderMenuItem = (item: MenuItem) => {
|
||||
const Icon = item.icon
|
||||
|
||||
// 如果有子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
const isExpanded = expandedItems.includes(item.title)
|
||||
return (
|
||||
<div key={item.title}>
|
||||
<button
|
||||
onClick={() => toggleExpand(item.title)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors cursor-pointer',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{item.title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && isExpanded && (
|
||||
<div className="ml-6 mt-1 space-y-1 border-l-2 border-muted pl-3">
|
||||
{item.children.map((child) => renderMenuItem(child))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 普通菜单项
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)
|
||||
}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -83,7 +206,7 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
||||
{/* Logo区域 */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
{!collapsed && (
|
||||
<span className="text-xl font-bold text-primary">Carrot BBS</span>
|
||||
<span className="text-xl font-bold text-primary">WithYou</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -101,31 +224,13 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{visibleMenuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)
|
||||
}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
{visibleMenuItems.map((item) => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* 底部版本信息 */}
|
||||
{!collapsed && (
|
||||
<div className="border-t p-4 text-center text-xs text-muted-foreground">
|
||||
Carrot BBS Admin v1.0.0
|
||||
WithYou Admin v0.0.1
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -31,3 +31,12 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-dynamic-color {
|
||||
color: var(--dynamic-color);
|
||||
}
|
||||
.bg-dynamic-color {
|
||||
background-color: var(--dynamic-color);
|
||||
}
|
||||
}
|
||||
|
||||
394
src/pages/Channels.tsx
Normal file
394
src/pages/Channels.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { channelsApi, type Channel, type ChannelUpsertRequest } from '@/api/channels'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2, Plus, Pencil, Trash2, RefreshCw, Layers } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export default function Channels() {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
// 创建/编辑对话框
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<Channel | null>(null)
|
||||
const [formData, setFormData] = useState<ChannelUpsertRequest>({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
const [dialogLoading, setDialogLoading] = useState(false)
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingChannel, setDeletingChannel] = useState<Channel | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// 加载频道列表
|
||||
const loadChannels = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await channelsApi.getChannels()
|
||||
const data = response.data.data as Channel[] | { list: Channel[] } | undefined
|
||||
const list = Array.isArray(data) ? data : (data?.list || [])
|
||||
list.sort((a: Channel, b: Channel) => a.sort_order - b.sort_order)
|
||||
setChannels(list)
|
||||
} catch (error) {
|
||||
console.error('Failed to load channels:', error)
|
||||
setChannels([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels()
|
||||
}, [loadChannels])
|
||||
|
||||
// 打开创建对话框
|
||||
const handleOpenCreate = () => {
|
||||
setEditingChannel(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
sort_order: channels.length,
|
||||
is_active: true,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const handleOpenEdit = (channel: Channel) => {
|
||||
setEditingChannel(channel)
|
||||
setFormData({
|
||||
name: channel.name,
|
||||
slug: channel.slug,
|
||||
description: channel.description,
|
||||
sort_order: channel.sort_order,
|
||||
is_active: channel.is_active,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 生成 slug(根据名称)
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
// 名称改变时自动生成 slug
|
||||
const handleNameChange = (name: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
name,
|
||||
slug: editingChannel ? formData.slug : generateSlug(name),
|
||||
})
|
||||
}
|
||||
|
||||
// 保存频道
|
||||
const handleSave = async () => {
|
||||
if (!formData.name?.trim() || !formData.slug?.trim()) return
|
||||
setDialogLoading(true)
|
||||
try {
|
||||
if (editingChannel) {
|
||||
await channelsApi.updateChannel(editingChannel.id, formData)
|
||||
} else {
|
||||
await channelsApi.createChannel(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadChannels()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save channel:', error)
|
||||
alert(error?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
setDialogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开删除确认
|
||||
const handleOpenDelete = (channel: Channel) => {
|
||||
setDeletingChannel(channel)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingChannel) return
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await channelsApi.deleteChannel(deletingChannel.id)
|
||||
setDeleteOpen(false)
|
||||
loadChannels()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete channel:', error)
|
||||
alert(error?.response?.data?.message || '删除失败')
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 分页后的数据
|
||||
const paginatedChannels = channels.slice((page - 1) * pageSize, page * pageSize)
|
||||
const totalPages = Math.ceil(channels.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">频道管理</h1>
|
||||
<p className="text-muted-foreground">管理帖子的分类频道</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={loadChannels} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加频道
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" />
|
||||
频道列表
|
||||
</CardTitle>
|
||||
<CardDescription>共 {channels.length} 个频道</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">排序</TableHead>
|
||||
<TableHead>频道名称</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead className="w-[150px]">创建时间</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-2">加载中...</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedChannels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据,点击「添加频道」创建第一个频道
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedChannels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.sort_order}</TableCell>
|
||||
<TableCell className="font-medium">{channel.name}</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-muted px-2 py-1 rounded text-sm">
|
||||
{channel.slug}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{channel.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={channel.is_active ? 'default' : 'outline'}>
|
||||
{channel.is_active ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(channel.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenEdit(channel)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenDelete(channel)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingChannel ? '编辑频道' : '添加频道'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingChannel ? '修改频道信息' : '创建新的帖子分类频道'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">频道名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="如:校园生活"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug(标识符)*</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug || ''}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="如:campus-life"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用于 URL 和 API 调用,建议使用英文小写、数字和连字符
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="频道描述(可选)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">排序值</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">数值越小排序越靠前</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active ?? true}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
|
||||
/>
|
||||
<Label htmlFor="is_active">启用此频道</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={dialogLoading || !formData.name?.trim() || !formData.slug?.trim()}
|
||||
>
|
||||
{dialogLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingChannel ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除频道「{deletingChannel?.name}」吗?此操作不可撤销。
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
注意:删除频道后,该频道下的帖子将变为无频道状态。
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
@@ -47,7 +48,14 @@ import {
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
ThumbsUp,
|
||||
Check,
|
||||
X,
|
||||
Reply,
|
||||
ImageIcon,
|
||||
} from 'lucide-react'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
// 状态颜色映射
|
||||
@@ -90,7 +98,12 @@ export default function Comments() {
|
||||
const [deleteComment, setDeleteComment] = useState<Comment | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// 批量操作加载状态
|
||||
const [moderateOpen, setModerateOpen] = useState(false)
|
||||
const [moderateComment, setModerateComment] = useState<Comment | null>(null)
|
||||
const [moderateStatus, setModerateStatus] = useState<'published' | 'rejected'>('published')
|
||||
const [moderateReason, setModerateReason] = useState('')
|
||||
const [moderateLoading, setModerateLoading] = useState(false)
|
||||
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
// 加载评论列表
|
||||
@@ -191,7 +204,48 @@ export default function Comments() {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const openModerateDialog = (comment: Comment, status: 'published' | 'rejected') => {
|
||||
setModerateComment(comment)
|
||||
setModerateStatus(status)
|
||||
setModerateReason('')
|
||||
setModerateOpen(true)
|
||||
}
|
||||
|
||||
const handleModerate = async () => {
|
||||
if (!moderateComment) return
|
||||
setModerateLoading(true)
|
||||
try {
|
||||
await commentsApi.moderateComment(moderateComment.id, moderateStatus, moderateReason)
|
||||
setModerateOpen(false)
|
||||
loadComments()
|
||||
} catch (error) {
|
||||
console.error('Failed to moderate comment:', error)
|
||||
setComments(comments.map(c =>
|
||||
c.id === moderateComment.id ? { ...c, status: moderateStatus } : c
|
||||
))
|
||||
setModerateOpen(false)
|
||||
} finally {
|
||||
setModerateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchModerate = async (status: 'published' | 'rejected') => {
|
||||
if (selectedIds.length === 0) return
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
await commentsApi.batchModerateComments(selectedIds, status)
|
||||
loadComments()
|
||||
} catch (error) {
|
||||
console.error('Failed to batch moderate comments:', error)
|
||||
setComments(comments.map(c =>
|
||||
selectedIds.includes(c.id) ? { ...c, status } : c
|
||||
))
|
||||
setSelectedIds([])
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.length === 0) return
|
||||
setBatchLoading(true)
|
||||
@@ -242,23 +296,30 @@ export default function Comments() {
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>筛选条件</CardTitle>
|
||||
<Card className="border-slate-200 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
筛选条件
|
||||
</CardTitle>
|
||||
<CardDescription>根据条件筛选评论</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px] max-w-sm">
|
||||
<Input
|
||||
placeholder="搜索评论内容..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[240px] max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索评论内容..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectTrigger className="w-[160px] bg-slate-50 border-slate-200">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -269,7 +330,7 @@ export default function Comments() {
|
||||
<SelectItem value="deleted">已删除</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch}>
|
||||
<Button onClick={handleSearch} className="bg-primary hover:bg-primary/90">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
@@ -279,13 +340,38 @@ export default function Comments() {
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<Card className="border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 shadow-sm">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-orange-800">
|
||||
已选择 {selectedIds.length} 项
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 rounded-lg">
|
||||
<Check className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-900">
|
||||
已选择 <span className="font-bold">{selectedIds.length}</span> 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBatchModerate('published')}
|
||||
disabled={batchLoading}
|
||||
className="bg-white border-green-200 text-green-700 hover:bg-green-50 hover:text-green-800"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
批量通过
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBatchModerate('rejected')}
|
||||
disabled={batchLoading}
|
||||
className="bg-white border-amber-200 text-amber-700 hover:bg-amber-50 hover:text-amber-800"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
批量拒绝
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -302,107 +388,172 @@ export default function Comments() {
|
||||
)}
|
||||
|
||||
{/* 评论表格 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>评论列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 条记录
|
||||
</CardDescription>
|
||||
<Card className="border-slate-200 shadow-sm">
|
||||
<CardHeader className="pb-4 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
评论列表
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
共 <span className="font-medium text-slate-900">{total}</span> 条记录
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||
<TableHead className="w-12 rounded-tl-lg">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead className="w-20">预览</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>评论者</TableHead>
|
||||
<TableHead>所属帖子</TableHead>
|
||||
<TableHead className="w-24">状态</TableHead>
|
||||
<TableHead className="w-16">点赞</TableHead>
|
||||
<TableHead className="w-36">评论时间</TableHead>
|
||||
<TableHead className="w-20">操作</TableHead>
|
||||
<TableHead className="w-20 rounded-tr-lg">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<TableCell colSpan={10} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : comments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
<TableCell colSpan={10} className="text-center py-12 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<MessageSquare className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<span>暂无数据</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<TableRow key={comment.id}>
|
||||
<TableRow
|
||||
key={comment.id}
|
||||
className="group hover:bg-slate-50/80 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(comment.id)}
|
||||
onCheckedChange={(checked) => handleSelect(comment.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{comment.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-slate-500">#{comment.id.slice(-6)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="relative w-14 h-14 rounded-lg overflow-hidden bg-slate-100 ring-1 ring-slate-200 group-hover:ring-primary/30 transition-all">
|
||||
{comment.images && comment.images.length > 0 ? (
|
||||
<>
|
||||
<img
|
||||
src={comment.images[0].url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{comment.images.length > 1 && (
|
||||
<div className="absolute bottom-0 right-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded-tl-md">
|
||||
+{comment.images.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<MessageSquare className="h-5 w-5 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[250px]">
|
||||
<p className="text-sm truncate" title={comment.content}>
|
||||
{truncateContent(comment.content)}
|
||||
<p className="text-sm text-slate-700 truncate group-hover:text-primary transition-colors" title={comment.content}>
|
||||
{truncateContent(comment.content, 80)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="h-9 w-9 ring-2 ring-white shadow-sm">
|
||||
<AvatarImage src={comment.author.avatar} />
|
||||
<AvatarFallback>{getAvatarFallback(comment.author)}</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs font-medium">
|
||||
{getAvatarFallback(comment.author)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{comment.author.nickname || comment.author.username}</div>
|
||||
<div className="text-xs text-muted-foreground">@{comment.author.username}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-slate-900 truncate">{comment.author.nickname || comment.author.username}</div>
|
||||
<div className="text-xs text-slate-500 truncate">@{comment.author.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate max-w-[150px]" title={comment.post_title}>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<ExternalLink className="h-3.5 w-3.5 text-slate-400" />
|
||||
<span className="truncate max-w-[140px] text-slate-600" title={comment.post_title}>
|
||||
{comment.post_title || `帖子 #${comment.post_id}`}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColors[comment.status]} text-white`}>
|
||||
<Badge className={`${statusColors[comment.status]} text-white font-medium text-xs px-2.5 py-1`}>
|
||||
{statusText[comment.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{comment.like_count}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-slate-600">
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{comment.likes_count}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-500">
|
||||
{formatDate(comment.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-slate-100">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewDetail(comment)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => handleViewDetail(comment)} className="cursor-pointer">
|
||||
<Eye className="mr-2 h-4 w-4 text-blue-500" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
{comment.status === 'pending' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openModerateDialog(comment, 'published')} className="cursor-pointer">
|
||||
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||
通过审核
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openModerateDialog(comment, 'rejected')} className="cursor-pointer">
|
||||
<X className="mr-2 h-4 w-4 text-amber-500" />
|
||||
拒绝
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => openDeleteDialog(comment)}
|
||||
className="text-red-600"
|
||||
className="text-red-600 cursor-pointer focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
@@ -417,7 +568,7 @@ export default function Comments() {
|
||||
</Table>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-4">
|
||||
<div className="p-4 border-t border-slate-100 bg-slate-50/50">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
@@ -429,65 +580,215 @@ export default function Comments() {
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>评论详情</DialogTitle>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<DialogTitle className="text-xl flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
评论详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : selectedComment ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<div className="space-y-6 py-2">
|
||||
{/* 评论者信息卡片 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl">
|
||||
<Avatar className="h-14 w-14 ring-2 ring-white shadow-sm">
|
||||
<AvatarImage src={selectedComment.author?.avatar} />
|
||||
<AvatarFallback>{getAvatarFallback(selectedComment.author)}</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-semibold text-lg">
|
||||
{getAvatarFallback(selectedComment.author)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
|
||||
<div className="text-sm text-muted-foreground">@{selectedComment.author?.username}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-lg">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
|
||||
<div className="text-sm text-slate-500">@{selectedComment.author?.username}</div>
|
||||
</div>
|
||||
<Badge className={`${statusColors[selectedComment.status]} text-white ml-auto`}>
|
||||
<Badge className={`${statusColors[selectedComment.status]} text-white font-medium px-3 py-1`}>
|
||||
{statusText[selectedComment.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 所属帖子 */}
|
||||
{selectedComment.post && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">所属帖子</div>
|
||||
<div className="font-medium">{selectedComment.post.title}</div>
|
||||
<Card className="bg-slate-50 border-slate-200">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<ExternalLink className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-slate-500 mb-1">所属帖子</div>
|
||||
<div className="font-medium text-slate-900">{selectedComment.post.title}</div>
|
||||
{selectedComment.post.content && (
|
||||
<div className="text-sm text-slate-500 mt-1 truncate">
|
||||
{selectedComment.post.content.substring(0, 100)}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="shrink-0">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 帖子图片预览 */}
|
||||
{selectedComment.post.images && selectedComment.post.images.length > 0 && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
{selectedComment.post.images.slice(0, 3).map((img, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="w-16 h-16 object-cover rounded-lg ring-1 ring-slate-200"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{selectedComment.post.images.length > 3 && (
|
||||
<div className="w-16 h-16 bg-slate-200 rounded-lg flex items-center justify-center text-xs text-slate-500">
|
||||
+{selectedComment.post.images.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 回复信息 */}
|
||||
{selectedComment.reply_to_user && (
|
||||
<div className="flex items-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<Reply className="h-4 w-4 text-amber-600" />
|
||||
<span className="text-sm text-amber-800">
|
||||
回复给 <span className="font-medium">@{selectedComment.reply_to_user.nickname || selectedComment.reply_to_user.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评论内容 */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">评论内容</div>
|
||||
<p className="bg-muted/50 p-3 rounded-lg whitespace-pre-wrap">{selectedComment.content}</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
<span>评论内容</span>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||
<p className="text-slate-700 whitespace-pre-wrap leading-relaxed">{selectedComment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
||||
<span>👍 {selectedComment.like_count} 点赞</span>
|
||||
{/* 评论图片 */}
|
||||
{selectedComment.images && selectedComment.images.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<ImageIcon className="h-4 w-4 text-primary" />
|
||||
<span>评论图片 ({selectedComment.images.length}张)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{selectedComment.images.map((img, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative aspect-square rounded-xl overflow-hidden bg-slate-100 ring-1 ring-slate-200 hover:ring-primary/50 transition-all"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`图片 ${index + 1}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%239ca3af" stroke-width="1"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" ry="2"/%3E%3Ccircle cx="8.5" cy="8.5" r="1.5"/%3E%3Cpolyline points="21 15 16 10 5 21"/%3E%3C/svg%3E';
|
||||
(e.target as HTMLImageElement).classList.add('p-4');
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审核信息 */}
|
||||
{selectedComment.status !== 'published' && selectedComment.reject_reason && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-50 rounded-xl border border-red-100">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-red-500 mb-1">
|
||||
{selectedComment.reviewed_by ? `审核人: ${selectedComment.reviewed_by}` : 'AI审核'}
|
||||
</div>
|
||||
<div className="text-sm text-red-700">{selectedComment.reject_reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className="flex items-center gap-6 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<div className="p-2 bg-rose-100 rounded-lg">
|
||||
<ThumbsUp className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-900">{selectedComment.likes_count}</div>
|
||||
<div className="text-xs text-slate-500">点赞</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
评论时间:{formatDate(selectedComment.created_at)}
|
||||
{/* 时间信息 */}
|
||||
<div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
|
||||
<span>评论时间:{formatDate(selectedComment.created_at)}</span>
|
||||
{selectedComment.updated_at !== selectedComment.created_at && (
|
||||
<span>更新于:{formatDate(selectedComment.updated_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 审核对话框 */}
|
||||
<Dialog open={moderateOpen} onOpenChange={setModerateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{moderateStatus === 'published' ? '通过审核' : '拒绝评论'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要{moderateStatus === 'published' ? '通过' : '拒绝'}这条评论吗?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{moderateComment && (
|
||||
<div className="bg-muted/50 p-3 rounded-lg">
|
||||
<p className="text-sm">{truncateContent(moderateComment.content, 100)}</p>
|
||||
</div>
|
||||
)}
|
||||
{moderateStatus === 'rejected' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moderate-reason">拒绝原因</Label>
|
||||
<Textarea
|
||||
id="moderate-reason"
|
||||
placeholder="请输入拒绝原因(可选)"
|
||||
value={moderateReason}
|
||||
onChange={(e) => setModerateReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModerateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant={moderateStatus === 'published' ? 'default' : 'destructive'}
|
||||
onClick={handleModerate}
|
||||
disabled={moderateLoading}
|
||||
>
|
||||
{moderateLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Users, FileText, MessageSquare, Clock, TrendingUp, Calendar, CalendarDays, CalendarRange } from 'lucide-react'
|
||||
import { StatsCard } from '@/components/StatsCard'
|
||||
import { ActivityChart } from '@/components/ActivityChart'
|
||||
@@ -78,7 +78,7 @@ export default function Dashboard() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-muted-foreground">欢迎来到 Carrot BBS 管理后台</p>
|
||||
<p className="text-muted-foreground">欢迎来到 WithYou 管理后台</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -62,7 +62,7 @@ export default function Login() {
|
||||
<span className="text-2xl font-bold">C</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Carrot BBS</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">WithYou</CardTitle>
|
||||
<CardDescription>管理后台登录</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
|
||||
481
src/pages/MaterialSubjects.tsx
Normal file
481
src/pages/MaterialSubjects.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { subjectsApi, type MaterialSubject, type SubjectUpsertRequest } from '@/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
// 颜色预设选项
|
||||
const COLOR_PRESETS = [
|
||||
{ name: '红色', value: '#ef4444' },
|
||||
{ name: '橙色', value: '#f97316' },
|
||||
{ name: '黄色', value: '#eab308' },
|
||||
{ name: '绿色', value: '#22c55e' },
|
||||
{ name: '蓝色', value: '#3b82f6' },
|
||||
{ name: '紫色', value: '#a855f7' },
|
||||
{ name: '粉色', value: '#ec4899' },
|
||||
{ name: '青色', value: '#06b6d4' },
|
||||
]
|
||||
|
||||
// 图标预设选项(使用 lucide 图标名称)
|
||||
const ICON_PRESETS = [
|
||||
'BookOpen', 'Book', 'Ruler', 'Calculator', 'Type', 'Languages', 'Globe', 'FlaskConical',
|
||||
'TestTube', 'Dna', 'Laptop', 'BarChart3', 'TrendingUp', 'Palette', 'Music', 'Gamepad2',
|
||||
]
|
||||
|
||||
// 图标名称映射(兼容 MaterialCommunityIcons -> Lucide)
|
||||
const ICON_NAME_MAPPING: Record<string, string> = {
|
||||
// 常见 MaterialCommunityIcons 到 Lucide 的映射
|
||||
'book-open': 'BookOpen',
|
||||
'book': 'Book',
|
||||
'book-open-variant': 'BookOpen',
|
||||
'calculator': 'Calculator',
|
||||
'ruler': 'Ruler',
|
||||
'pencil-ruler': 'Ruler',
|
||||
'type': 'Type',
|
||||
'format-text': 'Type',
|
||||
'translate': 'Languages',
|
||||
'web': 'Globe',
|
||||
'earth': 'Globe',
|
||||
'flask': 'FlaskConical',
|
||||
'flask-outline': 'FlaskConical',
|
||||
'test-tube': 'TestTube',
|
||||
'dna': 'Dna',
|
||||
'laptop': 'Laptop',
|
||||
'chart-bar': 'BarChart3',
|
||||
'chart-line': 'TrendingUp',
|
||||
'palette': 'Palette',
|
||||
'music': 'Music',
|
||||
'soccer': 'Gamepad2',
|
||||
'basketball': 'Gamepad2',
|
||||
'school': 'GraduationCap',
|
||||
'math-compass': 'Compass',
|
||||
'history': 'Clock',
|
||||
'atom': 'Atom',
|
||||
'microscope': 'Microscope',
|
||||
}
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (iconName: string): React.ComponentType<{ className?: string; style?: React.CSSProperties }> => {
|
||||
// 尝试直接获取
|
||||
const icons = Icons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>
|
||||
const IconComponent = icons[iconName]
|
||||
if (IconComponent) return IconComponent
|
||||
|
||||
// 尝试映射后的名称
|
||||
const mappedName = ICON_NAME_MAPPING[iconName]
|
||||
if (mappedName) {
|
||||
return icons[mappedName] || Icons.BookOpen
|
||||
}
|
||||
|
||||
// 默认返回 BookOpen
|
||||
return Icons.BookOpen
|
||||
}
|
||||
|
||||
export default function MaterialSubjects() {
|
||||
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
// 创建/编辑对话框
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingSubject, setEditingSubject] = useState<MaterialSubject | null>(null)
|
||||
const [formData, setFormData] = useState<SubjectUpsertRequest>({
|
||||
name: '',
|
||||
icon: 'BookOpen',
|
||||
color: '#3b82f6',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
const [dialogLoading, setDialogLoading] = useState(false)
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingSubject, setDeletingSubject] = useState<MaterialSubject | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// 加载学科列表
|
||||
const loadSubjects = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await subjectsApi.getSubjects()
|
||||
const list = response.data.data?.list ?? []
|
||||
setSubjects(list)
|
||||
} catch (error) {
|
||||
console.error('Failed to load subjects:', error)
|
||||
setSubjects([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubjects()
|
||||
}, [loadSubjects])
|
||||
|
||||
// 打开创建对话框
|
||||
const handleOpenCreate = () => {
|
||||
setEditingSubject(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
icon: 'BookOpen',
|
||||
color: '#3b82f6',
|
||||
description: '',
|
||||
sort_order: subjects.length,
|
||||
is_active: true,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const handleOpenEdit = (subject: MaterialSubject) => {
|
||||
setEditingSubject(subject)
|
||||
setFormData({
|
||||
name: subject.name,
|
||||
icon: subject.icon,
|
||||
color: subject.color,
|
||||
description: subject.description,
|
||||
sort_order: subject.sort_order,
|
||||
is_active: subject.is_active,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 保存学科
|
||||
const handleSave = async () => {
|
||||
if (!formData.name?.trim()) return
|
||||
setDialogLoading(true)
|
||||
try {
|
||||
if (editingSubject) {
|
||||
await subjectsApi.updateSubject(editingSubject.id, formData)
|
||||
} else {
|
||||
await subjectsApi.createSubject(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadSubjects()
|
||||
} catch (error) {
|
||||
console.error('Failed to save subject:', error)
|
||||
} finally {
|
||||
setDialogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开删除确认
|
||||
const handleOpenDelete = (subject: MaterialSubject) => {
|
||||
setDeletingSubject(subject)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingSubject) return
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await subjectsApi.deleteSubject(deletingSubject.id)
|
||||
setDeleteOpen(false)
|
||||
loadSubjects()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete subject:', error)
|
||||
alert(error?.response?.data?.message || '删除失败,该学科下可能存在资料')
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 分页后的数据
|
||||
const paginatedSubjects = subjects.slice((page - 1) * pageSize, page * pageSize)
|
||||
const totalPages = Math.ceil(subjects.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">学科管理</h1>
|
||||
<p className="text-muted-foreground">管理学习资料的学科分类</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>学科列表</CardTitle>
|
||||
<CardDescription>共 {subjects.length} 个学科</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加学科
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">排序</TableHead>
|
||||
<TableHead className="w-[80px]">图标</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>颜色</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="w-[100px]">资料数</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead className="w-[150px]">创建时间</TableHead>
|
||||
<TableHead className="w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-2">加载中...</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedSubjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedSubjects.map((subject) => {
|
||||
const IconComponent = getIconComponent(subject.icon)
|
||||
return (
|
||||
<TableRow key={subject.id}>
|
||||
<TableCell>{subject.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<IconComponent className="h-6 w-6" style={{ color: subject.color }} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{subject.name}</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className="w-6 h-6 rounded"
|
||||
style={{ backgroundColor: subject.color }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{subject.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{subject.material_count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={subject.is_active ? 'default' : 'outline'}>
|
||||
{subject.is_active ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(subject.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenEdit(subject)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenDelete(subject)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingSubject ? '编辑学科' : '添加学科'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSubject ? '修改学科信息' : '创建新的学科分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">学科名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:高等数学"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>图标</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ICON_PRESETS.map((iconName) => {
|
||||
const IconComponent = getIconComponent(iconName)
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon: iconName })}
|
||||
className={`w-10 h-10 rounded border-2 transition-colors flex items-center justify-center ${
|
||||
formData.icon === iconName
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-muted hover:border-muted-foreground'
|
||||
}`}
|
||||
title={iconName}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>颜色</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_PRESETS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, color: color.value })}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-colors ${
|
||||
formData.color === color.value
|
||||
? 'border-primary scale-110'
|
||||
: 'border-transparent hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="学科描述(可选)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">排序值</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">数值越小排序越靠前</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="is_active"
|
||||
type="checkbox"
|
||||
checked={formData.is_active ?? true}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
aria-label="启用此学科"
|
||||
/>
|
||||
<Label htmlFor="is_active">启用此学科</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={dialogLoading || !formData.name?.trim()}>
|
||||
{dialogLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingSubject ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除学科「{deletingSubject?.name}」吗?
|
||||
{deletingSubject && deletingSubject.material_count > 0 && (
|
||||
<span className="block mt-2 text-destructive">
|
||||
该学科下有 {deletingSubject.material_count} 个资料,无法删除。
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteLoading || (deletingSubject?.material_count ?? 0) > 0}
|
||||
>
|
||||
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
667
src/pages/Materials.tsx
Normal file
667
src/pages/Materials.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { materialsApi, subjectsApi, type MaterialFile, type MaterialQueryParams, type MaterialSubject, type MaterialCreateRequest, type MaterialUpdateRequest } from '@/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Loader2, Plus, Pencil, Trash2, Search, Download, Eye } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
// 文件类型配置
|
||||
const FILE_TYPE_CONFIG = {
|
||||
pdf: { icon: '📄', color: 'bg-red-100 text-red-800', label: 'PDF' },
|
||||
word: { icon: '📝', color: 'bg-blue-100 text-blue-800', label: 'Word' },
|
||||
ppt: { icon: '📊', color: 'bg-orange-100 text-orange-800', label: 'PPT' },
|
||||
} as const
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export default function Materials() {
|
||||
const [materials, setMaterials] = useState<MaterialFile[]>([])
|
||||
const [subjects, setSubjects] = useState<MaterialSubject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
// 筛选
|
||||
const [subjectFilter, setSubjectFilter] = useState<string>('all')
|
||||
const [fileTypeFilter, setFileTypeFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
// 创建/编辑对话框
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingMaterial, setEditingMaterial] = useState<MaterialFile | null>(null)
|
||||
const [formData, setFormData] = useState<MaterialCreateRequest & { status?: 'active' | 'inactive' }>({
|
||||
subject_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
file_type: 'pdf',
|
||||
file_size: 0,
|
||||
file_url: '',
|
||||
file_name: '',
|
||||
tags: [],
|
||||
})
|
||||
const [dialogLoading, setDialogLoading] = useState(false)
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deletingMaterial, setDeletingMaterial] = useState<MaterialFile | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
// 详情对话框
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<MaterialFile | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 加载资料列表
|
||||
const loadMaterials = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: MaterialQueryParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keyword: keyword || undefined,
|
||||
subject_id: subjectFilter !== 'all' ? subjectFilter : undefined,
|
||||
file_type: fileTypeFilter !== 'all' ? fileTypeFilter as any : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
}
|
||||
const response = await materialsApi.getMaterials(params)
|
||||
const data = response.data.data
|
||||
setMaterials(data.list || [])
|
||||
setTotal(data.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load materials:', error)
|
||||
setMaterials([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, keyword, subjectFilter, fileTypeFilter, statusFilter])
|
||||
|
||||
// 加载学科列表(用于筛选和表单)
|
||||
const loadSubjects = useCallback(async () => {
|
||||
try {
|
||||
const response = await subjectsApi.getSubjects()
|
||||
const data = response.data.data as MaterialSubject[] | { list: MaterialSubject[] } | undefined
|
||||
const list = Array.isArray(data) ? data : (data?.list || [])
|
||||
setSubjects(list)
|
||||
} catch (error) {
|
||||
console.error('Failed to load subjects:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubjects()
|
||||
}, [loadSubjects])
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterials()
|
||||
}, [loadMaterials])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
loadMaterials()
|
||||
}
|
||||
|
||||
// 打开创建对话框
|
||||
const handleOpenCreate = () => {
|
||||
setEditingMaterial(null)
|
||||
setFormData({
|
||||
subject_id: subjects[0]?.id || '',
|
||||
title: '',
|
||||
description: '',
|
||||
file_type: 'pdf',
|
||||
file_size: 0,
|
||||
file_url: '',
|
||||
file_name: '',
|
||||
tags: [],
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const handleOpenEdit = (material: MaterialFile) => {
|
||||
setEditingMaterial(material)
|
||||
setFormData({
|
||||
subject_id: material.subject_id,
|
||||
title: material.title,
|
||||
description: material.description || '',
|
||||
file_type: material.file_type,
|
||||
file_size: material.file_size,
|
||||
file_url: material.file_url,
|
||||
file_name: material.file_name || '',
|
||||
tags: material.tags || [],
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 保存资料
|
||||
const handleSave = async () => {
|
||||
if (!formData.subject_id || !formData.title?.trim() || !formData.file_url?.trim()) return
|
||||
setDialogLoading(true)
|
||||
try {
|
||||
if (editingMaterial) {
|
||||
const updateData: MaterialUpdateRequest = { ...formData }
|
||||
await materialsApi.updateMaterial(editingMaterial.id, updateData)
|
||||
} else {
|
||||
await materialsApi.createMaterial(formData)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadMaterials()
|
||||
} catch (error) {
|
||||
console.error('Failed to save material:', error)
|
||||
} finally {
|
||||
setDialogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开删除确认
|
||||
const handleOpenDelete = (material: MaterialFile) => {
|
||||
setDeletingMaterial(material)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingMaterial) return
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await materialsApi.deleteMaterial(deletingMaterial.id)
|
||||
setDeleteOpen(false)
|
||||
loadMaterials()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete material:', error)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (material: MaterialFile) => {
|
||||
setDetailLoading(true)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
const response = await materialsApi.getMaterialById(material.id)
|
||||
setSelectedMaterial(response.data.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load material detail:', error)
|
||||
setSelectedMaterial(null)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return format(new Date(dateStr), 'yyyy-MM-dd HH:mm')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">学习资料管理</h1>
|
||||
<p className="text-muted-foreground">管理各学科的学习资料文件</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>资料列表</CardTitle>
|
||||
<CardDescription>共 {total} 个资料</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加资料
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-wrap gap-4 mb-4">
|
||||
<Input
|
||||
placeholder="搜索资料标题..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
<Select value={subjectFilter} onValueChange={(v) => { setSubjectFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="学科筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部学科</SelectItem>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.icon} {s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={fileTypeFilter} onValueChange={(v) => { setFileTypeFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="文件类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="word">Word</SelectItem>
|
||||
<SelectItem value="ppt">PPT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">启用</SelectItem>
|
||||
<SelectItem value="inactive">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadMaterials}>
|
||||
<Loader2 className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">类型</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead className="w-[120px]">学科</TableHead>
|
||||
<TableHead className="w-[100px]">大小</TableHead>
|
||||
<TableHead className="w-[80px]">下载</TableHead>
|
||||
<TableHead className="w-[100px]">状态</TableHead>
|
||||
<TableHead className="w-[150px]">创建时间</TableHead>
|
||||
<TableHead className="w-[150px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-2">加载中...</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : materials.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
materials.map((material) => {
|
||||
const typeConfig = FILE_TYPE_CONFIG[material.file_type] || FILE_TYPE_CONFIG.pdf
|
||||
const subject = subjects.find((s) => s.id === material.subject_id)
|
||||
return (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell>
|
||||
<Badge className={typeConfig.color}>
|
||||
{typeConfig.icon} {typeConfig.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[300px]">
|
||||
<p className="font-medium truncate">{material.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{material.file_name || '未命名文件'}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subject ? (
|
||||
<span>{subject.icon} {subject.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(material.file_size)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3 text-muted-foreground" />
|
||||
{material.download_count}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={material.status === 'active' ? 'default' : 'outline'}>
|
||||
{material.status === 'active' ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(material.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewDetail(material)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenEdit(material)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleOpenDelete(material)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingMaterial ? '编辑资料' : '添加资料'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingMaterial ? '修改资料信息' : '创建新的学习资料'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject_id">所属学科 *</Label>
|
||||
<Select
|
||||
value={formData.subject_id}
|
||||
onValueChange={(v) => setFormData({ ...formData, subject_id: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择学科" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.icon} {s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">资料标题 *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title || ''}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="如:高等数学期末复习资料"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="资料描述(可选)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file_type">文件类型 *</Label>
|
||||
<Select
|
||||
value={formData.file_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, file_type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">📄 PDF</SelectItem>
|
||||
<SelectItem value="word">📝 Word</SelectItem>
|
||||
<SelectItem value="ppt">📊 PPT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file_size">文件大小 (bytes)</Label>
|
||||
<Input
|
||||
id="file_size"
|
||||
type="number"
|
||||
value={formData.file_size || 0}
|
||||
onChange={(e) => setFormData({ ...formData, file_size: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file_name">原始文件名</Label>
|
||||
<Input
|
||||
id="file_name"
|
||||
value={formData.file_name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, file_name: e.target.value })}
|
||||
placeholder="如:高等数学-期末复习-2024.pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file_url">文件链接 *</Label>
|
||||
<Input
|
||||
id="file_url"
|
||||
value={formData.file_url || ''}
|
||||
onChange={(e) => setFormData({ ...formData, file_url: e.target.value })}
|
||||
placeholder="https://example.com/path/to/file.pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingMaterial && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="status"
|
||||
type="checkbox"
|
||||
checked={formData.status === 'active' || formData.status === undefined}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.checked ? 'active' : 'inactive' })}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="status">启用此资料</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={dialogLoading || !formData.subject_id || !formData.title?.trim() || !formData.file_url?.trim()}
|
||||
>
|
||||
{dialogLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingMaterial ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除资料「{deletingMaterial?.title}」吗?此操作不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||
{deleteLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>资料详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedMaterial ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-4xl">
|
||||
{FILE_TYPE_CONFIG[selectedMaterial.file_type]?.icon || '📄'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{selectedMaterial.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{selectedMaterial.file_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">所属学科:</span>
|
||||
<span>
|
||||
{subjects.find((s) => s.id === selectedMaterial.subject_id)?.name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">文件类型:</span>
|
||||
<span>{FILE_TYPE_CONFIG[selectedMaterial.file_type]?.label || selectedMaterial.file_type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">文件大小:</span>
|
||||
<span>{formatFileSize(selectedMaterial.file_size)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">下载次数:</span>
|
||||
<span>{selectedMaterial.download_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<Badge variant={selectedMaterial.status === 'active' ? 'default' : 'outline'}>
|
||||
{selectedMaterial.status === 'active' ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">创建时间:</span>
|
||||
<span>{formatDate(selectedMaterial.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMaterial.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">描述:</span>
|
||||
<p className="mt-1 text-sm">{selectedMaterial.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMaterial.tags && selectedMaterial.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">标签:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{selectedMaterial.tags.map((tag, i) => (
|
||||
<Badge key={i} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Label className="text-muted-foreground">文件链接:</Label>
|
||||
<a
|
||||
href={selectedMaterial.file_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-1 text-sm text-blue-600 hover:underline break-all"
|
||||
>
|
||||
{selectedMaterial.file_url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">无法加载详情</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,12 @@ import {
|
||||
X,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
ImageIcon,
|
||||
ThumbsUp,
|
||||
MessageCircle,
|
||||
Eye as EyeIcon,
|
||||
Pin,
|
||||
Star,
|
||||
} from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
@@ -298,23 +304,30 @@ export default function Posts() {
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>筛选条件</CardTitle>
|
||||
<Card className="border-slate-200 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
筛选条件
|
||||
</CardTitle>
|
||||
<CardDescription>根据条件筛选帖子</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px] max-w-sm">
|
||||
<Input
|
||||
placeholder="搜索帖子标题或内容..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[240px] max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索帖子标题或内容..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectTrigger className="w-[160px] bg-slate-50 border-slate-200">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -325,7 +338,7 @@ export default function Posts() {
|
||||
<SelectItem value="deleted">已删除</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch}>
|
||||
<Button onClick={handleSearch} className="bg-primary hover:bg-primary/90">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
@@ -335,18 +348,24 @@ export default function Posts() {
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<Card className="border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 shadow-sm">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-orange-800">
|
||||
已选择 {selectedIds.length} 项
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 rounded-lg">
|
||||
<Check className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-900">
|
||||
已选择 <span className="font-bold">{selectedIds.length}</span> 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBatchModerate('published')}
|
||||
disabled={batchLoading}
|
||||
className="bg-white border-green-200 text-green-700 hover:bg-green-50 hover:text-green-800"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
批量通过
|
||||
@@ -356,6 +375,7 @@ export default function Posts() {
|
||||
size="sm"
|
||||
onClick={() => handleBatchModerate('rejected')}
|
||||
disabled={batchLoading}
|
||||
className="bg-white border-amber-200 text-amber-700 hover:bg-amber-50 hover:text-amber-800"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
批量拒绝
|
||||
@@ -376,107 +396,162 @@ export default function Posts() {
|
||||
)}
|
||||
|
||||
{/* 帖子表格 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>帖子列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 条记录
|
||||
</CardDescription>
|
||||
<Card className="border-slate-200 shadow-sm">
|
||||
<CardHeader className="pb-4 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-primary" />
|
||||
帖子列表
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
共 <span className="font-medium text-slate-900">{total}</span> 条记录
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||
<TableHead className="w-12 rounded-tl-lg">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead className="w-20">预览</TableHead>
|
||||
<TableHead>标题/内容</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead className="w-24">状态</TableHead>
|
||||
<TableHead className="w-20">点赞</TableHead>
|
||||
<TableHead className="w-20">评论</TableHead>
|
||||
<TableHead className="w-20">互动</TableHead>
|
||||
<TableHead className="w-36">发布时间</TableHead>
|
||||
<TableHead className="w-28">操作</TableHead>
|
||||
<TableHead className="w-28 rounded-tr-lg">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<TableCell colSpan={9} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : posts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="p-4 bg-slate-100 rounded-full">
|
||||
<Eye className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<span>暂无数据</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<TableRow key={post.id}>
|
||||
<TableRow
|
||||
key={post.id}
|
||||
className="group hover:bg-slate-50/80 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(post.id)}
|
||||
onCheckedChange={(checked) => handleSelect(post.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{post.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-slate-500">#{post.id.slice(-6)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[300px]">
|
||||
<div className="font-medium truncate">{post.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{post.content.substring(0, 50)}...
|
||||
<div className="relative w-14 h-14 rounded-lg overflow-hidden bg-slate-100 ring-1 ring-slate-200 group-hover:ring-primary/30 transition-all">
|
||||
{post.images && post.images.length > 0 ? (
|
||||
<img
|
||||
src={post.images[0].url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="h-5 w-5 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
{post.images && post.images.length > 1 && (
|
||||
<div className="absolute bottom-0 right-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded-tl-md">
|
||||
+{post.images.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[280px] space-y-1">
|
||||
<div className="font-medium text-slate-900 truncate group-hover:text-primary transition-colors">
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
{post.content.substring(0, 60)}{post.content.length > 60 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="h-9 w-9 ring-2 ring-white shadow-sm">
|
||||
<AvatarImage src={post.author.avatar} />
|
||||
<AvatarFallback>{getAvatarFallback(post.author)}</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs font-medium">
|
||||
{getAvatarFallback(post.author)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{post.author.nickname || post.author.username}</div>
|
||||
<div className="text-xs text-muted-foreground">@{post.author.username}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-slate-900 truncate">{post.author.nickname || post.author.username}</div>
|
||||
<div className="text-xs text-slate-500 truncate">@{post.author.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColors[post.status]} text-white`}>
|
||||
<Badge className={`${statusColors[post.status]} text-white font-medium text-xs px-2.5 py-1`}>
|
||||
{statusText[post.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{post.like_count}</TableCell>
|
||||
<TableCell>{post.comment_count}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center gap-1 text-slate-600">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
<span>{post.likes_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-slate-600">
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
<span>{post.comments_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-500">
|
||||
{formatDate(post.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-slate-100">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewDetail(post)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => handleViewDetail(post)} className="cursor-pointer">
|
||||
<Eye className="mr-2 h-4 w-4 text-blue-500" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
{post.status === 'pending' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')} className="cursor-pointer">
|
||||
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||
通过审核
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')} className="cursor-pointer">
|
||||
<X className="mr-2 h-4 w-4 text-amber-500" />
|
||||
拒绝
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
@@ -484,7 +559,7 @@ export default function Posts() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => openDeleteDialog(post)}
|
||||
className="text-red-600"
|
||||
className="text-red-600 cursor-pointer focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
@@ -499,7 +574,7 @@ export default function Posts() {
|
||||
</Table>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-4">
|
||||
<div className="p-4 border-t border-slate-100 bg-slate-50/50">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
@@ -511,43 +586,161 @@ export default function Posts() {
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>帖子详情</DialogTitle>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<DialogTitle className="text-xl flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-primary" />
|
||||
帖子详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : selectedPost ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<div className="space-y-6 py-2">
|
||||
{/* 作者信息卡片 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl">
|
||||
<Avatar className="h-14 w-14 ring-2 ring-white shadow-sm">
|
||||
<AvatarImage src={selectedPost.author?.avatar} />
|
||||
<AvatarFallback>{getAvatarFallback(selectedPost.author)}</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-semibold text-lg">
|
||||
{getAvatarFallback(selectedPost.author)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{selectedPost.author?.nickname || selectedPost.author?.username}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-lg">{selectedPost.author?.nickname || selectedPost.author?.username}</div>
|
||||
<div className="text-sm text-muted-foreground">@{selectedPost.author?.username}</div>
|
||||
</div>
|
||||
<Badge className={`${statusColors[selectedPost.status]} text-white ml-auto`}>
|
||||
{statusText[selectedPost.status]}
|
||||
</Badge>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={`${statusColors[selectedPost.status]} text-white font-medium px-3 py-1`}>
|
||||
{statusText[selectedPost.status]}
|
||||
</Badge>
|
||||
<div className="flex gap-1">
|
||||
{selectedPost.is_pinned && (
|
||||
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
|
||||
<Pin className="h-3 w-3 mr-1" />
|
||||
置顶
|
||||
</Badge>
|
||||
)}
|
||||
{selectedPost.is_featured && (
|
||||
<Badge variant="outline" className="text-purple-600 border-purple-200 bg-purple-50">
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
精华
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-2">{selectedPost.title}</h3>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">{selectedPost.content}</p>
|
||||
{/* 帖子内容 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold text-slate-900 leading-tight">{selectedPost.title}</h3>
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-slate-700 whitespace-pre-wrap leading-relaxed text-base">
|
||||
{selectedPost.content?.split(/@(\S+)/g).map((part, i) =>
|
||||
i % 2 === 1 ? <span key={i} className="text-blue-600 font-medium">@{part}</span> : part
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* 投票信息(从 segments 解析) */}
|
||||
{selectedPost.segments?.filter(s => s.type === 'vote').map((seg, i) => (
|
||||
<div key={i} className="mt-3 p-4 rounded-lg border border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-3">
|
||||
<span>📊 投票</span>
|
||||
</div>
|
||||
{seg.data?.options?.map((opt: any, j: number) => (
|
||||
<div key={j} className="py-1.5 text-slate-600">
|
||||
{j + 1}. {opt.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
||||
<span>👍 {selectedPost.like_count} 点赞</span>
|
||||
<span>💬 {selectedPost.comment_count} 评论</span>
|
||||
<span>👁 {selectedPost.view_count} 浏览</span>
|
||||
{/* 图片展示 */}
|
||||
{selectedPost.images && selectedPost.images.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<ImageIcon className="h-4 w-4 text-primary" />
|
||||
<span>图片附件 ({selectedPost.images.length}张)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{selectedPost.images.map((img, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative aspect-square rounded-xl overflow-hidden bg-slate-100 ring-1 ring-slate-200 hover:ring-primary/50 transition-all"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`图片 ${index + 1}`}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%239ca3af" stroke-width="1"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" ry="2"/%3E%3Ccircle cx="8.5" cy="8.5" r="1.5"/%3E%3Cpolyline points="21 15 16 10 5 21"/%3E%3C/svg%3E';
|
||||
(e.target as HTMLImageElement).classList.add('p-4');
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签展示 */}
|
||||
{selectedPost.tags && selectedPost.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPost.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="bg-slate-100 text-slate-700 hover:bg-slate-200">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className="flex items-center gap-6 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<div className="p-2 bg-rose-100 rounded-lg">
|
||||
<ThumbsUp className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-900">{selectedPost.likes_count}</div>
|
||||
<div className="text-xs text-slate-500">点赞</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<MessageCircle className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-900">{selectedPost.comments_count}</div>
|
||||
<div className="text-xs text-slate-500">评论</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<EyeIcon className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-900">{selectedPost.views_count}</div>
|
||||
<div className="text-xs text-slate-500">浏览</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
发布时间:{formatDate(selectedPost.created_at)}
|
||||
{/* 时间信息 */}
|
||||
<div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>发布时间:{formatDate(selectedPost.created_at)}</span>
|
||||
{selectedPost.group_name && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
圈子:{selectedPost.group_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selectedPost.updated_at !== selectedPost.created_at && (
|
||||
<span>更新于:{formatDate(selectedPost.updated_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
826
src/pages/Reports.tsx
Normal file
826
src/pages/Reports.tsx
Normal file
@@ -0,0 +1,826 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { reportsApi, type ReportListItem, type ReportDetail, type ReportQueryParams, type ReportStatus, type ReportTargetType } from '@/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Flag,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Filter,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import type { PaginatedResponse } from '@/types'
|
||||
|
||||
// 安全的日期格式化函数,处理无效日期
|
||||
function safeFormat(dateValue: string | number | Date | null | undefined, formatStr: string): string {
|
||||
if (!dateValue) return '-'
|
||||
const date = new Date(dateValue)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
return format(date, formatStr)
|
||||
}
|
||||
|
||||
// 状态颜色映射
|
||||
const statusColors: Record<ReportStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
processing: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
resolved: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
rejected: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
}
|
||||
|
||||
// 状态文本映射
|
||||
const statusText: Record<ReportStatus, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已确认',
|
||||
rejected: '已驳回',
|
||||
}
|
||||
|
||||
// 状态图标映射
|
||||
const statusIcons: Record<ReportStatus, React.ReactNode> = {
|
||||
pending: <Clock className="h-3 w-3" />,
|
||||
processing: <AlertTriangle className="h-3 w-3" />,
|
||||
resolved: <CheckCircle2 className="h-3 w-3" />,
|
||||
rejected: <XCircle className="h-3 w-3" />,
|
||||
}
|
||||
|
||||
// 举报原因图标和颜色
|
||||
const reasonConfig: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||
spam: { icon: <Mail className="h-3 w-3" />, color: 'bg-purple-100 text-purple-700' },
|
||||
inappropriate: { icon: <AlertTriangle className="h-3 w-3" />, color: 'bg-red-100 text-red-700' },
|
||||
harassment: { icon: <MessageSquare className="h-3 w-3" />, color: 'bg-orange-100 text-orange-700' },
|
||||
misinformation: { icon: <FileText className="h-3 w-3" />, color: 'bg-amber-100 text-amber-700' },
|
||||
other: { icon: <Flag className="h-3 w-3" />, color: 'bg-gray-100 text-gray-600' },
|
||||
}
|
||||
|
||||
// 目标类型配置
|
||||
const targetTypeConfig: Record<ReportTargetType, { icon: React.ReactNode; color: string; label: string }> = {
|
||||
post: { icon: <FileText className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50', label: '帖子' },
|
||||
comment: { icon: <MessageSquare className="h-4 w-4" />, color: 'text-green-600 bg-green-50', label: '评论' },
|
||||
message: { icon: <Mail className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50', label: '消息' },
|
||||
}
|
||||
|
||||
// 举报原因文本
|
||||
const reasonText: Record<string, string> = {
|
||||
spam: '垃圾广告',
|
||||
inappropriate: '违规内容',
|
||||
harassment: '辱骂/攻击',
|
||||
misinformation: '虚假信息',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
export default function Reports() {
|
||||
// 状态
|
||||
const [reports, setReports] = useState<ReportListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(10)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
|
||||
// 统计数据
|
||||
const [stats, setStats] = useState({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
resolved: 0,
|
||||
rejected: 0,
|
||||
})
|
||||
|
||||
// 批量选择
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [isAllSelected, setIsAllSelected] = useState(false)
|
||||
|
||||
// 详情对话框
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedReport, setSelectedReport] = useState<ReportDetail | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 处理对话框
|
||||
const [handleOpen, setHandleOpen] = useState(false)
|
||||
const [handleReport, setHandleReport] = useState<ReportListItem | null>(null)
|
||||
const [handleAction, setHandleAction] = useState<'approve' | 'reject'>('approve')
|
||||
const [handleResult, setHandleResult] = useState('')
|
||||
const [handleLoading, setHandleLoading] = useState(false)
|
||||
|
||||
// 批量操作加载状态
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
// 加载举报列表
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: ReportQueryParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keyword: keyword || undefined,
|
||||
status: statusFilter !== 'all' ? (statusFilter as ReportStatus) : undefined,
|
||||
target_type: typeFilter !== 'all' ? (typeFilter as ReportTargetType) : undefined,
|
||||
}
|
||||
const response = await reportsApi.getReports(params)
|
||||
const data = response.data.data as PaginatedResponse<ReportListItem>
|
||||
setReports(data.list || [])
|
||||
setTotal(data.total || 0)
|
||||
|
||||
// 更新统计数据(仅当无筛选时)
|
||||
if (!keyword && statusFilter === 'all' && typeFilter === 'all') {
|
||||
const newStats = { pending: 0, processing: 0, resolved: 0, rejected: 0 }
|
||||
data.list?.forEach((report: ReportListItem) => {
|
||||
newStats[report.status] = (newStats[report.status] || 0) + 1
|
||||
})
|
||||
setStats(newStats)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load reports:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setSelectedIds([])
|
||||
setIsAllSelected(false)
|
||||
}
|
||||
}, [page, pageSize, keyword, statusFilter, typeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadReports()
|
||||
}, [loadReports])
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
loadReports()
|
||||
}
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = () => {
|
||||
loadReports()
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(reports.map(r => r.id))
|
||||
setIsAllSelected(true)
|
||||
} else {
|
||||
setSelectedIds([])
|
||||
setIsAllSelected(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 单选
|
||||
const handleSelect = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds([...selectedIds, id])
|
||||
} else {
|
||||
setSelectedIds(selectedIds.filter(i => i !== id))
|
||||
}
|
||||
setIsAllSelected(checked && selectedIds.length === reports.length - 1)
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (report: ReportListItem) => {
|
||||
setDetailLoading(true)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
const response = await reportsApi.getReportById(report.id)
|
||||
setSelectedReport(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load report detail:', error)
|
||||
setDetailOpen(false)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开处理对话框
|
||||
const openHandleDialog = (report: ReportListItem) => {
|
||||
setHandleReport(report)
|
||||
setHandleAction('approve')
|
||||
setHandleResult('')
|
||||
setHandleOpen(true)
|
||||
}
|
||||
|
||||
// 提交处理
|
||||
const submitHandle = async () => {
|
||||
if (!handleReport) return
|
||||
setHandleLoading(true)
|
||||
try {
|
||||
await reportsApi.handleReport(handleReport.id, {
|
||||
action: handleAction,
|
||||
result: handleResult || undefined,
|
||||
})
|
||||
setHandleOpen(false)
|
||||
loadReports()
|
||||
} catch (error) {
|
||||
console.error('Failed to handle report:', error)
|
||||
} finally {
|
||||
setHandleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量处理
|
||||
const handleBatchAction = async (action: 'approve' | 'reject') => {
|
||||
if (selectedIds.length === 0) return
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
await reportsApi.batchHandle({
|
||||
ids: selectedIds,
|
||||
action,
|
||||
})
|
||||
loadReports()
|
||||
} catch (error) {
|
||||
console.error('Failed to batch handle:', error)
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between bg-gradient-to-r from-slate-50 to-white p-6 rounded-2xl border border-slate-200/60 shadow-sm">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 rounded-xl">
|
||||
<Flag className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">举报管理</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">处理用户举报的帖子、评论和消息,维护社区环境</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="bg-white hover:bg-slate-50 border-slate-200"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-l-4 border-l-yellow-400 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">待处理</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-yellow-100 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-blue-400 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">处理中</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.processing}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-emerald-400 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">已确认</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.resolved}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-emerald-100 rounded-lg">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-gray-400 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">已驳回</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stats.rejected}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 筛选卡片 */}
|
||||
<Card className="border-slate-200/60 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
筛选条件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索举报内容、举报人..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="目标类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="post">帖子</SelectItem>
|
||||
<SelectItem value="comment">评论</SelectItem>
|
||||
<SelectItem value="message">消息</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="处理状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="resolved">已确认</SelectItem>
|
||||
<SelectItem value="rejected">已驳回</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch} className="bg-slate-900 hover:bg-slate-800">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 举报列表 */}
|
||||
<Card className="border-slate-200/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">举报列表</CardTitle>
|
||||
<CardDescription className="text-sm mt-1">
|
||||
共 <span className="font-semibold text-slate-900">{total}</span> 条记录
|
||||
{selectedIds.length > 0 && (
|
||||
<span className="ml-2 text-blue-600 font-medium">
|
||||
· 已选择 {selectedIds.length} 条
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBatchAction('approve')}
|
||||
disabled={batchLoading}
|
||||
className="border-emerald-200 text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
{batchLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
批量确认
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBatchAction('reject')}
|
||||
disabled={batchLoading}
|
||||
className="border-red-200 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
批量驳回
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
className="data-[state=checked]:bg-slate-900 data-[state=checked]:border-slate-900"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">举报人</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">目标类型</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">举报原因</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">状态</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">举报时间</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||||
<span className="text-slate-500">加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : reports.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="p-3 bg-slate-100 rounded-full">
|
||||
<Flag className="h-6 w-6 text-slate-400" />
|
||||
</div>
|
||||
<span className="text-slate-500 font-medium">暂无举报记录</span>
|
||||
<span className="text-slate-400 text-sm">所有举报都已处理完毕</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
reports.map((report) => {
|
||||
const reasonCfg = reasonConfig[report.reason] || reasonConfig.other
|
||||
const typeCfg = targetTypeConfig[report.target_type] || {
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
color: 'text-slate-600 bg-slate-50',
|
||||
label: report.target_type || '未知',
|
||||
}
|
||||
return (
|
||||
<TableRow
|
||||
key={report.id}
|
||||
className={`group transition-colors ${
|
||||
selectedIds.includes(report.id) ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'
|
||||
}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(report.id)}
|
||||
onCheckedChange={(checked) => handleSelect(report.id, !!checked)}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar className="h-9 w-9 ring-2 ring-white">
|
||||
<AvatarImage src={report.reporter?.avatar} />
|
||||
<AvatarFallback className="bg-slate-100 text-slate-600 text-sm font-medium">
|
||||
{report.reporter?.nickname?.[0] || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<span className="font-medium text-slate-900">
|
||||
{report.reporter?.nickname || '未知用户'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex items-center gap-2 px-2.5 py-1 rounded-md w-fit ${typeCfg.color}`}>
|
||||
{typeCfg.icon}
|
||||
<span className="text-sm font-medium">{typeCfg.label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={`flex items-center gap-2 w-fit px-2 py-0.5 rounded text-sm font-medium ${reasonCfg.color}`}>
|
||||
{reasonCfg.icon}
|
||||
<span>{reasonText[report.reason] || report.reason}</span>
|
||||
</div>
|
||||
{report.description && (
|
||||
<p className="text-xs text-slate-500 line-clamp-1 max-w-[200px]">
|
||||
{report.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 border ${statusColors[report.status]}`}
|
||||
>
|
||||
{statusIcons[report.status]}
|
||||
<span className="font-medium">{statusText[report.status]}</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-600 font-mono">
|
||||
{safeFormat(report.created_at, 'yyyy-MM-dd HH:mm')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(report)}
|
||||
className="h-8 w-8 p-0 hover:bg-slate-100"
|
||||
>
|
||||
<Eye className="h-4 w-4 text-slate-600" />
|
||||
</Button>
|
||||
{report.status === 'pending' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openHandleDialog(report)}
|
||||
className="h-8 w-8 p-0 hover:bg-yellow-50"
|
||||
>
|
||||
<Flag className="h-4 w-4 text-yellow-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-slate-100"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 text-slate-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-slate-500">
|
||||
显示第 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} 条
|
||||
</div>
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={Math.ceil(total / pageSize)}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flag className="h-5 w-5 text-red-500" />
|
||||
举报详情
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
|
||||
<span className="text-slate-500">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedReport ? (
|
||||
<div className="space-y-5">
|
||||
{/* 基本信息 */}
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<h4 className="text-sm font-semibold text-slate-700 mb-3">基本信息</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">举报人</Label>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={selectedReport.reporter?.avatar} />
|
||||
<AvatarFallback className="text-xs">{selectedReport.reporter?.nickname?.[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{selectedReport.reporter?.nickname}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">举报时间</Label>
|
||||
<p className="text-sm font-mono text-slate-700 mt-1.5">
|
||||
{safeFormat(selectedReport.created_at, 'yyyy-MM-dd HH:mm:ss')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">目标类型</Label>
|
||||
{(() => {
|
||||
const detailTypeCfg = targetTypeConfig[selectedReport.target_type] || {
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
color: 'text-slate-600 bg-slate-50',
|
||||
label: selectedReport.target_type || '未知',
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center gap-2 mt-1.5 w-fit px-2 py-0.5 rounded ${detailTypeCfg.color}`}>
|
||||
{detailTypeCfg.icon}
|
||||
<span className="text-sm font-medium">{detailTypeCfg.label}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">处理状态</Label>
|
||||
<div className="mt-1.5">
|
||||
<Badge className={`flex items-center gap-1.5 ${statusColors[selectedReport.status]}`}>
|
||||
{statusIcons[selectedReport.status]}
|
||||
<span className="font-medium">{statusText[selectedReport.status]}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 举报原因 */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">举报原因</Label>
|
||||
<div className={`flex items-center gap-2 mt-2 w-fit px-3 py-1.5 rounded-lg ${reasonConfig[selectedReport.reason]?.color || reasonConfig.other.color}`}>
|
||||
{reasonConfig[selectedReport.reason]?.icon || reasonConfig.other.icon}
|
||||
<span className="text-sm font-medium">
|
||||
{reasonText[selectedReport.reason] || selectedReport.reason}
|
||||
</span>
|
||||
</div>
|
||||
{selectedReport.description && (
|
||||
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<Label className="text-xs text-amber-700">详细描述</Label>
|
||||
<p className="text-sm text-amber-900 mt-1">{selectedReport.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 被举报内容 */}
|
||||
{selectedReport.target_content && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<h4 className="text-sm font-semibold text-slate-700 mb-3">被举报内容</h4>
|
||||
{selectedReport.target_content.title && (
|
||||
<p className="font-semibold text-slate-900 mb-2">{selectedReport.target_content.title}</p>
|
||||
)}
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{selectedReport.target_content.content}</p>
|
||||
{selectedReport.target_content.images && selectedReport.target_content.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedReport.target_content.images.map((img, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-20 h-20 object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity ring-1 ring-slate-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 处理信息 */}
|
||||
{selectedReport.handler && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<h4 className="text-sm font-semibold text-slate-700 mb-3">处理信息</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={selectedReport.handler.avatar} />
|
||||
<AvatarFallback className="text-xs">{selectedReport.handler.nickname?.[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{selectedReport.handler.nickname}</p>
|
||||
{selectedReport.handled_at && (
|
||||
<p className="text-xs text-slate-500 font-mono mt-0.5">
|
||||
{safeFormat(selectedReport.handled_at, 'yyyy-MM-dd HH:mm')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedReport.result && (
|
||||
<div className="mt-3 p-3 bg-white border border-slate-200 rounded-lg">
|
||||
<Label className="text-xs text-slate-500">处理结果</Label>
|
||||
<p className="text-sm text-slate-700 mt-1">{selectedReport.result}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 处理对话框 */}
|
||||
<Dialog open={handleOpen} onOpenChange={setHandleOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flag className="h-5 w-5 text-yellow-500" />
|
||||
处理举报
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
请根据举报内容选择处理方式
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={handleAction === 'approve' ? 'default' : 'outline'}
|
||||
className={`h-20 flex-col gap-2 ${
|
||||
handleAction === 'approve'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'border-slate-200 hover:bg-red-50 hover:text-red-600 hover:border-red-200'
|
||||
}`}
|
||||
onClick={() => setHandleAction('approve')}
|
||||
>
|
||||
<Check className="h-5 w-5" />
|
||||
<span>确认违规</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={handleAction === 'reject' ? 'default' : 'outline'}
|
||||
className={`h-20 flex-col gap-2 ${
|
||||
handleAction === 'reject'
|
||||
? 'bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'border-slate-200 hover:bg-emerald-50 hover:text-emerald-600 hover:border-emerald-200'
|
||||
}`}
|
||||
onClick={() => setHandleAction('reject')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span>驳回举报</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">处理说明(选填)</Label>
|
||||
<Textarea
|
||||
value={handleResult}
|
||||
onChange={(e) => setHandleResult(e.target.value)}
|
||||
placeholder="请输入处理说明,将发送给举报人..."
|
||||
rows={3}
|
||||
className="mt-2 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setHandleOpen(false)} className="border-slate-200">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitHandle}
|
||||
disabled={handleLoading}
|
||||
className={handleAction === 'approve' ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'}
|
||||
>
|
||||
{handleLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
确认提交
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { RefreshCw, Loader2, Users, Shield, Check } from 'lucide-react'
|
||||
import { RefreshCw, Loader2, Users, Check } from 'lucide-react'
|
||||
|
||||
// 角色颜色映射
|
||||
const roleColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { User, Role, PaginatedResponse } from '@/types'
|
||||
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType } from '@/api'
|
||||
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType, type UserDevice } from '@/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import UserDetail from '@/components/UserDetail'
|
||||
import UserRoleDialog from '@/components/UserRoleDialog'
|
||||
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Loader2 } from 'lucide-react'
|
||||
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Smartphone, Loader2, Copy, Check } from 'lucide-react'
|
||||
|
||||
// 状态颜色映射
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -75,6 +75,13 @@ export default function Users() {
|
||||
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||
|
||||
// 设备对话框
|
||||
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false)
|
||||
const [deviceUser, setDeviceUser] = useState<User | null>(null)
|
||||
const [devices, setDevices] = useState<UserDevice[]>([])
|
||||
const [devicesLoading, setDevicesLoading] = useState(false)
|
||||
const [copiedToken, setCopiedToken] = useState<string | null>(null)
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -105,10 +112,12 @@ export default function Users() {
|
||||
const response = await rolesApi.getRoles()
|
||||
const roles = response.data.data || []
|
||||
setAllRoles(roles.map(r => ({
|
||||
id: r.name, // 使用 name 作为 id
|
||||
id: r.name,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
created_at: '', // 后端列表接口不返回这些字段
|
||||
priority: r.priority,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
})))
|
||||
} catch (error) {
|
||||
@@ -147,6 +156,35 @@ export default function Users() {
|
||||
setRoleDialogOpen(true)
|
||||
}
|
||||
|
||||
// 打开设备对话框(查询 registration_id)
|
||||
const handleOpenDeviceDialog = async (user: User) => {
|
||||
setDeviceUser(user)
|
||||
setDeviceDialogOpen(true)
|
||||
setDevicesLoading(true)
|
||||
setDevices([])
|
||||
try {
|
||||
const response = await usersApi.getUserDevices(user.id)
|
||||
setDevices(response.data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load user devices:', error)
|
||||
setDevices([])
|
||||
} finally {
|
||||
setDevicesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制 registration_id
|
||||
const handleCopyToken = async (token: string) => {
|
||||
if (!token) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(token)
|
||||
setCopiedToken(token)
|
||||
window.setTimeout(() => setCopiedToken((cur) => (cur === token ? null : cur)), 1500)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分配角色
|
||||
const handleAssignRole = async (userId: string, role: string) => {
|
||||
await usersApi.assignRole(userId, role)
|
||||
@@ -363,6 +401,14 @@ export default function Users() {
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDeviceDialog(user)}
|
||||
title="查看设备 RegistrationID"
|
||||
>
|
||||
<Smartphone className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.status === 'active' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -453,6 +499,87 @@ export default function Users() {
|
||||
onRemoveRole={handleRemoveRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 设备 RegistrationID 对话框 */}
|
||||
<Dialog open={deviceDialogOpen} onOpenChange={setDeviceDialogOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>用户设备 / RegistrationID</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deviceUser
|
||||
? `用户:${deviceUser.nickname || deviceUser.username}(@${deviceUser.username})的注册设备及推送 RegistrationID`
|
||||
: '用户的注册设备及推送 RegistrationID'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{devicesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">该用户暂无注册设备</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[120px]">设备类型</TableHead>
|
||||
<TableHead>设备名称 / ID</TableHead>
|
||||
<TableHead>RegistrationID</TableHead>
|
||||
<TableHead className="w-[80px]">状态</TableHead>
|
||||
<TableHead className="w-[160px]">最后使用</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{d.device_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{d.device_name || '-'}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">{d.device_id}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{d.push_token ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="flex-1 text-xs font-mono break-all bg-muted px-2 py-1 rounded">
|
||||
{d.push_token}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(d.push_token!)}
|
||||
title="复制 RegistrationID"
|
||||
>
|
||||
{copiedToken === d.push_token ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">未上报</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={d.is_active ? 'bg-green-500' : 'bg-gray-400'}>
|
||||
{d.is_active ? '活跃' : '停用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{d.last_used_at ? formatDate(d.last_used_at) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
457
src/pages/Verifications.tsx
Normal file
457
src/pages/Verifications.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { verificationsApi, type VerificationListItem, type VerificationDetail, type VerificationStatus } from '@/api'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||
import { Search, RefreshCw, Eye, CheckCircle, XCircle, Loader2 } from 'lucide-react'
|
||||
|
||||
const statusColors: Record<VerificationStatus, string> = {
|
||||
not_submitted: 'bg-gray-500',
|
||||
pending: 'bg-yellow-500',
|
||||
approved: 'bg-green-500',
|
||||
rejected: 'bg-red-500',
|
||||
}
|
||||
|
||||
const statusText: Record<VerificationStatus, string> = {
|
||||
not_submitted: '未提交',
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
const identityText: Record<string, string> = {
|
||||
student: '学生',
|
||||
teacher: '教师',
|
||||
staff: '职工',
|
||||
alumni: '校友',
|
||||
external: '外部人员',
|
||||
general: '普通用户',
|
||||
}
|
||||
|
||||
export default function Verifications() {
|
||||
const [verifications, setVerifications] = useState<VerificationListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedVerification, setSelectedVerification] = useState<VerificationDetail | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = useState(false)
|
||||
const [reviewLoading, setReviewLoading] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
|
||||
const loadVerifications = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keyword: keyword || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter as VerificationStatus : undefined,
|
||||
}
|
||||
const response = await verificationsApi.getVerifications(params)
|
||||
setVerifications(response.data.data.list || [])
|
||||
setTotal(response.data.data.total || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load verifications:', error)
|
||||
setVerifications([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, keyword, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadVerifications()
|
||||
}, [loadVerifications])
|
||||
|
||||
const handleViewDetail = async (verification: VerificationListItem) => {
|
||||
setDetailLoading(true)
|
||||
setDetailOpen(true)
|
||||
try {
|
||||
const response = await verificationsApi.getVerificationById(verification.id)
|
||||
setSelectedVerification(response.data.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load verification detail:', error)
|
||||
setSelectedVerification(null)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenReviewDialog = () => {
|
||||
setRejectReason('')
|
||||
setReviewDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleReview = async (approve: boolean) => {
|
||||
if (!selectedVerification) return
|
||||
|
||||
if (!approve && !rejectReason.trim()) {
|
||||
alert('请填写拒绝原因')
|
||||
return
|
||||
}
|
||||
|
||||
setReviewLoading(true)
|
||||
try {
|
||||
await verificationsApi.reviewVerification(selectedVerification.id, {
|
||||
approve,
|
||||
reject_reason: approve ? undefined : rejectReason,
|
||||
})
|
||||
setReviewDialogOpen(false)
|
||||
setDetailOpen(false)
|
||||
loadVerifications()
|
||||
} catch (error) {
|
||||
console.error('Failed to review verification:', error)
|
||||
alert('审核失败')
|
||||
} finally {
|
||||
setReviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
loadVerifications()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadVerifications()
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">身份认证管理</h1>
|
||||
<p className="text-muted-foreground">审核用户身份认证申请</p>
|
||||
</div>
|
||||
<Button onClick={handleRefresh} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>筛选条件</CardTitle>
|
||||
<CardDescription>根据条件筛选认证申请</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex gap-2 flex-1 min-w-[300px]">
|
||||
<Input
|
||||
placeholder="搜索用户名、真实姓名..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待审核</SelectItem>
|
||||
<SelectItem value="approved">已通过</SelectItem>
|
||||
<SelectItem value="rejected">已拒绝</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>认证申请列表</CardTitle>
|
||||
<CardDescription>共 {total} 条申请</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>身份类型</TableHead>
|
||||
<TableHead>真实姓名</TableHead>
|
||||
<TableHead>学号/工号</TableHead>
|
||||
<TableHead>部门</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>提交时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-2">加载中...</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : verifications.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
verifications.map((verification) => (
|
||||
<TableRow
|
||||
key={verification.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleViewDetail(verification)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={verification.avatar} alt={verification.nickname} />
|
||||
<AvatarFallback>{getInitials(verification.nickname)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{verification.nickname}</p>
|
||||
<p className="text-xs text-muted-foreground">@{verification.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{identityText[verification.identity] || verification.identity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{verification.real_name}</TableCell>
|
||||
<TableCell>{verification.student_id || verification.employee_id || '-'}</TableCell>
|
||||
<TableCell>{verification.department || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[verification.status]}>
|
||||
{statusText[verification.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(verification.created_at)}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(verification)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<PaginationNavigator
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 详情对话框 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>认证详情</DialogTitle>
|
||||
<DialogDescription>查看用户认证申请详情</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedVerification ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={selectedVerification.avatar} alt={selectedVerification.nickname} />
|
||||
<AvatarFallback>{getInitials(selectedVerification.nickname)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{selectedVerification.nickname}</p>
|
||||
<p className="text-sm text-muted-foreground">@{selectedVerification.username}</p>
|
||||
</div>
|
||||
<Badge className={`ml-auto ${statusColors[selectedVerification.status]}`}>
|
||||
{statusText[selectedVerification.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">身份类型</p>
|
||||
<p className="font-medium">{identityText[selectedVerification.identity] || selectedVerification.identity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">真实姓名</p>
|
||||
<p className="font-medium">{selectedVerification.real_name}</p>
|
||||
</div>
|
||||
{selectedVerification.student_id && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">学号</p>
|
||||
<p className="font-medium">{selectedVerification.student_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVerification.employee_id && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">工号</p>
|
||||
<p className="font-medium">{selectedVerification.employee_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVerification.department && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">部门/院系</p>
|
||||
<p className="font-medium">{selectedVerification.department}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground mb-2">证明材料</p>
|
||||
<div className="border rounded-lg p-2">
|
||||
<img
|
||||
src={selectedVerification.proof_materials}
|
||||
alt="证明材料"
|
||||
className="max-w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedVerification.reviewed_at && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-muted-foreground">审核时间</p>
|
||||
<p className="font-medium">{formatDate(selectedVerification.reviewed_at)}</p>
|
||||
</div>
|
||||
{selectedVerification.reviewer_name && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">审核人</p>
|
||||
<p className="font-medium">{selectedVerification.reviewer_name}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedVerification.reject_reason && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">拒绝原因</p>
|
||||
<p className="font-medium text-red-600">{selectedVerification.reject_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedVerification.status === 'pending' && (
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleOpenReviewDialog}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
审核认证
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">加载失败</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 审核对话框 */}
|
||||
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>审核认证申请</DialogTitle>
|
||||
<DialogDescription>请选择通过或拒绝该认证申请</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">拒绝原因(拒绝时必填)</p>
|
||||
<Textarea
|
||||
placeholder="请输入拒绝原因..."
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleReview(false)}
|
||||
disabled={reviewLoading}
|
||||
className="text-red-600 border-red-600 hover:bg-red-50"
|
||||
>
|
||||
{reviewLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <XCircle className="h-4 w-4 mr-2" />}
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleReview(true)}
|
||||
disabled={reviewLoading}
|
||||
>
|
||||
{reviewLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <CheckCircle className="h-4 w-4 mr-2" />}
|
||||
通过
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,6 +103,11 @@ const Roles = lazy(() => import('@/pages/Roles'))
|
||||
const Posts = lazy(() => import('@/pages/Posts'))
|
||||
const Comments = lazy(() => import('@/pages/Comments'))
|
||||
const Groups = lazy(() => import('@/pages/Groups'))
|
||||
const Channels = lazy(() => import('@/pages/Channels'))
|
||||
const Reports = lazy(() => import('@/pages/Reports'))
|
||||
const MaterialSubjects = lazy(() => import('@/pages/MaterialSubjects'))
|
||||
const Materials = lazy(() => import('@/pages/Materials'))
|
||||
const Verifications = lazy(() => import('@/pages/Verifications'))
|
||||
const Forbidden = lazy(() => import('@/pages/Forbidden'))
|
||||
const NotFound = lazy(() => import('@/pages/NotFound'))
|
||||
|
||||
@@ -191,6 +196,56 @@ export const router = createBrowserRouter([
|
||||
</ModeratorGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
element: (
|
||||
<ModeratorGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Reports />
|
||||
</Suspense>
|
||||
</ModeratorGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'channels',
|
||||
element: (
|
||||
<AdminGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Channels />
|
||||
</Suspense>
|
||||
</AdminGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'materials/subjects',
|
||||
element: (
|
||||
<AdminGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<MaterialSubjects />
|
||||
</Suspense>
|
||||
</AdminGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'materials',
|
||||
element: (
|
||||
<AdminGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Materials />
|
||||
</Suspense>
|
||||
</AdminGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'verifications',
|
||||
element: (
|
||||
<AdminGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Verifications />
|
||||
</Suspense>
|
||||
</AdminGuard>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,9 @@ export type CommentStatus = 'pending' | 'published' | 'rejected' | 'deleted'
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
priority: number
|
||||
permissions?: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -45,20 +47,45 @@ export interface User {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 帖子图片
|
||||
export interface PostImage {
|
||||
id?: string
|
||||
url: string
|
||||
thumbnail_url?: string
|
||||
preview_url?: string
|
||||
preview_url_large?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
// 消息段类型
|
||||
export interface MessageSegment {
|
||||
type: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
// 帖子
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
segments?: MessageSegment[]
|
||||
author: User
|
||||
status: PostStatus
|
||||
like_count: number
|
||||
comment_count: number
|
||||
view_count: number
|
||||
likes_count: number
|
||||
comments_count: number
|
||||
views_count: number
|
||||
images?: PostImage[]
|
||||
is_vote?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 评论图片
|
||||
export interface CommentImage {
|
||||
url: string
|
||||
}
|
||||
|
||||
// 评论
|
||||
export interface Comment {
|
||||
id: string
|
||||
@@ -67,7 +94,12 @@ export interface Comment {
|
||||
post_id: string
|
||||
post_title?: string
|
||||
status: CommentStatus
|
||||
like_count: number
|
||||
reject_reason?: string
|
||||
reviewed_at?: string
|
||||
reviewed_by?: string
|
||||
likes_count: number
|
||||
replies_count?: number
|
||||
images?: CommentImage[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "react", "react-dom"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
@@ -17,7 +17,6 @@
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.9.3"}
|
||||
@@ -10,12 +10,5 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user