feat: Introduce owner management functionality with a new frontend list component and supporting backend API.

This commit is contained in:
2026-01-30 19:55:21 +08:00
parent 119b24efb2
commit 3c5bb04d82
2 changed files with 175 additions and 7 deletions

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -110,6 +111,8 @@ func startServer(cfg *config.Config) {
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
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/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": "已清空"})
}
// 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 - 测试注册流程
func handleRegisterTest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {

View File

@@ -1,5 +1,5 @@
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'
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',
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',
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> = {
valid: '有效',
registered: '已注册',
pooled: '已入库',
processing: 'processing',
invalid: '无效',
}
interface OwnerListProps {
@@ -32,6 +36,8 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<string>('')
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [deleting, setDeleting] = useState(false)
const limit = 20
const loadOwners = async () => {
@@ -60,16 +66,57 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
useEffect(() => {
loadOwners()
// 清除选择
setSelectedIds(new Set())
}, [page, filter])
// 单个删除
const handleDelete = async (id: number) => {
if (!confirm('确认删除此账号?')) return
try {
await fetch(`/api/db/owners/${id}`, { method: 'DELETE' })
const res = await fetch(`/api/db/owners/delete/${id}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0) {
loadOwners()
onStatsChange?.()
} else {
alert(data.message || '删除失败')
}
} catch (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 formatTime = (ts: string) => {
@@ -144,6 +212,7 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
<option value="valid"></option>
<option value="registered"></option>
<option value="pooled"></option>
<option value="invalid"></option>
</select>
<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'}
</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
variant="ghost"
size="sm"
@@ -175,6 +256,15 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<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">Account ID</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>
{loading ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
<td colSpan={6} className="text-center py-8 text-slate-500">
...
</td>
</tr>
) : owners.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
<td colSpan={6} className="text-center py-8 text-slate-500">
</td>
</tr>
) : (
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 font-mono text-xs text-slate-500">
{owner.account_id ? `${owner.account_id.slice(0, 20)}...` : '-'}