Jelajahi Sumber

feat(telegram): resumable multi-account group clone with Redis progress

New telegram.CloneGroupMembers function orchestrates group member collection
across multiple TG accounts with Redis-backed progress so FloodWait-cooled
accounts can be swapped in and partial runs can be resumed.

Progress state (24h TTL per group):
  spider:tg:clone:<group>:seen          — collected user ids (dedup)
  spider:tg:clone:<group>:done_queries  — search queries completed
  spider:tg:clone:<group>:participants  — JSON-encoded GroupParticipants
  spider:tg:clone:<group>:total         — TG-reported total count
  spider:tg:clone:<group>:status        — running | done | paused

Control flow:
  - Acquire account, Connect, Resolve channel
  - Phase 1 empty-query runs once per clone session to seed total
  - Phase 2 iterates undone queries; on FloodWait cools current account
    (HandleFloodWait) and the outer loop acquires a new one
  - ErrAllCooling → return partial, status=paused; retrying the endpoint
    picks up exactly where the last run stopped
  - len(seen) >= total OR all queries done → status=done

Client additions:
  - ResolveGroupChannel(ctx, username) → (*tg.InputChannel, *tg.Channel, error)
  - FetchParticipantsByQuery(ctx, ch, q) → ([]GroupParticipant, total, error)
  (existing GetGroupParticipants kept intact for backward compat)

Handler: POST /groups/:username/clone-members
  - Accepts ?reset=1 to clear Redis and start over
  - Response now includes partial, status, progress{collected,total,
    queries_done,queries_total}

Frontend: "克隆成员" button shows warning toast with X/Y progress on
partial; adds a "重置" button that calls ?reset=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 minggu lalu
induk
melakukan
0018039285

+ 178 - 0
internal/handler/group.go

