Compare commits

17 Commits
dev ... master

Author SHA1 Message Date
lafay
1ef3a55359 feat(users): add user device management and registration ID display
All checks were successful
Admin CI / build-and-push-web (push) Successful in 2m27s
Add UserDevice interface and API endpoint for fetching user devices. Implement device dialog in the users page with copy-to-clipboard functionality for push tokens and registration IDs.
2026-06-18 20:08:06 +08:00
lafay
4d25bf00ba feat(posts): add @mention highlighting and vote segment display
All checks were successful
Admin CI / build-and-push-web (push) Successful in 2m59s
- Parse post content to highlight @mentions with blue styling
- Render vote segments from post data with formatted options display
- Add MessageSegment interface and segments/is_vote fields to Post type
- Update API base URL and branding from Carrot BBS to WithYou
2026-04-25 13:01:36 +08:00
lafay
2d1dc3673a feat(comments): add moderation functionality and batch operations
All checks were successful
Admin CI / build-and-push-web (push) Successful in 1m35s
Add API methods, type definitions, and UI components for approving or
rejecting comments individually and in bulk. Update the Comment interface
to include moderation tracking fields and integrate admin controls into
the comments management page.
2026-04-14 02:13:39 +08:00
lafay
f20140f4ea refactor(posts): use PostImage type for images in PostDetail 2026-04-10 13:20:00 +08:00
lafay
0b16613be4 fix(materials): handle API response format and color rendering
Some checks failed
Admin CI / build-and-push-web (push) Failing after 1m43s
Update the subjects API to handle the nested list structure in the response, and fix color rendering by using direct inline styles instead of CSS custom properties that weren't being properly applied
2026-04-10 01:16:38 +08:00
lafay
4991dae691 feat(types): add image types and enhance Post/Comment interfaces
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m17s
Add PostImage and CommentImage types for handling post/comment images. Update
Post interface with likes_count, comments_count, views_count, and images.
Update Comment interface with likes_count, replies_count, and images. Add
display_name and priority to Role type. Include dynamic color CSS utilities
and UI enhancements for Comments/Posts pages. Add defensive empty array
fallback for subjects data.
2026-04-10 01:11:15 +08:00
lafay
7e91c81d86 refactor(api): remove unused User import from verifications module
All checks were successful
Admin CI / build-and-push-web (push) Successful in 1m58s
2026-04-08 16:40:19 +08:00
lafay
0f86e5b9ee feat(verifications): add identity verification management for admin panel
Some checks failed
Admin CI / build-and-push-web (push) Failing after 6m11s
Add new verifications module including API endpoints for retrieving and reviewing user identity verifications, along with corresponding admin page, routing, and sidebar navigation entry.
2026-04-05 20:26:57 +08:00
lafay
00b79681a0 refactor(types): add TypeScript type assertion for channel API response
All checks were successful
Admin CI / build-and-push-web (push) Successful in 4m53s
Add explicit type casting to handle potential channel data response formats, improving type safety and IDE autocomplete for Channel data structures.
2026-04-04 00:43:09 +08:00
lafay
bfb018b766 fix(api): handle varying API response formats in channels data retrieval
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m29s
Add defensive check to handle both direct array responses and nested list format from API, ensuring consistent channel data display regardless of response structure.
2026-04-04 00:38:52 +08:00
lafay
740bfc76fa build(config): update API endpoint to production URL and remove dev proxy
All checks were successful
Admin CI / build-and-push-web (push) Successful in 1m52s
Changes the default API base URL from local development server (127.0.0.1:8080) to the production endpoint (bbs.littlelan.cn) and removes the Vite dev server proxy configuration, as the application now connects directly to the production API.
2026-04-04 00:31:53 +08:00
lafay
dacad1587a refactor(ui): clean up unused imports and variables in UI components
All checks were successful
Admin CI / build-and-push-web (push) Successful in 3m48s
2026-04-04 00:25:18 +08:00
lafay
ff77b46f79 build: add Docker and nginx configuration for deployment
Some checks failed
Admin CI / build-and-push-web (push) Failing after 2m50s
Added Dockerfile.web for containerizing the web application and nginx.conf
for serving the application in production. Also included .gitea/ directory
for CI/CD configuration.
2026-04-04 00:20:45 +08:00
lafay
3e06cfde76 Enhance Reports page with safe date formatting, improved status colors and icons, and add statistics display for report statuses. 2026-03-30 18:01:38 +08:00
lafay
e7c9ee5159 Add reports management to Sidebar and API index; introduce new route for reports with appropriate guard 2026-03-29 20:13:32 +08:00
lafay
c71e6c6587 Add channels management to Sidebar and API index; update MaterialSubjects for icon handling 2026-03-26 01:27:59 +08:00
lafay
2910dd6ccf Enhance Sidebar with new menu items for materials management and update TypeScript configuration. Add subjects and materials APIs to the API index. Introduce new routes for materials and subjects with appropriate guards. 2026-03-25 20:44:27 +08:00
31 changed files with 4362 additions and 238 deletions

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 开发环境 API 地址
VITE_API_BASE_URL=https://withyou.littlelan.cn/api/v1

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

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
const apiClient = axios.create({ 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, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,5 +1,5 @@
import apiClient from './client' import apiClient from './client'
import type { Comment, ApiResponse, PaginatedResponse } from '@/types' import type { Comment, ApiResponse, PaginatedResponse, CommentImage } from '@/types'
// 评论查询参数 // 评论查询参数
export interface CommentQueryParams { export interface CommentQueryParams {
@@ -24,6 +24,8 @@ export interface CommentDetail extends Comment {
post?: { post?: {
id: string id: string
title: string title: string
content?: string
images?: CommentImage[]
} }
ip_address?: string ip_address?: string
device_info?: string device_info?: string
@@ -31,23 +33,24 @@ export interface CommentDetail extends Comment {
// 评论管理API // 评论管理API
export const commentsApi = { export const commentsApi = {
// 获取评论列表(分页、搜索、筛选)
getComments: (params: CommentQueryParams) => getComments: (params: CommentQueryParams) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }), apiClient.get<ApiResponse<PaginatedResponse<Comment>>>('/admin/comments', { params }),
// 获取评论详情
getCommentById: (id: string) => getCommentById: (id: string) =>
apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`), apiClient.get<ApiResponse<CommentDetail>>(`/admin/comments/${id}`),
// 删除评论
deleteComment: (id: string) => deleteComment: (id: string) =>
apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`), apiClient.delete<ApiResponse<void>>(`/admin/comments/${id}`),
// 批量删除评论
batchDeleteComments: (ids: string[]) => batchDeleteComments: (ids: string[]) =>
apiClient.post<ApiResponse<void>>('/admin/comments/batch-delete', { ids }), 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'>) => getCommentsByPost: (postId: string, params?: Omit<CommentQueryParams, 'post_id'>) =>
apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }), apiClient.get<ApiResponse<PaginatedResponse<Comment>>>(`/admin/comments/post/${postId}`, { params }),
} }

View File

@@ -1,7 +1,7 @@
export { default as apiClient } from './client' export { default as apiClient } from './client'
export { authApi } from './auth' export { authApi } from './auth'
export { usersApi } from './users' export { usersApi } from './users'
export type { UserQueryParams, UserDetail } from './users' export type { UserQueryParams, UserDetail, UserDevice } from './users'
export { rolesApi } from './roles' export { rolesApi } from './roles'
export type { Permission, RoleInfo, RoleDetail } from './roles' export type { Permission, RoleInfo, RoleDetail } from './roles'
export { postsApi } from './posts' export { postsApi } from './posts'
@@ -10,5 +10,13 @@ export { commentsApi } from './comments'
export type { CommentQueryParams, CommentDetail } from './comments' export type { CommentQueryParams, CommentDetail } from './comments'
export { groupsApi } from './groups' export { groupsApi } from './groups'
export type { GroupQueryParams, GroupDetail, GroupMember } from './groups' export type { GroupQueryParams, GroupDetail, GroupMember } from './groups'
export { channelsApi } from './channels'
export type { Channel, ChannelUpsertRequest } from './channels'
export { dashboardApi } from './dashboard' export { dashboardApi } from './dashboard'
export type { DashboardStatsData, ActivityTrendData, ContentStatsData, PendingContent } 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
View 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

View File

@@ -1,5 +1,5 @@
import apiClient from './client' import apiClient from './client'
import type { Post, ApiResponse, PaginatedResponse } from '@/types' import type { Post, PostImage, ApiResponse, PaginatedResponse } from '@/types'
// 帖子查询参数 // 帖子查询参数
export interface PostQueryParams { export interface PostQueryParams {
@@ -16,7 +16,7 @@ export interface PostQueryParams {
export interface PostDetail extends Post { export interface PostDetail extends Post {
group_id?: string group_id?: string
group_name?: string group_name?: string
images?: string[] images?: PostImage[]
tags?: string[] tags?: string[]
is_pinned: boolean is_pinned: boolean
is_featured: boolean is_featured: boolean

106
src/api/reports.ts Normal file
View 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;

View File

@@ -17,6 +17,20 @@ export interface UserDetail extends User {
following_count: number 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 // 用户管理API
export const usersApi = { export const usersApi = {
// 获取用户列表(分页、搜索、筛选) // 获取用户列表(分页、搜索、筛选)
@@ -27,6 +41,10 @@ export const usersApi = {
getUserById: (id: string) => getUserById: (id: string) =>
apiClient.get<ApiResponse<UserDetail>>(`/admin/users/${id}`), 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') => updateUserStatus: (id: string, status: 'active' | 'banned') =>
apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }), apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }),

53
src/api/verifications.ts Normal file
View 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

View File

@@ -10,15 +10,30 @@ import {
Users as UsersIcon, Users as UsersIcon,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
FolderOpen,
BookOpen,
ChevronDown,
Layers,
Flag,
ShieldCheck,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useState } from 'react'
interface SidebarProps { interface SidebarProps {
collapsed: boolean collapsed: boolean
onCollapse: (collapsed: boolean) => void 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', title: 'Dashboard',
path: '/dashboard', path: '/dashboard',
@@ -55,23 +70,131 @@ const menuItems = [
icon: UsersIcon, icon: UsersIcon,
roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MODERATOR], 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) { 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) const toggleExpand = (title: string) => {
console.log('[Sidebar DEBUG] ROLES constants:', ROLES) setExpandedItems((prev) =>
prev.includes(title) ? prev.filter((t) => t !== title) : [...prev, title]
)
}
// 过滤出当前用户有权访问的菜单 // 过滤出当前用户有权访问的菜单
const visibleMenuItems = menuItems.filter(item => { const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
const hasAccess = hasAnyRole(item.roles) return items
console.log(`[Sidebar DEBUG] Menu "${item.title}" - required roles:`, item.roles, '- hasAccess:', hasAccess) .filter((item) => hasAnyRole(item.roles))
return hasAccess .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 ( return (
<aside <aside
@@ -83,7 +206,7 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
{/* Logo区域 */} {/* Logo区域 */}
<div className="flex h-16 items-center justify-between border-b px-4"> <div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && ( {!collapsed && (
<span className="text-xl font-bold text-primary">Carrot BBS</span> <span className="text-xl font-bold text-primary">WithYou</span>
)} )}
<Button <Button
variant="ghost" variant="ghost"
@@ -101,31 +224,13 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
{/* 导航菜单 */} {/* 导航菜单 */}
<nav className="flex-1 space-y-1 p-2"> <nav className="flex-1 space-y-1 p-2">
{visibleMenuItems.map((item) => ( {visibleMenuItems.map((item) => renderMenuItem(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>
))}
</nav> </nav>
{/* 底部版本信息 */} {/* 底部版本信息 */}
{!collapsed && ( {!collapsed && (
<div className="border-t p-4 text-center text-xs text-muted-foreground"> <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> </div>
)} )}
</aside> </aside>

View File

@@ -31,3 +31,12 @@
@apply bg-background text-foreground; @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
View 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>
)
}

View File

@@ -34,6 +34,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { PaginationNavigator } from '@/components/ui/pagination' import { PaginationNavigator } from '@/components/ui/pagination'
@@ -47,7 +48,14 @@ import {
AlertTriangle, AlertTriangle,
MessageSquare, MessageSquare,
ExternalLink, ExternalLink,
ThumbsUp,
Check,
X,
Reply,
ImageIcon,
} from 'lucide-react' } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { format } from 'date-fns' import { format } from 'date-fns'
// 状态颜色映射 // 状态颜色映射
@@ -90,7 +98,12 @@ export default function Comments() {
const [deleteComment, setDeleteComment] = useState<Comment | null>(null) const [deleteComment, setDeleteComment] = useState<Comment | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false) 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) 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 () => { const handleBatchDelete = async () => {
if (selectedIds.length === 0) return if (selectedIds.length === 0) return
setBatchLoading(true) setBatchLoading(true)
@@ -242,23 +296,30 @@ export default function Comments() {
</div> </div>
{/* 筛选栏 */} {/* 筛选栏 */}
<Card> <Card className="border-slate-200 shadow-sm">
<CardHeader> <CardHeader className="pb-3">
<CardTitle></CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<Search className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px] max-w-sm"> <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 <Input
placeholder="搜索评论内容..." placeholder="搜索评论内容..."
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
/> />
</div> </div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]"> <SelectTrigger className="w-[160px] bg-slate-50 border-slate-200">
<SelectValue placeholder="状态筛选" /> <SelectValue placeholder="状态筛选" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -269,7 +330,7 @@ export default function Comments() {
<SelectItem value="deleted"></SelectItem> <SelectItem value="deleted"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={handleSearch}> <Button onClick={handleSearch} className="bg-primary hover:bg-primary/90">
<Search className="mr-2 h-4 w-4" /> <Search className="mr-2 h-4 w-4" />
</Button> </Button>
@@ -279,13 +340,38 @@ export default function Comments() {
{/* 批量操作栏 */} {/* 批量操作栏 */}
{selectedIds.length > 0 && ( {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"> <CardContent className="py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-orange-800"> <div className="flex items-center gap-3">
{selectedIds.length} <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> </span>
</div>
<div className="flex gap-2"> <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 <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -302,107 +388,172 @@ export default function Comments() {
)} )}
{/* 评论表格 */} {/* 评论表格 */}
<Card> <Card className="border-slate-200 shadow-sm">
<CardHeader> <CardHeader className="pb-4 border-b border-slate-100">
<CardTitle></CardTitle> <div className="flex items-center justify-between">
<CardDescription> <div>
{total} <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> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead className="w-12"> <TableHead className="w-12 rounded-tl-lg">
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
/> />
</TableHead> </TableHead>
<TableHead className="w-16">ID</TableHead> <TableHead className="w-16">ID</TableHead>
<TableHead className="w-20"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-24"></TableHead> <TableHead className="w-24"></TableHead>
<TableHead className="w-16"></TableHead> <TableHead className="w-16"></TableHead>
<TableHead className="w-36"></TableHead> <TableHead className="w-36"></TableHead>
<TableHead className="w-20"></TableHead> <TableHead className="w-20 rounded-tr-lg"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center py-8"> <TableCell colSpan={10} className="text-center py-12">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" /> <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> </TableCell>
</TableRow> </TableRow>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<TableRow> <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> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
comments.map((comment) => ( comments.map((comment) => (
<TableRow key={comment.id}> <TableRow
key={comment.id}
className="group hover:bg-slate-50/80 transition-colors"
>
<TableCell> <TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(comment.id)} checked={selectedIds.includes(comment.id)}
onCheckedChange={(checked) => handleSelect(comment.id, !!checked)} onCheckedChange={(checked) => handleSelect(comment.id, !!checked)}
/> />
</TableCell> </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> <TableCell>
<div className="max-w-[250px]"> <div className="max-w-[250px]">
<p className="text-sm truncate" title={comment.content}> <p className="text-sm text-slate-700 truncate group-hover:text-primary transition-colors" title={comment.content}>
{truncateContent(comment.content)} {truncateContent(comment.content, 80)}
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<Avatar className="h-8 w-8"> <Avatar className="h-9 w-9 ring-2 ring-white shadow-sm">
<AvatarImage src={comment.author.avatar} /> <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> </Avatar>
<div> <div className="min-w-0">
<div className="font-medium">{comment.author.nickname || comment.author.username}</div> <div className="font-medium text-sm text-slate-900 truncate">{comment.author.nickname || comment.author.username}</div>
<div className="text-xs text-muted-foreground">@{comment.author.username}</div> <div className="text-xs text-slate-500 truncate">@{comment.author.username}</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<MessageSquare className="h-4 w-4 text-muted-foreground" /> <ExternalLink className="h-3.5 w-3.5 text-slate-400" />
<span className="truncate max-w-[150px]" title={comment.post_title}> <span className="truncate max-w-[140px] text-slate-600" title={comment.post_title}>
{comment.post_title || `帖子 #${comment.post_id}`} {comment.post_title || `帖子 #${comment.post_id}`}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<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]} {statusText[comment.status]}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{comment.like_count}</TableCell> <TableCell>
<TableCell className="text-sm text-muted-foreground"> <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)} {formatDate(comment.created_at)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleViewDetail(comment)}> <DropdownMenuItem onClick={() => handleViewDetail(comment)} className="cursor-pointer">
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4 text-blue-500" />
</DropdownMenuItem> </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 <DropdownMenuItem
onClick={() => openDeleteDialog(comment)} 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" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -417,7 +568,7 @@ export default function Comments() {
</Table> </Table>
{/* 分页 */} {/* 分页 */}
<div className="mt-4"> <div className="p-4 border-t border-slate-100 bg-slate-50/50">
<PaginationNavigator <PaginationNavigator
currentPage={page} currentPage={page}
totalPages={Math.ceil(total / pageSize)} totalPages={Math.ceil(total / pageSize)}
@@ -429,65 +580,215 @@ export default function Comments() {
{/* 详情对话框 */} {/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}> <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader className="border-b pb-4">
<DialogTitle></DialogTitle> <DialogTitle className="text-xl flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary" />
</DialogTitle>
</DialogHeader> </DialogHeader>
{detailLoading ? ( {detailLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
) : selectedComment ? ( ) : selectedComment ? (
<div className="space-y-4"> <div className="space-y-6 py-2">
<div className="flex items-center gap-4"> {/* 评论者信息卡片 */}
<Avatar className="h-12 w-12"> <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} /> <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> </Avatar>
<div> <div className="flex-1">
<div className="font-medium">{selectedComment.author?.nickname || selectedComment.author?.username}</div> <div className="font-semibold text-lg">{selectedComment.author?.nickname || selectedComment.author?.username}</div>
<div className="text-sm text-muted-foreground">@{selectedComment.author?.username}</div> <div className="text-sm text-slate-500">@{selectedComment.author?.username}</div>
</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]} {statusText[selectedComment.status]}
</Badge> </Badge>
</div> </div>
{/* 所属帖子 */} {/* 所属帖子 */}
{selectedComment.post && ( {selectedComment.post && (
<Card className="bg-muted/50"> <Card className="bg-slate-50 border-slate-200">
<CardContent className="py-3"> <CardContent className="py-4">
<div className="flex items-center justify-between"> <div className="flex items-start gap-3">
<div> <div className="p-2 bg-blue-100 rounded-lg">
<div className="text-xs text-muted-foreground mb-1"></div> <ExternalLink className="h-4 w-4 text-blue-600" />
<div className="font-medium">{selectedComment.post.title}</div>
</div> </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" /> <ExternalLink className="h-4 w-4" />
</Button> </Button>
</div> </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> </CardContent>
</Card> </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 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>
{/* 评论图片 */}
{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>
<div className="text-xs text-muted-foreground mb-1"></div> <div className="text-lg font-semibold text-slate-900">{selectedComment.likes_count}</div>
<p className="bg-muted/50 p-3 rounded-lg whitespace-pre-wrap">{selectedComment.content}</p> <div className="text-xs text-slate-500"></div>
</div>
</div>
</div> </div>
<div className="flex gap-6 text-sm text-muted-foreground"> {/* 时间信息 */}
<span>👍 {selectedComment.like_count} </span> <div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
</div> <span>{formatDate(selectedComment.created_at)}</span>
{selectedComment.updated_at !== selectedComment.created_at && (
<div className="text-sm text-muted-foreground"> <span>{formatDate(selectedComment.updated_at)}</span>
{formatDate(selectedComment.created_at)} )}
</div> </div>
</div> </div>
) : null} ) : null}
</DialogContent> </DialogContent>
</Dialog> </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}> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent> <DialogContent>

View File

@@ -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 { Users, FileText, MessageSquare, Clock, TrendingUp, Calendar, CalendarDays, CalendarRange } from 'lucide-react'
import { StatsCard } from '@/components/StatsCard' import { StatsCard } from '@/components/StatsCard'
import { ActivityChart } from '@/components/ActivityChart' import { ActivityChart } from '@/components/ActivityChart'
@@ -78,7 +78,7 @@ export default function Dashboard() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold">Dashboard</h1> <h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground"> Carrot BBS </p> <p className="text-muted-foreground"> WithYou </p>
</div> </div>
{/* 统计卡片 */} {/* 统计卡片 */}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -62,7 +62,7 @@ export default function Login() {
<span className="text-2xl font-bold">C</span> <span className="text-2xl font-bold">C</span>
</div> </div>
</div> </div>
<CardTitle className="text-2xl font-bold">Carrot BBS</CardTitle> <CardTitle className="text-2xl font-bold">WithYou</CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>

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

View File

@@ -50,6 +50,12 @@ import {
X, X,
Loader2, Loader2,
AlertTriangle, AlertTriangle,
ImageIcon,
ThumbsUp,
MessageCircle,
Eye as EyeIcon,
Pin,
Star,
} from 'lucide-react' } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
@@ -298,23 +304,30 @@ export default function Posts() {
</div> </div>
{/* 筛选栏 */} {/* 筛选栏 */}
<Card> <Card className="border-slate-200 shadow-sm">
<CardHeader> <CardHeader className="pb-3">
<CardTitle></CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<Search className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[200px] max-w-sm"> <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 <Input
placeholder="搜索帖子标题或内容..." placeholder="搜索帖子标题或内容..."
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
/> />
</div> </div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]"> <SelectTrigger className="w-[160px] bg-slate-50 border-slate-200">
<SelectValue placeholder="状态筛选" /> <SelectValue placeholder="状态筛选" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -325,7 +338,7 @@ export default function Posts() {
<SelectItem value="deleted"></SelectItem> <SelectItem value="deleted"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={handleSearch}> <Button onClick={handleSearch} className="bg-primary hover:bg-primary/90">
<Search className="mr-2 h-4 w-4" /> <Search className="mr-2 h-4 w-4" />
</Button> </Button>
@@ -335,18 +348,24 @@ export default function Posts() {
{/* 批量操作栏 */} {/* 批量操作栏 */}
{selectedIds.length > 0 && ( {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"> <CardContent className="py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-orange-800"> <div className="flex items-center gap-3">
{selectedIds.length} <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> </span>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleBatchModerate('published')} onClick={() => handleBatchModerate('published')}
disabled={batchLoading} 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" /> <Check className="mr-2 h-4 w-4" />
@@ -356,6 +375,7 @@ export default function Posts() {
size="sm" size="sm"
onClick={() => handleBatchModerate('rejected')} onClick={() => handleBatchModerate('rejected')}
disabled={batchLoading} 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" /> <X className="mr-2 h-4 w-4" />
@@ -376,107 +396,162 @@ export default function Posts() {
)} )}
{/* 帖子表格 */} {/* 帖子表格 */}
<Card> <Card className="border-slate-200 shadow-sm">
<CardHeader> <CardHeader className="pb-4 border-b border-slate-100">
<CardTitle></CardTitle> <div className="flex items-center justify-between">
<CardDescription> <div>
{total} <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> </CardDescription>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead className="w-12"> <TableHead className="w-12 rounded-tl-lg">
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
/> />
</TableHead> </TableHead>
<TableHead className="w-16">ID</TableHead> <TableHead className="w-16">ID</TableHead>
<TableHead></TableHead> <TableHead className="w-20"></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-24"></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-36"></TableHead>
<TableHead className="w-28"></TableHead> <TableHead className="w-28 rounded-tr-lg"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center py-8"> <TableCell colSpan={9} className="text-center py-12">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" /> <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> </TableCell>
</TableRow> </TableRow>
) : posts.length === 0 ? ( ) : posts.length === 0 ? (
<TableRow> <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> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
posts.map((post) => ( posts.map((post) => (
<TableRow key={post.id}> <TableRow
key={post.id}
className="group hover:bg-slate-50/80 transition-colors"
>
<TableCell> <TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(post.id)} checked={selectedIds.includes(post.id)}
onCheckedChange={(checked) => handleSelect(post.id, !!checked)} onCheckedChange={(checked) => handleSelect(post.id, !!checked)}
/> />
</TableCell> </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> <TableCell>
<div className="max-w-[300px]"> <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">
<div className="font-medium truncate">{post.title}</div> {post.images && post.images.length > 0 ? (
<div className="text-sm text-muted-foreground truncate"> <img
{post.content.substring(0, 50)}... 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>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<Avatar className="h-8 w-8"> <Avatar className="h-9 w-9 ring-2 ring-white shadow-sm">
<AvatarImage src={post.author.avatar} /> <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> </Avatar>
<div> <div className="min-w-0">
<div className="font-medium">{post.author.nickname || post.author.username}</div> <div className="font-medium text-sm text-slate-900 truncate">{post.author.nickname || post.author.username}</div>
<div className="text-xs text-muted-foreground">@{post.author.username}</div> <div className="text-xs text-slate-500 truncate">@{post.author.username}</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<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]} {statusText[post.status]}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{post.like_count}</TableCell> <TableCell>
<TableCell>{post.comment_count}</TableCell> <div className="flex flex-col gap-1 text-xs">
<TableCell className="text-sm text-muted-foreground"> <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)} {formatDate(post.created_at)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleViewDetail(post)}> <DropdownMenuItem onClick={() => handleViewDetail(post)} className="cursor-pointer">
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4 text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
{post.status === 'pending' && ( {post.status === 'pending' && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openModerateDialog(post, 'published')}> <DropdownMenuItem onClick={() => openModerateDialog(post, 'published')} className="cursor-pointer">
<Check className="mr-2 h-4 w-4" /> <Check className="mr-2 h-4 w-4 text-green-500" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')}> <DropdownMenuItem onClick={() => openModerateDialog(post, 'rejected')} className="cursor-pointer">
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4 text-amber-500" />
</DropdownMenuItem> </DropdownMenuItem>
</> </>
@@ -484,7 +559,7 @@ export default function Posts() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => openDeleteDialog(post)} 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" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -499,7 +574,7 @@ export default function Posts() {
</Table> </Table>
{/* 分页 */} {/* 分页 */}
<div className="mt-4"> <div className="p-4 border-t border-slate-100 bg-slate-50/50">
<PaginationNavigator <PaginationNavigator
currentPage={page} currentPage={page}
totalPages={Math.ceil(total / pageSize)} totalPages={Math.ceil(total / pageSize)}
@@ -511,43 +586,161 @@ export default function Posts() {
{/* 详情对话框 */} {/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}> <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader className="border-b pb-4">
<DialogTitle></DialogTitle> <DialogTitle className="text-xl flex items-center gap-2">
<Eye className="h-5 w-5 text-primary" />
</DialogTitle>
</DialogHeader> </DialogHeader>
{detailLoading ? ( {detailLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
) : selectedPost ? ( ) : selectedPost ? (
<div className="space-y-4"> <div className="space-y-6 py-2">
<div className="flex items-center gap-4"> {/* 作者信息卡片 */}
<Avatar className="h-12 w-12"> <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} /> <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> </Avatar>
<div> <div className="flex-1">
<div className="font-medium">{selectedPost.author?.nickname || selectedPost.author?.username}</div> <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 className="text-sm text-muted-foreground">@{selectedPost.author?.username}</div>
</div> </div>
<Badge className={`${statusColors[selectedPost.status]} text-white ml-auto`}> <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]} {statusText[selectedPost.status]}
</Badge> </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>
{/* 帖子内容 */}
<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>
{/* 图片展示 */}
{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>
<h3 className="text-xl font-bold mb-2">{selectedPost.title}</h3> <div className="text-lg font-semibold text-slate-900">{selectedPost.likes_count}</div>
<p className="text-muted-foreground whitespace-pre-wrap">{selectedPost.content}</p> <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>
<div className="flex gap-6 text-sm text-muted-foreground"> {/* 时间信息 */}
<span>👍 {selectedPost.like_count} </span> <div className="flex items-center justify-between text-sm text-slate-500 pt-4 border-t border-slate-100">
<span>💬 {selectedPost.comment_count} </span> <div className="flex items-center gap-4">
<span>👁 {selectedPost.view_count} </span> <span>{formatDate(selectedPost.created_at)}</span>
{selectedPost.group_name && (
<Badge variant="outline" className="text-xs">
{selectedPost.group_name}
</Badge>
)}
</div> </div>
{selectedPost.updated_at !== selectedPost.created_at && (
<div className="text-sm text-muted-foreground"> <span>{formatDate(selectedPost.updated_at)}</span>
{formatDate(selectedPost.created_at)} )}
</div> </div>
</div> </div>
) : null} ) : null}

826
src/pages/Reports.tsx Normal file
View 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>
)
}

View File

@@ -12,7 +12,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } 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 }> = { const roleColors: Record<string, { bg: string; text: string; border: string }> = {

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import type { User, Role, PaginatedResponse } from '@/types' 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -31,7 +31,7 @@ import {
import { PaginationNavigator } from '@/components/ui/pagination' import { PaginationNavigator } from '@/components/ui/pagination'
import UserDetail from '@/components/UserDetail' import UserDetail from '@/components/UserDetail'
import UserRoleDialog from '@/components/UserRoleDialog' 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> = { const statusColors: Record<string, string> = {
@@ -75,6 +75,13 @@ export default function Users() {
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null) const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
const [allRoles, setAllRoles] = useState<Role[]>([]) 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 () => { const loadUsers = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -105,10 +112,12 @@ export default function Users() {
const response = await rolesApi.getRoles() const response = await rolesApi.getRoles()
const roles = response.data.data || [] const roles = response.data.data || []
setAllRoles(roles.map(r => ({ setAllRoles(roles.map(r => ({
id: r.name, // 使用 name 作为 id id: r.name,
name: r.name, name: r.name,
display_name: r.display_name,
description: r.description, description: r.description,
created_at: '', // 后端列表接口不返回这些字段 priority: r.priority,
created_at: '',
updated_at: '', updated_at: '',
}))) })))
} catch (error) { } catch (error) {
@@ -147,6 +156,35 @@ export default function Users() {
setRoleDialogOpen(true) 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) => { const handleAssignRole = async (userId: string, role: string) => {
await usersApi.assignRole(userId, role) await usersApi.assignRole(userId, role)
@@ -363,6 +401,14 @@ export default function Users() {
> >
<UserPlus className="h-4 w-4" /> <UserPlus className="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDeviceDialog(user)}
title="查看设备 RegistrationID"
>
<Smartphone className="h-4 w-4" />
</Button>
{user.status === 'active' ? ( {user.status === 'active' ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -453,6 +499,87 @@ export default function Users() {
onRemoveRole={handleRemoveRole} 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> </div>
) )
} }

457
src/pages/Verifications.tsx Normal file
View 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>
)
}

View File

@@ -103,6 +103,11 @@ const Roles = lazy(() => import('@/pages/Roles'))
const Posts = lazy(() => import('@/pages/Posts')) const Posts = lazy(() => import('@/pages/Posts'))
const Comments = lazy(() => import('@/pages/Comments')) const Comments = lazy(() => import('@/pages/Comments'))
const Groups = lazy(() => import('@/pages/Groups')) 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 Forbidden = lazy(() => import('@/pages/Forbidden'))
const NotFound = lazy(() => import('@/pages/NotFound')) const NotFound = lazy(() => import('@/pages/NotFound'))
@@ -191,6 +196,56 @@ export const router = createBrowserRouter([
</ModeratorGuard> </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>
),
},
], ],
}, },
{ {

View File

@@ -26,7 +26,9 @@ export type CommentStatus = 'pending' | 'published' | 'rejected' | 'deleted'
export interface Role { export interface Role {
id: string id: string
name: string name: string
display_name: string
description?: string description?: string
priority: number
permissions?: string[] permissions?: string[]
created_at: string created_at: string
updated_at: string updated_at: string
@@ -45,20 +47,45 @@ export interface User {
updated_at: string 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 { export interface Post {
id: string id: string
title: string title: string
content: string content: string
segments?: MessageSegment[]
author: User author: User
status: PostStatus status: PostStatus
like_count: number likes_count: number
comment_count: number comments_count: number
view_count: number views_count: number
images?: PostImage[]
is_vote?: boolean
created_at: string created_at: string
updated_at: string updated_at: string
} }
// 评论图片
export interface CommentImage {
url: string
}
// 评论 // 评论
export interface Comment { export interface Comment {
id: string id: string
@@ -67,7 +94,12 @@ export interface Comment {
post_id: string post_id: string
post_title?: string post_title?: string
status: CommentStatus 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 created_at: string
updated_at: string updated_at: string
} }

View File

@@ -5,7 +5,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client", "react", "react-dom"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@@ -17,7 +17,6 @@
"jsx": "react-jsx", "jsx": "react-jsx",
/* Path aliases */ /* Path aliases */
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },

1
tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.9.3"}

View File

@@ -10,12 +10,5 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: { server: {},
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
}) })