Compare commits
4 Commits
0b16613be4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef3a55359 | ||
|
|
4d25bf00ba | ||
|
|
2d1dc3673a | ||
|
|
f20140f4ea |
@@ -1,2 +1,2 @@
|
|||||||
# 开发环境 API 地址
|
# 开发环境 API 地址
|
||||||
VITE_API_BASE_URL=https://bbs.littlelan.cn/api/v1
|
VITE_API_BASE_URL=https://withyou.littlelan.cn/api/v1
|
||||||
@@ -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 || 'https://bbs.littlelan.cn/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',
|
||||||
|
|||||||
@@ -33,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 }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -206,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"
|
||||||
@@ -230,7 +230,7 @@ export default function Sidebar({ collapsed, onCollapse }: SidebarProps) {
|
|||||||
{/* 底部版本信息 */}
|
{/* 底部版本信息 */}
|
||||||
{!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>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -49,9 +50,12 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
Check,
|
Check,
|
||||||
|
X,
|
||||||
Reply,
|
Reply,
|
||||||
ImageIcon,
|
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'
|
||||||
|
|
||||||
// 状态颜色映射
|
// 状态颜色映射
|
||||||
@@ -94,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)
|
||||||
|
|
||||||
// 加载评论列表
|
// 加载评论列表
|
||||||
@@ -195,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)
|
||||||
@@ -302,6 +352,26 @@ export default function Comments() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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"
|
||||||
@@ -467,6 +537,20 @@ export default function Comments() {
|
|||||||
<Eye className="mr-2 h-4 w-4 text-blue-500" />
|
<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 cursor-pointer focus:text-red-600"
|
className="text-red-600 cursor-pointer focus:text-red-600"
|
||||||
@@ -622,6 +706,21 @@ export default function Comments() {
|
|||||||
</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-6 p-4 bg-slate-50 rounded-xl">
|
||||||
<div className="flex items-center gap-2 text-slate-600">
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
@@ -647,6 +746,49 @@ export default function Comments() {
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -636,8 +636,25 @@ export default function Posts() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-2xl font-bold text-slate-900 leading-tight">{selectedPost.title}</h3>
|
<h3 className="text-2xl font-bold text-slate-900 leading-tight">{selectedPost.title}</h3>
|
||||||
<div className="prose prose-slate max-w-none">
|
<div className="prose prose-slate max-w-none">
|
||||||
<p className="text-slate-700 whitespace-pre-wrap leading-relaxed text-base">{selectedPost.content}</p>
|
<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>
|
</div>
|
||||||
|
{/* 投票信息(从 segments 解析) */}
|
||||||
|
{selectedPost.segments?.filter(s => s.type === 'vote').map((seg, i) => (
|
||||||
|
<div key={i} className="mt-3 p-4 rounded-lg border border-slate-200 bg-slate-50">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-3">
|
||||||
|
<span>📊 投票</span>
|
||||||
|
</div>
|
||||||
|
{seg.data?.options?.map((opt: any, j: number) => (
|
||||||
|
<div key={j} className="py-1.5 text-slate-600">
|
||||||
|
{j + 1}. {opt.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图片展示 */}
|
{/* 图片展示 */}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -149,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)
|
||||||
@@ -365,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"
|
||||||
@@ -455,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,17 +58,25 @@ export interface PostImage {
|
|||||||
height?: 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
|
||||||
likes_count: number
|
likes_count: number
|
||||||
comments_count: number
|
comments_count: number
|
||||||
views_count: number
|
views_count: number
|
||||||
images?: PostImage[]
|
images?: PostImage[]
|
||||||
|
is_vote?: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -86,6 +94,9 @@ export interface Comment {
|
|||||||
post_id: string
|
post_id: string
|
||||||
post_title?: string
|
post_title?: string
|
||||||
status: CommentStatus
|
status: CommentStatus
|
||||||
|
reject_reason?: string
|
||||||
|
reviewed_at?: string
|
||||||
|
reviewed_by?: string
|
||||||
likes_count: number
|
likes_count: number
|
||||||
replies_count?: number
|
replies_count?: number
|
||||||
images?: CommentImage[]
|
images?: CommentImage[]
|
||||||
|
|||||||
Reference in New Issue
Block a user