@@ -0,0 +1,178 @@
+package handler
+
+import (
+	"context"
+	"encoding/csv"
+	"fmt"
+	"time"
+
+	"spider/internal/store"
+	"spider/internal/telegram"
+
+	"github.com/gin-gonic/gin"
+	"github.com/redis/go-redis/v9"
+)
+
+// GroupHandler handles group-member relationship queries.
+type GroupHandler struct {
+	store     *store.Store
+	tgManager *telegram.AccountManager
+	rdb       *redis.Client
+}
+
+// ListGroups handles GET /groups — returns all groups with member counts.
+func (h *GroupHandler) ListGroups(c *gin.Context) {
+	page, pageSize, _ := parsePage(c)
+	search := c.Query("search")
+	groups, total, err := h.store.ListGroups(page, pageSize, search)
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, groups, total, page, pageSize)
+}
+
+// SearchMembers handles GET /members/search — search members across all groups.
+func (h *GroupHandler) SearchMembers(c *gin.Context) {
+	query := c.Query("q")
+	if query == "" {
+		Fail(c, 400, "搜索关键词不能为空")
+		return
+	}
+	page, pageSize, _ := parsePage(c)
+	members, total, err := h.store.SearchMembers(query, page, pageSize)
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, members, total, page, pageSize)
+}
+
+// ListMembers handles GET /groups/:username/members — members of a group.
+func (h *GroupHandler) ListMembers(c *gin.Context) {
+	username := c.Param("username")
+	search := c.Query("search")
+	page, pageSize, _ := parsePage(c)
+	members, total, err := h.store.ListMembersByGroup(username, page, pageSize, search)
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	PageOK(c, members, total, page, pageSize)
+}
+
+// ListMemberGroups handles GET /merchants/:username/groups — groups a member belongs to.
+func (h *GroupHandler) ListMemberGroups(c *gin.Context) {
+	username := c.Param("username")
+	groups, err := h.store.ListGroupsByMember(username)
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+	OK(c, groups)
+}
+
+// ExportMembers handles GET /groups/:username/members/export — streams all members as CSV.
+func (h *GroupHandler) ExportMembers(c *gin.Context) {
+	username := c.Param("username")
+	members, _, err := h.store.ListMembersByGroup(username, 1, 100000, "")
+	if err != nil {
+		Fail(c, 500, err.Error())
+		return
+	}
+
+	c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="members_%s.csv"`, username))
+	c.Header("Content-Type", "text/csv; charset=utf-8")
+	c.Writer.Write([]byte("\xef\xbb\xbf")) // UTF-8 BOM for Excel
+
+	w := csv.NewWriter(c.Writer)
+	_ = w.Write([]string{"用户名", "来源类型", "发现时间"})
+	for _, m := range members {
+		discoveredAt := ""
+		if !m.DiscoveredAt.IsZero() {
+			discoveredAt = m.DiscoveredAt.Format("2006-01-02 15:04:05")
+		}
+		_ = w.Write([]string{m.MemberUsername, m.SourceType, discoveredAt})
+	}
+	w.Flush()
+}
+
+// CloneMembers handles POST /groups/:username/clone-members
+// Runs a multi-account, FloodWait-aware, resumable clone. Progress lives in
+// Redis so a call that hits "all accounts cooling" can return Partial=true
+// and be retried later to continue where it left off.
+// Query param ?reset=1 discards prior progress and restarts from scratch.
+func (h *GroupHandler) CloneMembers(c *gin.Context) {
+	username := c.Param("username")
+	if username == "" {
+		Fail(c, 400, "群组用户名不能为空")
+		return
+	}
+	if h.tgManager == nil {
+		Fail(c, 500, "TG 账号管理器未初始化")
+		return
+	}
+	if h.rdb == nil {
+		Fail(c, 500, "Redis 客户端未初始化")
+		return
+	}
+
+	reset := c.Query("reset") == "1"
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
+	defer cancel()
+
+	res, err := telegram.CloneGroupMembers(ctx, h.tgManager, h.rdb, username, reset)
+	if err != nil && res == nil {
+		Fail(c, 500, fmt.Sprintf("克隆失败: %v", err))
+		return
+	}
+	if res == nil {
+		Fail(c, 500, "克隆返回空结果")
+		return
+	}
+
+	// Filter + persist all currently-known participants with usernames.
+	var usernames []string
+	for _, p := range res.Participants {
+		if p.Username != "" && !p.IsBot {
+			usernames = append(usernames, p.Username)
+		}
+	}
+
+	groupTitle := res.GroupTitle
+	if groupTitle == "" {
+		groupTitle = username
+	}
+
+	created := 0
+	if len(usernames) > 0 {
+		created = h.store.BatchSaveGroupMembers(username, groupTitle, "tg_clone", usernames)
+	}
+
+	LogAudit(h.store, c, "clone_members", "group", username, gin.H{
+		"total_participants": len(res.Participants),
+		"with_username":      len(usernames),
+		"new_saved":          created,
+		"partial":            res.Partial,
+		"status":             res.Status,
+		"queries_done":       res.QueriesDone,
+		"queries_total":      res.QueriesTotal,
+		"reset":              reset,
+	})
+
+	OK(c, gin.H{
+		"group_username":     username,
+		"group_title":        groupTitle,
+		"total_participants": len(res.Participants),
+		"with_username":      len(usernames),
+		"new_saved":          created,
+		"partial":            res.Partial,
+		"status":             res.Status,
+		"progress": gin.H{
+			"collected":     len(res.Participants),
+			"total":         res.Total,
+			"queries_done":  res.QueriesDone,
+			"queries_total": res.QueriesTotal,
+		},
+	})
+}

+ 1 - 1
internal/handler/router.go

@@ -217,7 +217,7 @@ func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr
 	protected.DELETE("/channels/:id", RequireRole("admin"), ch.Delete)
 
 	// Groups
-	gh := &GroupHandler{store: s, tgManager: tgMgr}
+	gh := &GroupHandler{store: s, tgManager: tgMgr, rdb: rdb}
 	protected.GET("/groups", gh.ListGroups)
 	protected.GET("/groups/:username/members", gh.ListMembers)
 	protected.GET("/groups/:username/members/export", gh.ExportMembers)

+ 89 - 0
internal/telegram/client.go

@@ -348,6 +348,95 @@ func (c *Client) VerifyUser(ctx context.Context, username string) (*UserInfo, er
 	return &UserInfo{Username: username, Exists: false}, nil
 }
 
+// ResolveGroupChannel looks up a group/channel by username and returns both
+// the InputChannel handle (for subsequent API calls) and the raw Channel
+// struct (for metadata like title and participant count). Returns
+// (nil, nil, error) for basic chats (no InputChannel).
+func (c *Client) ResolveGroupChannel(ctx context.Context, username string) (*tg.InputChannel, *tg.Channel, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return nil, nil, err
+	}
+	username = strings.TrimPrefix(username, "@")
+
+	resolved, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: username})
+	if err != nil {
+		return nil, nil, wrapFloodWait(err)
+	}
+	for _, ch := range resolved.Chats {
+		if v, ok := ch.(*tg.Channel); ok {
+			accessHash, _ := v.GetAccessHash()
+			return &tg.InputChannel{ChannelID: v.GetID(), AccessHash: accessHash}, v, nil
+		}
+	}
+	return nil, nil, fmt.Errorf("无法解析群组为超级群组: %s", username)
+}
+
+// FetchParticipantsByQuery runs ChannelParticipantsSearch for one query string,
+// paginating through all pages. Returns the users surfaced by this query and
+// the total count reported by TG. On FloodWait, returns a *FloodWaitError.
+// The caller is responsible for deduping across queries.
+func (c *Client) FetchParticipantsByQuery(ctx context.Context, channel *tg.InputChannel, query string) ([]GroupParticipant, int, error) {
+	api, err := c.waitReady(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	const pageSize = 200
+	offset := 0
+	totalCount := 0
+	var out []GroupParticipant
+
+	for {
+		result, err := api.ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
+			Channel: channel,
+			Filter:  &tg.ChannelParticipantsSearch{Q: query},
+			Offset:  offset,
+			Limit:   pageSize,
+			Hash:    0,
+		})
+		if err != nil {
+			return out, totalCount, wrapFloodWait(err)
+		}
+		cp, ok := result.(*tg.ChannelsChannelParticipants)
+		if !ok || len(cp.Users) == 0 {
+			break
+		}
+		if cp.Count > totalCount {
+			totalCount = cp.Count
+		}
+		for _, u := range cp.Users {
+			user, ok := u.(*tg.User)
+			if !ok {
+				continue
+			}
+			p := GroupParticipant{
+				ID:        user.GetID(),
+				IsBot:     user.GetBot(),
+				IsPremium: user.GetPremium(),
+			}
+			if un, ok := user.GetUsername(); ok {
+				p.Username = un
+			}
+			if fn, ok := user.GetFirstName(); ok {
+				p.FirstName = fn
+			}
+			if ln, ok := user.GetLastName(); ok {
+				p.LastName = ln
+			}
+			out = append(out, p)
+		}
+		offset += len(cp.Users)
+		if offset >= cp.Count {
+			break
+		}
+		if err := jitterSleep(ctx, 800*time.Millisecond, 1500*time.Millisecond); err != nil {
+			return out, totalCount, err
+		}
+	}
+	return out, totalCount, nil
+}
+
 // GetGroupParticipants 获取群组/超级群组的成员列表(分页拉取全部)
 func (c *Client) GetGroupParticipants(ctx context.Context, username string) ([]GroupParticipant, error) {
 	api, err := c.waitReady(ctx)

+ 296 - 0
internal/telegram/clonegroup.go

@@ -0,0 +1,296 @@
+package telegram
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"strconv"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// CloneResult is the outcome of a (possibly partial) group-clone run.
+type CloneResult struct {
+	Username     string             `json:"group_username"`
+	GroupTitle   string             `json:"group_title"`
+	Participants []GroupParticipant `json:"participants"`
+	Total        int                `json:"total"`           // TG-reported total participant count
+	Partial      bool               `json:"partial"`         // true when the run stopped before done_queries was exhausted
+	Status       string             `json:"status"`          // "done" | "paused" (all accounts cooling) | "running" (transient)
+	QueriesDone  int                `json:"queries_done"`    // how many search queries have been completed across all runs
+	QueriesTotal int                `json:"queries_total"`   // how many search queries participantSearchQueries defines
+}
+
+const (
+	cloneKeyPrefix = "spider:tg:clone:"
+	cloneTTL       = 24 * time.Hour
+)
+
+func cloneKey(username, suffix string) string {
+	return cloneKeyPrefix + username + ":" + suffix
+}
+
+// CloneGroupMembers fetches all visible participants of a group, persisting
+// progress in Redis so that FloodWait-cooled accounts can be swapped in and
+// subsequent calls pick up from where the last one stopped.
+//
+// Behavior:
+//   - Phase 1 (empty query) runs once per session to obtain the current total.
+//   - Phase 2 iterates the extended search query set; each query is marked in
+//     Redis `done_queries` only on success, so a FloodWait in mid-query means
+//     that query will be retried on the next run.
+//   - On FloodWait, the current account is cooled via mgr.HandleFloodWait and
+//     a new account is acquired. When ErrAllCooling is returned, the function
+//     returns a partial result with Status="paused".
+//   - Set reset=true to discard the Redis state and start over.
+func CloneGroupMembers(ctx context.Context, mgr *AccountManager, rdb *redis.Client, username string, reset bool) (*CloneResult, error) {
+	if mgr == nil {
+		return nil, errors.New("account manager is nil")
+	}
+	if rdb == nil {
+		return nil, errors.New("redis client is nil")
+	}
+
+	if reset {
+		_ = rdb.Del(ctx,
+			cloneKey(username, "seen"),
+			cloneKey(username, "done_queries"),
+			cloneKey(username, "participants"),
+			cloneKey(username, "total"),
+			cloneKey(username, "status"),
+		).Err()
+	}
+
+	res := &CloneResult{Username: username, Status: "running"}
+	queries := participantSearchQueries()
+	res.QueriesTotal = len(queries)
+
+	// Restore state from Redis.
+	seen := make(map[int64]bool)
+	if ids, err := rdb.SMembers(ctx, cloneKey(username, "seen")).Result(); err == nil {
+		for _, s := range ids {
+			if id, err := strconv.ParseInt(s, 10, 64); err == nil {
+				seen[id] = true
+			}
+		}
+	}
+	doneQueries := make(map[string]bool)
+	if qs, err := rdb.SMembers(ctx, cloneKey(username, "done_queries")).Result(); err == nil {
+		for _, q := range qs {
+			doneQueries[q] = true
+		}
+	}
+	res.QueriesDone = len(doneQueries)
+	if pts, err := rdb.LRange(ctx, cloneKey(username, "participants"), 0, -1).Result(); err == nil {
+		for _, s := range pts {
+			var p GroupParticipant
+			if err := json.Unmarshal([]byte(s), &p); err == nil {
+				res.Participants = append(res.Participants, p)
+			}
+		}
+	}
+	if t, err := rdb.Get(ctx, cloneKey(username, "total")).Int(); err == nil {
+		res.Total = t
+	}
+	_ = rdb.Set(ctx, cloneKey(username, "status"), "running", cloneTTL).Err()
+
+	// Persist helpers.
+	addUsers := func(users []GroupParticipant) int {
+		added := 0
+		if len(users) == 0 {
+			return 0
+		}
+		pipe := rdb.TxPipeline()
+		for _, p := range users {
+			if seen[p.ID] {
+				continue
+			}
+			seen[p.ID] = true
+			res.Participants = append(res.Participants, p)
+			pipe.SAdd(ctx, cloneKey(username, "seen"), strconv.FormatInt(p.ID, 10))
+			if b, err := json.Marshal(p); err == nil {
+				pipe.RPush(ctx, cloneKey(username, "participants"), string(b))
+			}
+			added++
+		}
+		if added > 0 {
+			pipe.Expire(ctx, cloneKey(username, "seen"), cloneTTL)
+			pipe.Expire(ctx, cloneKey(username, "participants"), cloneTTL)
+			_, _ = pipe.Exec(ctx)
+		}
+		return added
+	}
+	markQueryDone := func(q string) {
+		if doneQueries[q] {
+			return
+		}
+		doneQueries[q] = true
+		res.QueriesDone = len(doneQueries)
+		pipe := rdb.TxPipeline()
+		pipe.SAdd(ctx, cloneKey(username, "done_queries"), q)
+		pipe.Expire(ctx, cloneKey(username, "done_queries"), cloneTTL)
+		_, _ = pipe.Exec(ctx)
+	}
+	setTotal := func(n int) {
+		if n > res.Total {
+			res.Total = n
+			_ = rdb.Set(ctx, cloneKey(username, "total"), n, cloneTTL).Err()
+		}
+	}
+
+	collectedEverything := func() bool {
+		return res.Total > 0 && len(seen) >= res.Total
+	}
+	allQueriesDone := func() bool {
+		return len(doneQueries) >= len(queries) && res.Total > 0 // require total known so we know we saw phase 1
+	}
+
+	// Main loop: keep rotating accounts until done or all cooling.
+	for {
+		if ctx.Err() != nil {
+			res.Partial = true
+			res.Status = "paused"
+			_ = rdb.Set(ctx, cloneKey(username, "status"), res.Status, cloneTTL).Err()
+			return res, ctx.Err()
+		}
+		if collectedEverything() || allQueriesDone() {
+			break
+		}
+
+		acc, err := mgr.Acquire(ctx)
+		if err != nil {
+			if errors.Is(err, ErrAllCooling) {
+				res.Partial = true
+				res.Status = "paused"
+				_ = rdb.Set(ctx, cloneKey(username, "status"), res.Status, cloneTTL).Err()
+				log.Printf("[clone_group] %s: all accounts cooling, partial %d/%d (queries %d/%d)",
+					username, len(seen), res.Total, res.QueriesDone, res.QueriesTotal)
+				return res, nil
+			}
+			return res, err
+		}
+
+		// Per-account work. cooldownSecs is set when the account hits FloodWait
+		// and should be cooled; otherwise we release with 0 (immediate availability).
+		cooldownSecs, runErr := cloneGroupWithAccount(ctx, acc, username, queries, seen, doneQueries, addUsers, markQueryDone, setTotal, res)
+
+		if cooldownSecs > 0 {
+			mgr.HandleFloodWait(acc, cooldownSecs)
+		} else {
+			mgr.Release(acc, 0)
+		}
+
+		if runErr != nil {
+			// Non-FloodWait fatal error (e.g. connect failed, channel resolve failed)
+			res.Partial = true
+			res.Status = "paused"
+			_ = rdb.Set(ctx, cloneKey(username, "status"), res.Status, cloneTTL).Err()
+			return res, runErr
+		}
+		// Otherwise loop again to try another account or confirm done.
+	}
+
+	res.Status = "done"
+	res.Partial = false
+	_ = rdb.Set(ctx, cloneKey(username, "status"), res.Status, cloneTTL).Err()
+	log.Printf("[clone_group] %s: done, %d/%d participants, %d/%d queries",
+		username, len(seen), res.Total, res.QueriesDone, res.QueriesTotal)
+	return res, nil
+}
+
+// cloneGroupWithAccount runs one account's worth of work: connect → resolve →
+// (phase 1 if total unknown) → iterate undone queries until a FloodWait, all
+// queries done, or collection is saturated. Returns (cooldownSecs, err):
+//   - cooldownSecs > 0 means the account hit FloodWait and should cool for that duration
+//   - err != nil is a non-recoverable error; caller should abort the whole run
+//   - both zero = clean exit (either all done or saturation); caller inspects res
+func cloneGroupWithAccount(
+	ctx context.Context,
+	acc *ManagedAccount,
+	username string,
+	queries []string,
+	seen map[int64]bool,
+	doneQueries map[string]bool,
+	addUsers func([]GroupParticipant) int,
+	markQueryDone func(string),
+	setTotal func(int),
+	res *CloneResult,
+) (int, error) {
+	if err := acc.Client.Connect(ctx); err != nil {
+		return 0, fmt.Errorf("connect %s: %w", acc.Account.Phone, err)
+	}
+	defer acc.Client.Disconnect()
+
+	inputCh, ch, err := acc.Client.ResolveGroupChannel(ctx, username)
+	if err != nil {
+		if fwe, ok := err.(*FloodWaitError); ok {
+			return fwe.Seconds, nil
+		}
+		return 0, fmt.Errorf("resolve %s: %w", username, err)
+	}
+	if res.GroupTitle == "" && ch != nil {
+		res.GroupTitle = ch.Title
+	}
+
+	// Phase 1: empty query. Run only once per clone session (when total is not
+	// yet known). This both seeds seen with the visible batch AND captures total.
+	if res.Total == 0 {
+		users, total, err := acc.Client.FetchParticipantsByQuery(ctx, inputCh, "")
+		if err != nil {
+			if fwe, ok := err.(*FloodWaitError); ok {
+				return fwe.Seconds, nil
+			}
+			log.Printf("[clone_group] phase1 error for %s: %v", username, err)
+			return 0, nil // non-FloodWait; abort this account, let outer loop retry
+		}
+		added := addUsers(users)
+		setTotal(total)
+		markQueryDone("") // empty string marks phase 1 as complete
+		log.Printf("[clone_group] %s phase1: +%d (total=%d)", username, added, total)
+		if res.Total > 0 && len(seen) >= res.Total {
+			return 0, nil // saturated
+		}
+		if err := jitterSleep(ctx, 2*time.Second, 4*time.Second); err != nil {
+			return 0, err
+		}
+	}
+
+	// Phase 2: iterate undone queries.
+	for _, q := range queries {
+		if ctx.Err() != nil {
+			return 0, ctx.Err()
+		}
+		if doneQueries[q] {
+			continue
+		}
+		if res.Total > 0 && len(seen) >= res.Total {
+			return 0, nil // saturated
+		}
+
+		users, total, err := acc.Client.FetchParticipantsByQuery(ctx, inputCh, q)
+		if err != nil {
+			if fwe, ok := err.(*FloodWaitError); ok {
+				// Partial results from this query may be in `users` — persist them
+				// before swapping account. Don't mark the query done.
+				addUsers(users)
+				log.Printf("[clone_group] %s q=%q flood wait %ds after %d new, %d/%d",
+					username, q, fwe.Seconds, len(users), len(seen), res.Total)
+				return fwe.Seconds, nil
+			}
+			log.Printf("[clone_group] %s q=%q error: %v (skip, not marking done)", username, q, err)
+			continue
+		}
+
+		addUsers(users)
+		setTotal(total)
+		markQueryDone(q)
+
+		if err := jitterSleep(ctx, 2*time.Second, 4*time.Second); err != nil {
+			return 0, err
+		}
+	}
+	return 0, nil
+}

+ 439 - 8
web/src/api/index.ts

@@ -1,11 +1,4 @@
-import axios from 'axios'
-
-const api = axios.create({ baseURL: '/api/v1' })
-
-api.interceptors.response.use(
-  (res) => res.data,
-  (error) => Promise.reject(error)
-)
+import api from './client'
 
 // Types
 export interface ApiResponse<T> {
@@ -24,6 +17,9 @@ export interface PagedResponse<T> {
 export interface StartTaskRequest {
   plugin_name: string
   auto_clean?: boolean
+  target_group?: string
+  proxy_id?: number
+  proxy_mode?: 'single' | 'pool'
 }
 
 export interface TaskLog {
@@ -34,6 +30,9 @@ export interface TaskLog {
   items_processed: number
   merchants_added: number
   errors_count: number
+  proxy_id: number | null
+  proxy_name: string
+  proxy_mode: string
   started_at: string | null
   finished_at: string | null
   detail: string
@@ -70,11 +69,28 @@ export interface MerchantClean {
   level: string // Hot / Warm / Cold
   status: string
   is_alive: boolean
+  follow_status: string
+  assigned_to: string
+  remark: string
   last_checked_at: string | null
   created_at: string
   updated_at: string
 }
 
+export interface AssignableUser {
+  username: string
+  nickname: string
+}
+
+export interface MerchantNote {
+  id: number
+  merchant_id: number
+  tg_username: string
+  content: string
+  created_by: string
+  created_at: string
+}
+
 export interface Keyword {
   id: number
   keyword: string
@@ -91,6 +107,53 @@ export interface MerchantStats {
   by_source: Record<string, number>
 }
 
+// Dashboard
+export interface DailyTrend {
+  date: string
+  count: number
+}
+
+export interface DashboardData {
+  raw_total: number
+  clean_total: number
+  valid_total: number
+  by_level: Record<string, number>
+  by_status: Record<string, number>
+  by_source: Record<string, number>
+  by_industry: Record<string, number>
+  today_added: number
+  week_added: number
+  recent_tasks: TaskLog[]
+  daily_trend: DailyTrend[]
+}
+
+export const getDashboard = () => api.get<unknown, ApiResponse<DashboardData>>('/dashboard')
+export const logoutApi = () => api.post<unknown, ApiResponse<null>>('/auth/logout')
+export const updateProfile = (data: { nickname?: string }) =>
+  api.put<unknown, ApiResponse<{ id: number; username: string; nickname: string; role: string }>>('/auth/profile', data)
+export const getMyPermissions = () =>
+  api.get<unknown, ApiResponse<{ role: string; menus: string[]; actions: string[] }>>('/auth/permissions')
+
+// Permissions management (admin)
+export interface RolePermission {
+  id: number
+  role: string
+  menus: string
+  actions: string
+}
+
+export interface PermissionMeta {
+  key: string
+  label: string
+}
+
+export const getAllPermissions = () =>
+  api.get<unknown, ApiResponse<{ roles: RolePermission[]; all_menus: PermissionMeta[]; all_actions: PermissionMeta[] }>>('/permissions')
+export const updateRolePermission = (role: string, menus: string[], actions: string[]) =>
+  api.put<unknown, ApiResponse<RolePermission>>(`/permissions/${role}`, { menus, actions })
+export const resetPermissions = () =>
+  api.post<unknown, ApiResponse<RolePermission[]>>('/permissions/reset')
+
 // Tasks
 export const getTasks = (params?: Record<string, unknown>) =>
   api.get<unknown, ApiResponse<PagedResponse<TaskLog>>>('/tasks', { params })
@@ -99,6 +162,8 @@ export const startTask = (data: StartTaskRequest) =>
   api.post<unknown, ApiResponse<TaskLog>>('/tasks/start', data)
 export const stopTask = (id: number) =>
   api.post<unknown, ApiResponse<null>>(`/tasks/${id}/stop`)
+export const retryTask = (id: number) =>
+  api.post<unknown, ApiResponse<TaskLog>>(`/tasks/${id}/retry`)
 
 // Merchants
 export const getMerchantsStats = () => api.get<unknown, ApiResponse<MerchantStats>>('/merchants/stats')
@@ -107,6 +172,43 @@ export const getMerchantsRaw = (params?: Record<string, unknown>) =>
 export const getMerchantsClean = (params?: Record<string, unknown>) =>
   api.get<unknown, ApiResponse<PagedResponse<MerchantClean>>>('/merchants/clean', { params })
 export const getMerchant = (id: number) => api.get<unknown, ApiResponse<unknown>>(`/merchants/${id}`)
+export const updateMerchantClean = (id: number, data: Record<string, unknown>) =>
+  api.put<unknown, ApiResponse<MerchantClean>>(`/merchants/clean/${id}`, data)
+export const updateFollowStatus = (id: number, follow_status: string) =>
+  api.put<unknown, ApiResponse<null>>(`/merchants/clean/${id}/follow-status`, { follow_status })
+export const getMerchantNotes = (id: number) =>
+  api.get<unknown, ApiResponse<MerchantNote[]>>(`/merchants/clean/${id}/notes`)
+export const addMerchantNote = (id: number, content: string) =>
+  api.post<unknown, ApiResponse<MerchantNote>>(`/merchants/clean/${id}/notes`, { content })
+export const batchDeleteRaw = (ids: number[]) =>
+  api.delete<unknown, ApiResponse<{ deleted: number }>>('/merchants/raw/batch', { data: { ids } })
+export const batchDeleteClean = (ids: number[]) =>
+  api.delete<unknown, ApiResponse<{ deleted: number }>>('/merchants/clean/batch', { data: { ids } })
+export const assignMerchant = (id: number, assigned_to: string) =>
+  api.put<unknown, ApiResponse<MerchantClean>>(`/merchants/clean/${id}/assign`, { assigned_to })
+export const batchAssign = (ids: number[], assigned_to: string) =>
+  api.put<unknown, ApiResponse<{ updated: number }>>('/merchants/clean/batch-assign', { ids, assigned_to })
+export const batchFollowStatus = (ids: number[], follow_status: string) =>
+  api.put<unknown, ApiResponse<{ updated: number }>>('/merchants/clean/batch-follow-status', { ids, follow_status })
+export const batchLevel = (ids: number[], level: string) =>
+  api.put<unknown, ApiResponse<{ updated: number }>>('/merchants/clean/batch-level', { ids, level })
+export const getAssignableUsers = () =>
+  api.get<unknown, ApiResponse<AssignableUser[]>>('/merchants/clean/users')
+export const getIndustryTags = () =>
+  api.get<unknown, ApiResponse<string[]>>('/merchants/clean/industry-tags')
+export const mergeMerchants = (primary_id: number, secondary_id: number) =>
+  api.post<unknown, ApiResponse<MerchantClean>>('/merchants/clean/merge', { primary_id, secondary_id })
+export const recheckMerchant = (id: number) =>
+  api.post<unknown, ApiResponse<{ message: string }>>(`/merchants/clean/${id}/recheck`)
+export const batchRecheck = (ids: number[]) =>
+  api.post<unknown, ApiResponse<{ updated: number }>>('/merchants/clean/batch-recheck', { ids })
+export const importMerchantsCSV = (file: File) => {
+  const form = new FormData()
+  form.append('file', file)
+  return api.post<unknown, ApiResponse<{ imported: number; skipped: number; failed: number; errors: string[] }>>('/merchants/clean/import', form, {
+    headers: { 'Content-Type': 'multipart/form-data' },
+  })
+}
 
 // Keywords
 export const getKeywords = (params?: Record<string, unknown>) =>
@@ -116,3 +218,332 @@ export const createKeywords = (data: { keywords: string[]; industry_tag: string
 export const updateKeyword = (id: number, data: Partial<Keyword>) =>
   api.put<unknown, ApiResponse<Keyword>>(`/keywords/${id}`, data)
 export const deleteKeyword = (id: number) => api.delete<unknown, ApiResponse<null>>(`/keywords/${id}`)
+export const importKeywordsCSV = (file: File, industryTag?: string) => {
+  const form = new FormData()
+  form.append('file', file)
+  if (industryTag) form.append('industry_tag', industryTag)
+  return api.post<unknown, ApiResponse<{ imported: number; skipped: number; errors: string[] }>>('/keywords/import', form, {
+    headers: { 'Content-Type': 'multipart/form-data' },
+  })
+}
+
+// Settings
+export interface LevelDef {
+  key: string
+  label: string
+  color: string
+  description: string
+  rules: GradeRule[]
+}
+
+export interface GradeRule {
+  has_industry?: boolean
+  has_website?: boolean
+  has_email?: boolean
+  has_phone?: boolean
+  min_source_count?: number
+}
+
+export interface GradingConfig {
+  levels: LevelDef[]
+  industry_keywords: Record<string, string[]>
+}
+
+export const getGradingConfig = () => api.get<unknown, ApiResponse<GradingConfig>>('/settings/grading')
+export const updateGradingConfig = (data: GradingConfig) => api.put<unknown, ApiResponse<GradingConfig>>('/settings/grading', data)
+export const resetGradingConfig = () => api.post<unknown, ApiResponse<GradingConfig>>('/settings/grading/reset')
+export const getLevelMap = () => api.get<unknown, ApiResponse<Record<string, { label: string; color: string; description: string }>>>('/settings/level-map')
+
+// Groups
+export interface GroupSummary {
+  group_username: string
+  group_title: string
+  member_count: number
+  source_type: string
+}
+
+export interface GroupMember {
+  id: number
+  group_username: string
+  member_username: string
+  group_title: string
+  source_type: string
+  task_id: number
+  discovered_at: string
+}
+
+export const getGroups = (params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<GroupSummary>>>('/groups', { params })
+export const getGroupMembers = (username: string, params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<GroupMember>>>(`/groups/${username}/members`, { params })
+export const getMemberGroups = (username: string) =>
+  api.get<unknown, ApiResponse<GroupMember[]>>(`/member-groups/${username}`)
+
+export interface CloneMembersResult {
+  group_username: string
+  group_title: string
+  total_participants: number
+  with_username: number
+  new_saved: number
+  partial: boolean
+  status: string
+  progress: {
+    collected: number
+    total: number
+    queries_done: number
+    queries_total: number
+  }
+}
+export const cloneGroupMembers = (username: string, reset = false) =>
+  api.post<unknown, ApiResponse<CloneMembersResult>>(
+    `/groups/${username}/clone-members${reset ? '?reset=1' : ''}`
+  )
+
+export interface MemberSummary {
+  member_username: string
+  group_count: number
+  last_seen: string
+}
+export const searchMembers = (q: string, params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<MemberSummary>>>('/members/search', { params: { q, ...params } })
+
+// Task details (per-operation execution logs)
+export interface TaskDetail {
+  id: number
+  task_id: number
+  seq: number
+  action: string
+  url: string
+  parent_url: string
+  depth: number
+  input: string
+  output: string
+  status: string
+  duration_ms: number
+  extra: string
+  created_at: string
+}
+
+export interface TaskDetailResponse {
+  items: TaskDetail[]
+  total: number
+  page: number
+  page_size: number
+  summary: Record<string, Record<string, number>>
+}
+
+export const getTaskDetails = (id: number, params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<TaskDetailResponse>>(`/tasks/${id}/details`, { params })
+
+// Schedules
+export interface ScheduleJob {
+  id: number
+  name: string
+  plugin_name: string
+  cron_expr: string
+  enabled: boolean
+  last_run_at: string | null
+  next_run_at: string | null
+  created_at: string
+  updated_at: string
+}
+
+export const getSchedules = () =>
+  api.get<unknown, ApiResponse<ScheduleJob[]>>('/schedules')
+export const createSchedule = (data: { name: string; plugin_name: string; cron_expr: string }) =>
+  api.post<unknown, ApiResponse<ScheduleJob>>('/schedules', data)
+export const updateSchedule = (id: number, data: Partial<Pick<ScheduleJob, 'name' | 'cron_expr' | 'enabled'>>) =>
+  api.put<unknown, ApiResponse<ScheduleJob>>(`/schedules/${id}`, data)
+export const deleteSchedule = (id: number) =>
+  api.delete<unknown, ApiResponse<null>>(`/schedules/${id}`)
+export const runScheduleNow = (id: number) =>
+  api.post<unknown, ApiResponse<TaskLog>>(`/schedules/${id}/run`)
+
+// Audit logs
+export interface AuditLog {
+  id: number
+  username: string
+  action: string
+  target_type: string
+  target_id: string
+  detail: Record<string, unknown>
+  ip: string
+  created_at: string
+}
+
+export const getAuditLogs = (params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<AuditLog>>>('/audit-logs', { params })
+
+// Notification configs
+export interface NotificationConfig {
+  id: number
+  name: string
+  event_type: string
+  channel: string
+  config: Record<string, string>
+  enabled: boolean
+  created_at: string
+  updated_at: string
+}
+
+export const getNotificationConfigs = () =>
+  api.get<unknown, ApiResponse<NotificationConfig[]>>('/notification-configs')
+export const createNotificationConfig = (data: Partial<NotificationConfig>) =>
+  api.post<unknown, ApiResponse<NotificationConfig>>('/notification-configs', data)
+export const updateNotificationConfig = (id: number, data: Partial<NotificationConfig>) =>
+  api.put<unknown, ApiResponse<NotificationConfig>>(`/notification-configs/${id}`, data)
+export const deleteNotificationConfig = (id: number) =>
+  api.delete<unknown, ApiResponse<null>>(`/notification-configs/${id}`)
+export const testNotificationConfig = (id: number) =>
+  api.post<unknown, ApiResponse<{ message: string }>>(`/notification-configs/${id}/test`)
+
+// System health
+export interface SystemHealth {
+  mysql: { status: string; open_conns?: number; in_use?: number; idle?: number; max_open?: number; error?: string }
+  redis: { status: string; error?: string }
+  tg_accounts: Record<string, number>
+  tasks_24h: Record<string, number>
+  data: { merchants_raw: number; merchants_clean: number; task_details: number }
+}
+
+export const getSystemHealth = () =>
+  api.get<unknown, ApiResponse<SystemHealth>>('/system/health')
+
+// Archived merchants
+export interface MerchantArchived {
+  id: number
+  original_id: number
+  tg_username: string
+  merchant_name: string
+  level: string
+  status: string
+  follow_status: string
+  archive_reason: string
+  archived_at: string
+  created_at: string
+}
+
+export const archiveMerchants = (params?: { max_days_invalid?: number; max_days_rejected?: number }) =>
+  api.post<unknown, ApiResponse<{ archived: number; candidates: number }>>('/merchants/archive', params)
+export const getArchivedMerchants = (params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<MerchantArchived>>>('/merchants/archived', { params })
+export const restoreArchived = (id: number) =>
+  api.post<unknown, ApiResponse<MerchantClean>>(`/merchants/archived/${id}/restore`)
+
+// Analytics
+export interface FunnelData {
+  raw_total: number
+  clean_total: number
+  valid_total: number
+  contacted: number
+  cooperating: number
+  rejected: number
+  conversion_rates: Record<string, number>
+}
+
+export interface SourceEfficiency {
+  by_source_type: { source_type: string; raw_count: number; clean_count: number; hot_count: number; efficiency: number }[]
+  top_keywords: { keyword: string; merchants_found: number }[]
+  top_groups: { group_username: string; members_found: number }[]
+}
+
+export interface TrendPeriod {
+  period_label: string
+  raw_added: number
+  clean_added: number
+}
+
+export const getAnalyticsFunnel = () =>
+  api.get<unknown, ApiResponse<FunnelData>>('/analytics/funnel')
+export const getAnalyticsSourceEfficiency = () =>
+  api.get<unknown, ApiResponse<SourceEfficiency>>('/analytics/source-efficiency')
+export const getAnalyticsTrends = (params?: { period?: string; range?: string }) =>
+  api.get<unknown, ApiResponse<{ period: string; data: TrendPeriod[] }>>('/analytics/trends', { params })
+
+// Proxies
+export interface Proxy {
+  id: number
+  name: string
+  protocol: string
+  host: string
+  port: number
+  username: string
+  password: string
+  region: string
+  enabled: boolean
+  status: string
+  last_checked_at: string | null
+  remark: string
+  created_at: string
+}
+
+export const getProxies = (params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<Proxy>>>('/proxies', { params })
+export const getEnabledProxies = () =>
+  api.get<unknown, ApiResponse<Proxy[]>>('/proxies/enabled')
+
+export interface ProxyPoolEntry {
+  id: number
+  name: string
+  url: string
+  region: string
+  failures: number
+  disabled: boolean
+  cool_down: string
+}
+export interface ProxyPoolStatus {
+  active: boolean
+  message?: string
+  total?: number
+  active_count?: number
+  proxies?: ProxyPoolEntry[]
+}
+export const getProxyPoolStatus = () =>
+  api.get<unknown, ApiResponse<ProxyPoolStatus>>('/proxies/pool-status')
+export const createProxy = (data: Partial<Proxy>) =>
+  api.post<unknown, ApiResponse<Proxy>>('/proxies', data)
+export const updateProxy = (id: number, data: Partial<Proxy>) =>
+  api.put<unknown, ApiResponse<Proxy>>(`/proxies/${id}`, data)
+export const deleteProxy = (id: number) =>
+  api.delete<unknown, ApiResponse<null>>(`/proxies/${id}`)
+export const testProxy = (id: number) =>
+  api.post<unknown, ApiResponse<{ status: string; proxy_url: string; error?: string }>>(`/proxies/${id}/test`)
+
+export interface TestAllResult {
+  total: number
+  ok: number
+  fail: number
+  results: { id: number; name: string; status: string; error?: string }[]
+}
+export const testAllProxies = () =>
+  api.post<unknown, ApiResponse<TestAllResult>>('/proxies/test-all')
+
+// Backup
+export const getBackupStats = () =>
+  api.get<unknown, ApiResponse<{ table: string; rows: number }[]>>('/backup/stats')
+
+// Channels
+export interface Channel {
+  id: number
+  username: string
+  status: string
+  merchants_found: number
+  source: string
+  created_at: string
+  updated_at: string
+}
+
+export const getChannels = (params?: Record<string, unknown>) =>
+  api.get<unknown, ApiResponse<PagedResponse<Channel>>>('/channels', { params })
+export const getChannelStats = () =>
+  api.get<unknown, ApiResponse<{ total: number; by_status: { key: string; count: number }[]; by_source: { key: string; count: number }[] }>>('/channels/stats')
+export const updateChannelStatus = (id: number, status: string) =>
+  api.put<unknown, ApiResponse<Channel>>(`/channels/${id}/status`, { status })
+export const deleteChannel = (id: number) =>
+  api.delete<unknown, ApiResponse<null>>(`/channels/${id}`)
+
+// Build WebSocket URL for task logs
+export function buildTaskLogWsUrl(taskId: number): string {
+  const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+  return `${proto}//${window.location.host}/api/v1/tasks/${taskId}/logs`
+}

