feat: Introduce owner management functionality with a new frontend list component and supporting backend API.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -110,6 +111,8 @@ func startServer(cfg *config.Config) {
|
|||||||
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
|
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
|
||||||
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
|
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
|
||||||
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
|
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
|
||||||
|
mux.HandleFunc("/api/db/owners/delete/", api.CORS(handleDeleteOwner)) // DELETE /api/db/owners/delete/{id}
|
||||||
|
mux.HandleFunc("/api/db/owners/batch-delete", api.CORS(handleBatchDeleteOwners)) // POST 批量删除
|
||||||
mux.HandleFunc("/api/db/owners/refetch-account-ids", api.CORS(api.HandleRefetchAccountIDs))
|
mux.HandleFunc("/api/db/owners/refetch-account-ids", api.CORS(api.HandleRefetchAccountIDs))
|
||||||
mux.HandleFunc("/api/upload/validate", api.CORS(api.HandleUploadValidate))
|
mux.HandleFunc("/api/upload/validate", api.CORS(api.HandleUploadValidate))
|
||||||
|
|
||||||
@@ -642,6 +645,72 @@ func handleClearOwners(w http.ResponseWriter, r *http.Request) {
|
|||||||
api.Success(w, map[string]string{"message": "已清空"})
|
api.Success(w, map[string]string{"message": "已清空"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteOwner DELETE /api/db/owners/delete/{id} - 删除单个 owner
|
||||||
|
func handleDeleteOwner(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
|
||||||
|
api.Error(w, http.StatusMethodNotAllowed, "仅支持 DELETE/POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if database.Instance == nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 URL 提取 ID: /api/db/owners/delete/{id}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/db/owners/delete/")
|
||||||
|
id, err := strconv.Atoi(path)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(w, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.Instance.DeleteTeamOwner(int64(id)); err != nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("删除失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(w, map[string]string{"message": "已删除"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBatchDeleteOwners POST /api/db/owners/batch-delete - 批量删除 owners
|
||||||
|
func handleBatchDeleteOwners(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if database.Instance == nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
IDs []int `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
api.Error(w, http.StatusBadRequest, "请选择要删除的账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
if err := database.Instance.DeleteTeamOwner(int64(id)); err == nil {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.Success(w, map[string]interface{}{
|
||||||
|
"message": fmt.Sprintf("已删除 %d 个账号", deleted),
|
||||||
|
"deleted": deleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleRegisterTest POST /api/register/test - 测试注册流程
|
// handleRegisterTest POST /api/register/test - 测试注册流程
|
||||||
func handleRegisterTest(w http.ResponseWriter, r *http.Request) {
|
func handleRegisterTest(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight, Key } from 'lucide-react'
|
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight, Key, CheckSquare, Square } from 'lucide-react'
|
||||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
||||||
|
|
||||||
interface TeamOwner {
|
interface TeamOwner {
|
||||||
@@ -14,12 +14,16 @@ const statusColors: Record<string, string> = {
|
|||||||
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||||
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
processing: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
invalid: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
valid: '有效',
|
valid: '有效',
|
||||||
registered: '已注册',
|
registered: '已注册',
|
||||||
pooled: '已入库',
|
pooled: '已入库',
|
||||||
|
processing: 'processing',
|
||||||
|
invalid: '无效',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OwnerListProps {
|
interface OwnerListProps {
|
||||||
@@ -32,6 +36,8 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [filter, setFilter] = useState<string>('')
|
const [filter, setFilter] = useState<string>('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
||||||
const loadOwners = async () => {
|
const loadOwners = async () => {
|
||||||
@@ -60,16 +66,57 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOwners()
|
loadOwners()
|
||||||
|
// 清除选择
|
||||||
|
setSelectedIds(new Set())
|
||||||
}, [page, filter])
|
}, [page, filter])
|
||||||
|
|
||||||
|
// 单个删除
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('确认删除此账号?')) return
|
if (!confirm('确认删除此账号?')) return
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/db/owners/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/db/owners/delete/${id}`, { method: 'POST' })
|
||||||
loadOwners()
|
const data = await res.json()
|
||||||
onStatsChange?.()
|
if (data.code === 0) {
|
||||||
|
loadOwners()
|
||||||
|
onStatsChange?.()
|
||||||
|
} else {
|
||||||
|
alert(data.message || '删除失败')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete:', e)
|
console.error('Failed to delete:', e)
|
||||||
|
alert('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
alert('请先选择要删除的账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!confirm(`确认删除选中的 ${selectedIds.size} 个账号?`)) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/owners/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
alert(data.data.message)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
loadOwners()
|
||||||
|
onStatsChange?.()
|
||||||
|
} else {
|
||||||
|
alert(data.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to batch delete:', e)
|
||||||
|
alert('删除失败')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +155,27 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 选择逻辑
|
||||||
|
const toggleSelect = (id: number) => {
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
if (newSet.has(id)) {
|
||||||
|
newSet.delete(id)
|
||||||
|
} else {
|
||||||
|
newSet.add(id)
|
||||||
|
}
|
||||||
|
setSelectedIds(newSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedIds.size === owners.length) {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(owners.map(o => o.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllSelected = owners.length > 0 && selectedIds.size === owners.length
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit)
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
const formatTime = (ts: string) => {
|
const formatTime = (ts: string) => {
|
||||||
@@ -144,6 +212,7 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
<option value="valid">有效</option>
|
<option value="valid">有效</option>
|
||||||
<option value="registered">已注册</option>
|
<option value="registered">已注册</option>
|
||||||
<option value="pooled">已入库</option>
|
<option value="pooled">已入库</option>
|
||||||
|
<option value="invalid">无效</option>
|
||||||
</select>
|
</select>
|
||||||
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
|
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
|
||||||
刷新
|
刷新
|
||||||
@@ -158,6 +227,18 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
>
|
>
|
||||||
{refetching ? '获取中...' : '重新获取ID'}
|
{refetching ? '获取中...' : '重新获取ID'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
className="text-orange-500 hover:text-orange-600"
|
||||||
|
>
|
||||||
|
{deleting ? '删除中...' : `删除选中 (${selectedIds.size})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -175,6 +256,15 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="text-center p-3 w-10">
|
||||||
|
<button onClick={toggleSelectAll} className="hover:text-blue-500">
|
||||||
|
{isAllSelected ? (
|
||||||
|
<CheckSquare className="h-4 w-4 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">邮箱</th>
|
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">邮箱</th>
|
||||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
|
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
|
||||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">状态</th>
|
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">状态</th>
|
||||||
@@ -185,19 +275,28 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
<td colSpan={6} className="text-center py-8 text-slate-500">
|
||||||
加载中...
|
加载中...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : owners.length === 0 ? (
|
) : owners.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
<td colSpan={6} className="text-center py-8 text-slate-500">
|
||||||
暂无数据
|
暂无数据
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
owners.map((owner) => (
|
owners.map((owner) => (
|
||||||
<tr key={owner.id} className="border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
<tr key={owner.id} className={`border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 ${selectedIds.has(owner.id) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<button onClick={() => toggleSelect(owner.id)} className="hover:text-blue-500">
|
||||||
|
{selectedIds.has(owner.id) ? (
|
||||||
|
<CheckSquare className="h-4 w-4 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
|
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
|
||||||
<td className="p-3 font-mono text-xs text-slate-500">
|
<td className="p-3 font-mono text-xs text-slate-500">
|
||||||
{owner.account_id ? `${owner.account_id.slice(0, 20)}...` : '-'}
|
{owner.account_id ? `${owner.account_id.slice(0, 20)}...` : '-'}
|
||||||
|
|||||||
Reference in New Issue
Block a user