Compare commits

...

4 Commits

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
13 changed files with 335 additions and 21 deletions

View File

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

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

View File

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

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'

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

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

View File

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

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

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

@@ -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>
{/* 图片展示 */} {/* 图片展示 */}

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

View File

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