Compare commits

..

2 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
10 changed files with 180 additions and 12 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

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

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

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