+ 429 - 0
web/src/pages/Groups.tsx

@@ -0,0 +1,429 @@
+import { useEffect, useState, useCallback } from 'react'
+import { Table, Tag, Input, message, Row, Col, Modal, Typography, Space, Button, Popconfirm, Tabs } from 'antd'
+import { TeamOutlined, UserOutlined, LinkOutlined, DownloadOutlined, SearchOutlined } from '@ant-design/icons'
+import { getGroups, getGroupMembers, cloneGroupMembers, searchMembers, type GroupSummary, type GroupMember, type MemberSummary } from '../api'
+
+const { Text } = Typography
+
+function formatDateTime(dateStr: string) {
+  return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+const sourceTypeColor: Record<string, string> = {
+  web: 'blue',
+  tg_channel: 'orange',
+  tg_group: 'green',
+  tg_clone: 'purple',
+  github: 'geekblue',
+}
+
+export default function Groups() {
+  const [groups, setGroups] = useState<GroupSummary[]>([])
+  const [total, setTotal] = useState(0)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(false)
+  const [search, setSearch] = useState('')
+
+  const [cloning, setCloning] = useState<string | null>(null)
+  // 新克隆群组输入
+  const [cloneInput, setCloneInput] = useState('')
+  const [cloneInputLoading, setCloneInputLoading] = useState(false)
+
+  // Member search
+  const [memberSearch, setMemberSearch] = useState('')
+  const [memberResults, setMemberResults] = useState<MemberSummary[]>([])
+  const [memberResultsTotal, setMemberResultsTotal] = useState(0)
+  const [memberResultsPage, setMemberResultsPage] = useState(1)
+  const [memberSearchLoading, setMemberSearchLoading] = useState(false)
+
+  // Member modal
+  const [selectedGroup, setSelectedGroup] = useState<GroupSummary | null>(null)
+  const [members, setMembers] = useState<GroupMember[]>([])
+  const [membersTotal, setMembersTotal] = useState(0)
+  const [membersPage, setMembersPage] = useState(1)
+  const [membersLoading, setMembersLoading] = useState(false)
+  const [membersSearch, setMembersSearch] = useState('')
+
+  const fetchGroups = useCallback(async (currentPage = 1) => {
+    setLoading(true)
+    try {
+      const params: Record<string, unknown> = { page: currentPage, page_size: 20 }
+      if (search) params.search = search
+      const res = await getGroups(params)
+      setGroups(res.data.items)
+      setTotal(res.data.total)
+    } catch {
+      message.error('获取群组列表失败')
+    } finally {
+      setLoading(false)
+    }
+  }, [search])
+
+  useEffect(() => {
+    setPage(1)
+    fetchGroups(1)
+  }, [search, fetchGroups])
+
+  const fetchMembers = useCallback(async (groupUsername: string, currentPage = 1, searchStr = '') => {
+    setMembersLoading(true)
+    try {
+      const params: Record<string, unknown> = { page: currentPage, page_size: 20 }
+      if (searchStr) params.search = searchStr
+      const res = await getGroupMembers(groupUsername, params)
+      setMembers(res.data.items)
+      setMembersTotal(res.data.total)
+    } catch {
+      message.error('获取成员列表失败')
+    } finally {
+      setMembersLoading(false)
+    }
+  }, [])
+
+  const handleClone = async (username: string, reset = false) => {
+    setCloning(username)
+    try {
+      const res = await cloneGroupMembers(username, reset)
+      const r = res.data
+      const p = r.progress
+      if (r.partial) {
+        message.warning(
+          `本轮进度 ${p.queries_done}/${p.queries_total} 条查询,已拿到 ${p.collected}/${p.total} 成员 —— 账号全部冷却中,稍后再次点击会从断点续跑`,
+          8
+        )
+      } else {
+        message.success(
+          `克隆完成:${p.collected}/${p.total} 成员,${r.with_username} 个有用户名,新增 ${r.new_saved} 条记录`
+        )
+      }
+      fetchGroups(page)
+    } catch (err: unknown) {
+      const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message
+      message.error(msg || '克隆群成员失败')
+    } finally {
+      setCloning(null)
+    }
+  }
+
+  const handleMemberSearch = useCallback(async (q: string, p = 1) => {
+    if (!q.trim()) return
+    setMemberSearchLoading(true)
+    try {
+      const res = await searchMembers(q.trim(), { page: p, page_size: 20 })
+      setMemberResults(res.data.items)
+      setMemberResultsTotal(res.data.total)
+    } catch {
+      message.error('搜索成员失败')
+    } finally {
+      setMemberSearchLoading(false)
+    }
+  }, [])
+
+  const handleCloneInput = async () => {
+    const username = cloneInput.trim().replace(/^@/, '').replace(/^https?:\/\/t\.me\//, '')
+    if (!username) {
+      message.warning('请输入群组用户名')
+      return
+    }
+    setCloneInputLoading(true)
+    try {
+      const res = await cloneGroupMembers(username)
+      const r = res.data
+      const p = r.progress
+      if (r.partial) {
+        message.warning(
+          `本轮进度 ${p.queries_done}/${p.queries_total} 条查询,已拿到 ${p.collected}/${p.total} 成员 —— 账号全部冷却中,稍后再次点击会从断点续跑`,
+          8
+        )
+      } else {
+        message.success(
+          `克隆完成:${p.collected}/${p.total} 成员,${r.with_username} 个有用户名,新增 ${r.new_saved} 条记录`
+        )
+      }
+      setCloneInput('')
+      fetchGroups(page)
+    } catch (err: unknown) {
+      const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message
+      message.error(msg || '克隆群成员失败')
+    } finally {
+      setCloneInputLoading(false)
+    }
+  }
+
+  const openMembers = (group: GroupSummary) => {
+    setSelectedGroup(group)
+    setMembersPage(1)
+    setMembers([])
+    setMembersSearch('')
+    fetchMembers(group.group_username, 1, '')
+  }
+
+  const groupColumns = [
+    {
+      title: '群/频道', key: 'group', width: 260,
+      render: (_: unknown, record: GroupSummary) => (
+        <div>
+          <a href={`https://t.me/${record.group_username}`} target="_blank" rel="noreferrer">
+            <TeamOutlined style={{ marginRight: 6 }} />@{record.group_username}
+          </a>
+          {record.group_title && (
+            <div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>{record.group_title}</div>
+          )}
+        </div>
+      ),
+    },
+    {
+      title: '类型', dataIndex: 'source_type', key: 'source_type', width: 120,
+      render: (v: string) => <Tag color={sourceTypeColor[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '成员数', dataIndex: 'member_count', key: 'member_count', width: 100,
+      sorter: (a: GroupSummary, b: GroupSummary) => a.member_count - b.member_count,
+      render: (v: number) => (
+        <Space>
+          <UserOutlined />
+          <Text strong>{v}</Text>
+        </Space>
+      ),
+    },
+    {
+      title: '操作', key: 'action', width: 200,
+      render: (_: unknown, record: GroupSummary) => (
+        <Space>
+          <Button type="link" size="small" onClick={() => openMembers(record)}>
+            查看成员
+          </Button>
+          <Popconfirm
+            title="克隆群成员"
+            description={`从 TG 拉取 @${record.group_username} 的成员(支持断点续跑 + 多账号轮换)。再次点击会从上次中断处继续。`}
+            onConfirm={() => handleClone(record.group_username)}
+            okText="开始/继续"
+            cancelText="取消"
+          >
+            <Button
+              type="link"
+              size="small"
+              icon={<DownloadOutlined />}
+              loading={cloning === record.group_username}
+            >
+              克隆成员
+            </Button>
+          </Popconfirm>
+          <Popconfirm
+            title="重置并克隆"
+            description="清空之前的进度,从头开始。用于群成员变化较大时。"
+            onConfirm={() => handleClone(record.group_username, true)}
+            okText="重置"
+            cancelText="取消"
+          >
+            <Button type="link" size="small" danger>重置</Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]
+
+  const memberColumns = [
+    {
+      title: '成员', dataIndex: 'member_username', key: 'member_username',
+      render: (v: string) => (
+        <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">@{v}</a>
+      ),
+    },
+    {
+      title: '来源', dataIndex: 'source_type', key: 'source_type', width: 120,
+      render: (v: string) => <Tag color={sourceTypeColor[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '发现时间', dataIndex: 'discovered_at', key: 'discovered_at', width: 170,
+      render: (v: string) => v ? formatDateTime(v) : '-',
+    },
+  ]
+
+  const memberSearchColumns = [
+    {
+      title: '成员', dataIndex: 'member_username', key: 'member_username',
+      render: (v: string) => (
+        <a href={`https://t.me/${v}`} target="_blank" rel="noreferrer">
+          <UserOutlined style={{ marginRight: 4 }} />@{v}
+        </a>
+      ),
+    },
+    {
+      title: '所在群组数', dataIndex: 'group_count', key: 'group_count', width: 120,
+      sorter: (a: MemberSummary, b: MemberSummary) => a.group_count - b.group_count,
+      render: (v: number) => <Tag color="blue">{v} 个群</Tag>,
+    },
+    {
+      title: '最近发现', dataIndex: 'last_seen', key: 'last_seen', width: 170,
+      render: (v: string) => v ? formatDateTime(v) : '-',
+    },
+  ]
+
+  return (
+    <div>
+      <Tabs defaultActiveKey="groups" items={[
+        {
+          key: 'groups',
+          label: <><TeamOutlined /> 群组列表</>,
+          children: (
+            <>
+              <Row gutter={[16, 16]} style={{ marginBottom: 16 }} align="middle">
+                <Col>
+                  <Input.Search
+                    placeholder="搜索群/频道名"
+                    onSearch={setSearch}
+                    style={{ width: 280 }}
+                    allowClear
+                  />
+                </Col>
+                <Col>
+                  <Space>
+                    <Input
+                      placeholder="输入群组用户名或链接"
+                      value={cloneInput}
+                      onChange={(e) => setCloneInput(e.target.value)}
+                      onPressEnter={handleCloneInput}
+                      style={{ width: 240 }}
+                    />
+                    <Button
+                      type="primary"
+                      icon={<DownloadOutlined />}
+                      loading={cloneInputLoading}
+                      onClick={handleCloneInput}
+                    >
+                      克隆群成员
+                    </Button>
+                  </Space>
+                </Col>
+                <Col flex="auto" style={{ textAlign: 'right' }}>
+                  <Text type="secondary">共 {total} 个群/频道</Text>
+                </Col>
+              </Row>
+
+              <Table
+                dataSource={groups}
+                columns={groupColumns}
+                rowKey="group_username"
+                loading={loading}
+                pagination={{
+                  current: page,
+                  pageSize: 20,
+                  total,
+                  onChange: (p) => { setPage(p); fetchGroups(p) },
+                  showTotal: (t) => `共 ${t} 条`,
+                }}
+              />
+            </>
+          ),
+        },
+        {
+          key: 'members',
+          label: <><SearchOutlined /> 成员搜索</>,
+          children: (
+            <>
+              <Row gutter={[16, 16]} style={{ marginBottom: 16 }} align="middle">
+                <Col>
+                  <Input.Search
+                    placeholder="搜索成员用户名"
+                    onSearch={(v) => { setMemberSearch(v); setMemberResultsPage(1); handleMemberSearch(v, 1) }}
+                    style={{ width: 320 }}
+                    enterButton={<><SearchOutlined /> 搜索</>}
+                    allowClear
+                  />
+                </Col>
+                <Col flex="auto" style={{ textAlign: 'right' }}>
+                  {memberResultsTotal > 0 && (
+                    <Text type="secondary">找到 {memberResultsTotal} 个成员</Text>
+                  )}
+                </Col>
+              </Row>
+
+              <Table
+                dataSource={memberResults}
+                columns={memberSearchColumns}
+                rowKey="member_username"
+                loading={memberSearchLoading}
+                pagination={{
+                  current: memberResultsPage,
+                  pageSize: 20,
+                  total: memberResultsTotal,
+                  onChange: (p) => { setMemberResultsPage(p); handleMemberSearch(memberSearch, p) },
+                  showTotal: (t) => `共 ${t} 条`,
+                }}
+                locale={{ emptyText: memberSearch ? '未找到匹配的成员' : '输入用户名进行搜索' }}
+              />
+            </>
+          ),
+        },
+      ]} />
+
+      <Modal
+        title={
+          selectedGroup ? (
+            <Space>
+              <TeamOutlined />
+              <span>@{selectedGroup.group_username}</span>
+              {selectedGroup.group_title && (
+                <Text type="secondary" style={{ fontSize: 14 }}>({selectedGroup.group_title})</Text>
+              )}
+              <Tag>{membersTotal} 个成员</Tag>
+            </Space>
+          ) : '成员列表'
+        }
+        open={!!selectedGroup}
+        onCancel={() => setSelectedGroup(null)}
+        footer={
+          selectedGroup ? (
+            <Button
+              icon={<DownloadOutlined />}
+              onClick={() => window.open(`/api/v1/groups/${selectedGroup.group_username}/members/export`, '_blank')}
+            >
+              导出 CSV
+            </Button>
+          ) : null
+        }
+        width={600}
+      >
+        {selectedGroup && (
+          <Row gutter={[8, 8]} align="middle" style={{ marginBottom: 12 }}>
+            <Col>
+              <a href={`https://t.me/${selectedGroup.group_username}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
+                <LinkOutlined /> https://t.me/{selectedGroup.group_username}
+              </a>
+            </Col>
+            <Col flex="auto" style={{ textAlign: 'right' }}>
+              <Input.Search
+                placeholder="搜索成员"
+                size="small"
+                style={{ width: 180 }}
+                allowClear
+                onSearch={(v) => {
+                  setMembersSearch(v)
+                  setMembersPage(1)
+                  fetchMembers(selectedGroup.group_username, 1, v)
+                }}
+              />
+            </Col>
+          </Row>
+        )}
+        <Table
+          dataSource={members}
+          columns={memberColumns}
+          rowKey="id"
+          loading={membersLoading}
+          size="small"
+          pagination={{
+            current: membersPage,
+            pageSize: 20,
+            total: membersTotal,
+            onChange: (p) => {
+              setMembersPage(p)
+              if (selectedGroup) fetchMembers(selectedGroup.group_username, p, membersSearch)
+            },
+            showTotal: (t) => `共 ${t} 条`,
+          }}
+        />
+      </Modal>
+    </div>
+  )
+}