mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
feat(contacts): channel contacts table, auto-collector, contacts page & managers tab redesign
- Add channel_contacts migration (000014) with UNIQUE(channel_type, sender_id) - Add ContactStore interface with UPSERT, list, count, merge operations - Add ContactCollector with 30-min TTL cache to skip redundant DB writes - Wire auto-collection into gateway consumer on every inbound message - Add GET /v1/contacts API with pagination, search, channel_type & peer_kind filters - Rename Writers tab → Managers tab (UI-only; backend routes unchanged) - Extract InlineAddForm with scoped state, debounce cleanup, aria-expanded - Add Combobox contact picker with debounced search + auto-fill - Add Contacts page with server-side pagination, filters, i18n (en/vi/zh) - Add shared ChannelContact type, sidebar nav entry, route & query keys - Fix ILIKE wildcard escape, log CountContacts errors, extract shared type
This commit is contained in:
1 parent
1ddd2e547a
commit
c8dc9917fe
32 files changed
+1204
-416
No files matched your search
+18
-2
@@ -26,7 +26,7 @@ import (
|
||||
// and routes them through the scheduler/agent loop, then publishes the response back.
|
||||
// Also handles subagent announcements: routes them through the parent agent's session
|
||||
// (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user.
|
||||
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, delegateMgr *tools.DelegateManager, sessStore store.SessionStore, agentStore store.AgentStore) {
|
||||
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, delegateMgr *tools.DelegateManager, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector) {
|
||||
slog.Info("inbound message consumer started")
|
||||
|
||||
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
|
||||
@@ -110,7 +110,8 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
|
||||
// Persist friendly names from channel metadata into session + user profile.
|
||||
if sessionMeta := extractSessionMetadata(msg, peerKind); len(sessionMeta) > 0 {
|
||||
sessionMeta := extractSessionMetadata(msg, peerKind)
|
||||
if len(sessionMeta) > 0 {
|
||||
sessStore.SetSessionMetadata(sessionKey, sessionMeta)
|
||||
if agentStore != nil {
|
||||
if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
|
||||
@@ -119,6 +120,21 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-collect channel contacts for the contact selector.
|
||||
if contactCollector != nil && msg.SenderID != "" {
|
||||
senderNumericID := msg.SenderID
|
||||
if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 {
|
||||
senderNumericID = senderNumericID[:idx]
|
||||
}
|
||||
channelType := channelMgr.ChannelTypeForName(msg.Channel)
|
||||
if channelType == "" {
|
||||
channelType = msg.Channel // fallback to instance name
|
||||
}
|
||||
displayName := sessionMeta["display_name"]
|
||||
username := sessionMeta["username"]
|
||||
contactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind)
|
||||
}
|
||||
|
||||
// --- Quota check ---
|
||||
if quotaChecker != nil {
|
||||
qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
|
||||
|
||||
@@ -52,7 +52,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
|
||||
}
|
||||
|
||||
if stores != nil && stores.ChannelInstances != nil {
|
||||
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, token, msgBus)
|
||||
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.Contacts, token, msgBus)
|
||||
}
|
||||
|
||||
if stores != nil && stores.Providers != nil {
|
||||
|
||||
@@ -16,15 +16,16 @@ import (
|
||||
|
||||
// ChannelInstancesHandler handles channel instance CRUD endpoints.
|
||||
type ChannelInstancesHandler struct {
|
||||
store store.ChannelInstanceStore
|
||||
agentStore store.AgentStore
|
||||
token string
|
||||
msgBus *bus.MessageBus
|
||||
store store.ChannelInstanceStore
|
||||
agentStore store.AgentStore
|
||||
contactStore store.ContactStore
|
||||
token string
|
||||
msgBus *bus.MessageBus
|
||||
}
|
||||
|
||||
// NewChannelInstancesHandler creates a handler for channel instance management endpoints.
|
||||
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
|
||||
return &ChannelInstancesHandler{store: s, agentStore: agentStore, token: token, msgBus: msgBus}
|
||||
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
|
||||
return &ChannelInstancesHandler{store: s, agentStore: agentStore, contactStore: contactStore, token: token, msgBus: msgBus}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all channel instance routes on the given mux.
|
||||
@@ -35,6 +36,11 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /v1/channels/instances/{id}", h.auth(h.handleUpdate))
|
||||
mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.auth(h.handleDelete))
|
||||
|
||||
// Channel contacts (global, not per-agent)
|
||||
if h.contactStore != nil {
|
||||
mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts))
|
||||
}
|
||||
|
||||
// Group file writers (nested under channel instances)
|
||||
if h.agentStore != nil {
|
||||
mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups))
|
||||
@@ -388,6 +394,58 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
||||
}
|
||||
|
||||
// --- Channel contacts ---
|
||||
|
||||
func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
opts := store.ContactListOpts{
|
||||
Limit: 50,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("search"); v != "" {
|
||||
opts.Search = v
|
||||
}
|
||||
if v := r.URL.Query().Get("channel_type"); v != "" {
|
||||
opts.ChannelType = v
|
||||
}
|
||||
if v := r.URL.Query().Get("peer_kind"); v != "" {
|
||||
opts.PeerKind = v
|
||||
}
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
opts.Limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
opts.Offset = n
|
||||
}
|
||||
}
|
||||
|
||||
contacts, err := h.contactStore.ListContacts(r.Context(), opts)
|
||||
if err != nil {
|
||||
slog.Error("contacts.list", "error", err)
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")})
|
||||
return
|
||||
}
|
||||
if contacts == nil {
|
||||
contacts = []store.ChannelContact{}
|
||||
}
|
||||
|
||||
total, countErr := h.contactStore.CountContacts(r.Context(), opts)
|
||||
if countErr != nil {
|
||||
slog.Warn("contacts.count", "error", countErr)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"contacts": contacts,
|
||||
"total": total,
|
||||
"limit": opts.Limit,
|
||||
"offset": opts.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
// isValidChannelType checks if the channel type is supported.
|
||||
func isValidChannelType(ct string) bool {
|
||||
switch ct {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
||||
)
|
||||
|
||||
const contactSeenTTL = 30 * time.Minute
|
||||
|
||||
// ContactCollector wraps ContactStore with an in-memory "seen" cache
|
||||
// to avoid redundant UPSERT queries on every message.
|
||||
type ContactCollector struct {
|
||||
store ContactStore
|
||||
seen cache.Cache[bool]
|
||||
}
|
||||
|
||||
// NewContactCollector creates a new collector backed by the given store and cache.
|
||||
func NewContactCollector(s ContactStore, c cache.Cache[bool]) *ContactCollector {
|
||||
return &ContactCollector{store: s, seen: c}
|
||||
}
|
||||
|
||||
// EnsureContact creates or refreshes a contact entry, skipping DB if recently seen.
|
||||
func (c *ContactCollector) EnsureContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) {
|
||||
key := channelType + ":" + senderID
|
||||
if _, ok := c.seen.Get(ctx, key); ok {
|
||||
return
|
||||
}
|
||||
if err := c.store.UpsertContact(ctx, channelType, channelInstance, senderID, userID, displayName, username, peerKind); err != nil {
|
||||
slog.Warn("contact_collector.upsert_failed", "error", err, "channel", channelType, "sender", senderID)
|
||||
return
|
||||
}
|
||||
c.seen.Set(ctx, key, true, contactSeenTTL)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ChannelContact represents a user discovered through channel interactions.
|
||||
// Global (not per-agent): same person on the same platform = one row.
|
||||
type ChannelContact struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
ChannelInstance *string `json:"channel_instance,omitempty"`
|
||||
SenderID string `json:"sender_id"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
PeerKind *string `json:"peer_kind,omitempty"`
|
||||
MergedID *uuid.UUID `json:"merged_id,omitempty"`
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// ContactListOpts holds pagination and filter options for listing contacts.
|
||||
type ContactListOpts struct {
|
||||
Search string // ILIKE on display_name, username, sender_id
|
||||
ChannelType string // filter by platform (telegram, discord, etc.)
|
||||
PeerKind string // "direct" or "group"
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ContactStore manages channel contacts (auto-collected user info).
|
||||
type ContactStore interface {
|
||||
// UpsertContact creates or updates a contact. On conflict (channel_type, sender_id),
|
||||
// updates display_name, username, user_id, channel_instance, and last_seen_at.
|
||||
UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error
|
||||
|
||||
// ListContacts searches contacts with pagination and filters.
|
||||
ListContacts(ctx context.Context, opts ContactListOpts) ([]ChannelContact, error)
|
||||
|
||||
// CountContacts returns total matching contacts for the given filters.
|
||||
CountContacts(ctx context.Context, opts ContactListOpts) (int, error)
|
||||
|
||||
// MergeContacts assigns the same merged_id to all given contact IDs,
|
||||
// linking them as the same person across channels.
|
||||
MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// PGContactStore implements store.ContactStore backed by Postgres.
|
||||
type PGContactStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPGContactStore creates a new PGContactStore.
|
||||
func NewPGContactStore(db *sql.DB) *PGContactStore {
|
||||
return &PGContactStore{db: db}
|
||||
}
|
||||
|
||||
func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind)
|
||||
VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''))
|
||||
ON CONFLICT (channel_type, sender_id) DO UPDATE SET
|
||||
display_name = COALESCE(NULLIF($5,''), channel_contacts.display_name),
|
||||
username = COALESCE(NULLIF($6,''), channel_contacts.username),
|
||||
user_id = COALESCE(NULLIF($4,''), channel_contacts.user_id),
|
||||
channel_instance = COALESCE(NULLIF($2,''), channel_contacts.channel_instance),
|
||||
peer_kind = COALESCE(NULLIF($7,''), channel_contacts.peer_kind),
|
||||
last_seen_at = NOW()`,
|
||||
channelType, channelInstance, senderID, userID, displayName, username, peerKind,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func contactWhereClause(opts store.ContactListOpts) (string, []any, int) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
if opts.ChannelType != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("channel_type = $%d", argIdx))
|
||||
args = append(args, opts.ChannelType)
|
||||
argIdx++
|
||||
}
|
||||
if opts.PeerKind != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("peer_kind = $%d", argIdx))
|
||||
args = append(args, opts.PeerKind)
|
||||
argIdx++
|
||||
}
|
||||
if opts.Search != "" {
|
||||
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
|
||||
pattern := "%" + escaped + "%"
|
||||
conditions = append(conditions, fmt.Sprintf(
|
||||
"(display_name ILIKE $%d OR username ILIKE $%d OR sender_id ILIKE $%d)",
|
||||
argIdx, argIdx, argIdx,
|
||||
))
|
||||
args = append(args, pattern)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
return where, args, argIdx
|
||||
}
|
||||
|
||||
func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) {
|
||||
where, args, argIdx := contactWhereClause(opts)
|
||||
|
||||
query := `SELECT id, channel_type, channel_instance, sender_id, user_id,
|
||||
display_name, username, avatar_url, peer_kind, merged_id,
|
||||
first_seen_at, last_seen_at
|
||||
FROM channel_contacts` + where + " ORDER BY last_seen_at DESC"
|
||||
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||||
args = append(args, limit)
|
||||
argIdx++
|
||||
|
||||
if opts.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||||
args = append(args, opts.Offset)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contacts []store.ChannelContact
|
||||
for rows.Next() {
|
||||
var c store.ChannelContact
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
|
||||
&c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
|
||||
&c.FirstSeenAt, &c.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) {
|
||||
where, args, _ := contactWhereClause(opts)
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error {
|
||||
if len(contactIDs) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any of the contacts already has a merged_id; reuse it.
|
||||
placeholders := make([]string, len(contactIDs))
|
||||
args := make([]any, len(contactIDs))
|
||||
for i, id := range contactIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
|
||||
var existingMergedID *uuid.UUID
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT merged_id FROM channel_contacts WHERE id IN (%s) AND merged_id IS NOT NULL LIMIT 1", inClause),
|
||||
args...,
|
||||
).Scan(&existingMergedID)
|
||||
|
||||
mergedID := uuid.New()
|
||||
if err == nil && existingMergedID != nil {
|
||||
mergedID = *existingMergedID
|
||||
}
|
||||
|
||||
// Update all contacts with the merged_id.
|
||||
args = append(args, mergedID)
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
fmt.Sprintf("UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s)", len(args), inClause),
|
||||
args...,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -41,5 +41,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
BuiltinTools: NewPGBuiltinToolStore(db),
|
||||
PendingMessages: NewPGPendingMessageStore(db),
|
||||
KnowledgeGraph: NewPGKnowledgeGraphStore(db),
|
||||
Contacts: NewPGContactStore(db),
|
||||
}, nil
|
||||
}
|
||||
@@ -22,4 +22,5 @@ type Stores struct {
|
||||
BuiltinTools BuiltinToolStore
|
||||
PendingMessages PendingMessageStore
|
||||
KnowledgeGraph KnowledgeGraphStore
|
||||
Contacts ContactStore
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 13
|
||||
const RequiredSchemaVersion uint = 14
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channel_contacts;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Channel contacts: auto-collected user info from all channels.
|
||||
-- Global (not per-agent). Used for contact selector, future RBAC, analytics.
|
||||
CREATE TABLE IF NOT EXISTS channel_contacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_type VARCHAR(50) NOT NULL,
|
||||
channel_instance VARCHAR(255),
|
||||
sender_id VARCHAR(255) NOT NULL,
|
||||
user_id VARCHAR(255),
|
||||
display_name VARCHAR(255),
|
||||
username VARCHAR(255),
|
||||
avatar_url TEXT,
|
||||
peer_kind VARCHAR(20),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
merged_id UUID,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (channel_type, sender_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_contacts_instance ON channel_contacts(channel_instance) WHERE channel_instance IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_contacts_merged ON channel_contacts(merged_id) WHERE merged_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_contacts_search ON channel_contacts(display_name, username);
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
HardDrive,
|
||||
Inbox,
|
||||
Brain,
|
||||
Contact,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarGroup } from "./sidebar-group";
|
||||
@@ -79,6 +80,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
<SidebarItem to={ROUTES.SESSIONS} icon={History} label={t("nav.sessions")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.PENDING_MESSAGES} icon={Inbox} label={t("nav.pendingMessages")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label={t("nav.channels")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CONTACTS} icon={Contact} label={t("nav.contacts")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.SKILLS} icon={Zap} label={t("nav.skills")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CRON} icon={Clock} label={t("nav.cron")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CUSTOM_TOOLS} icon={Wrench} label={t("nav.customTools")} collapsed={collapsed} />
|
||||
|
||||
@@ -30,6 +30,7 @@ import enSetup from "./locales/en/setup.json";
|
||||
import enMemory from "./locales/en/memory.json";
|
||||
import enStorage from "./locales/en/storage.json";
|
||||
import enPendingMessages from "./locales/en/pending-messages.json";
|
||||
import enContacts from "./locales/en/contacts.json";
|
||||
|
||||
// --- VI namespaces ---
|
||||
import viCommon from "./locales/vi/common.json";
|
||||
@@ -60,6 +61,7 @@ import viSetup from "./locales/vi/setup.json";
|
||||
import viMemory from "./locales/vi/memory.json";
|
||||
import viStorage from "./locales/vi/storage.json";
|
||||
import viPendingMessages from "./locales/vi/pending-messages.json";
|
||||
import viContacts from "./locales/vi/contacts.json";
|
||||
|
||||
// --- ZH namespaces ---
|
||||
import zhCommon from "./locales/zh/common.json";
|
||||
@@ -90,6 +92,7 @@ import zhSetup from "./locales/zh/setup.json";
|
||||
import zhMemory from "./locales/zh/memory.json";
|
||||
import zhStorage from "./locales/zh/storage.json";
|
||||
import zhPendingMessages from "./locales/zh/pending-messages.json";
|
||||
import zhContacts from "./locales/zh/contacts.json";
|
||||
|
||||
const STORAGE_KEY = "goclaw:language";
|
||||
|
||||
@@ -107,7 +110,7 @@ const ns = [
|
||||
"agents", "teams", "sessions", "skills", "cron", "config",
|
||||
"channels", "providers", "traces", "events", "delegations",
|
||||
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
|
||||
"setup", "memory", "storage", "pending-messages",
|
||||
"setup", "memory", "storage", "pending-messages", "contacts",
|
||||
] as const;
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
@@ -121,6 +124,7 @@ i18n.use(initReactI18next).init({
|
||||
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
|
||||
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
|
||||
"pending-messages": enPendingMessages,
|
||||
contacts: enContacts,
|
||||
},
|
||||
vi: {
|
||||
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
|
||||
@@ -131,6 +135,7 @@ i18n.use(initReactI18next).init({
|
||||
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
|
||||
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
|
||||
"pending-messages": viPendingMessages,
|
||||
contacts: viContacts,
|
||||
},
|
||||
zh: {
|
||||
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
|
||||
@@ -141,6 +146,7 @@ i18n.use(initReactI18next).init({
|
||||
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
|
||||
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
|
||||
"pending-messages": zhPendingMessages,
|
||||
contacts: zhContacts,
|
||||
},
|
||||
},
|
||||
ns: [...ns],
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"config": "Config",
|
||||
"credentials": "Credentials",
|
||||
"groups": "Groups",
|
||||
"writers": "Writers"
|
||||
"managers": "Managers"
|
||||
},
|
||||
"general": {
|
||||
"name": "Name",
|
||||
@@ -114,37 +114,39 @@
|
||||
"saveGroups": "Save Groups",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"writers": {
|
||||
"description": "Manage which users are allowed to write/edit files in group chats.",
|
||||
"managers": {
|
||||
"description": "Manage which users have manager permissions in group chats.",
|
||||
"groups": "Groups",
|
||||
"noWriterGroups": "No writer groups",
|
||||
"noWriterGroupsHint": "Use the form below to add writers to a group.",
|
||||
"loadingWriters": "Loading writers...",
|
||||
"noWriters": "No writers in this group.",
|
||||
"writersCount": "{{count}} writer",
|
||||
"writersCountPlural": "{{count}} writers",
|
||||
"noManagerGroups": "No manager groups",
|
||||
"noManagerGroupsHint": "Use the form below to add a manager to a group.",
|
||||
"loadingManagers": "Loading managers...",
|
||||
"noManagers": "No managers in this group.",
|
||||
"managersCount": "{{count}} manager",
|
||||
"managersCountPlural": "{{count}} managers",
|
||||
"searchContacts": "Search contacts...",
|
||||
"noResults": "No contacts found",
|
||||
"columns": {
|
||||
"userId": "User ID",
|
||||
"name": "Name",
|
||||
"username": "Username"
|
||||
},
|
||||
"addForm": {
|
||||
"title": "Add Writer to New Group",
|
||||
"hint": "Add a writer to a group that doesn't appear in the list above.",
|
||||
"title": "Add Manager to New Group",
|
||||
"hint": "Add a manager to a group that doesn't appear in the list above.",
|
||||
"groupId": "Group ID *",
|
||||
"groupIdPlaceholder": "e.g. group:telegram:-100123456",
|
||||
"userId": "User ID *",
|
||||
"userIdPlaceholder": "e.g. 12345678",
|
||||
"userIdPlaceholder": "Search or enter user ID",
|
||||
"displayName": "Display Name",
|
||||
"username": "Username",
|
||||
"optional": "Optional",
|
||||
"usernameWithout": "Optional (without @)",
|
||||
"adding": "Adding...",
|
||||
"addWriter": "Add Writer",
|
||||
"addManager": "Add Manager",
|
||||
"add": "Add",
|
||||
"errors": {
|
||||
"groupUserRequired": "Group ID and User ID are required",
|
||||
"failedAdd": "Failed to add writer"
|
||||
"failedAdd": "Failed to add manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "Contacts",
|
||||
"description": "Auto-collected contacts from all channel interactions.",
|
||||
"searchPlaceholder": "Search by name, username, or ID...",
|
||||
"filter": "Filter",
|
||||
"emptyTitle": "No contacts",
|
||||
"emptyDescription": "Contacts will appear here as users interact with your agents through channels.",
|
||||
"noMatchTitle": "No matching contacts",
|
||||
"noMatchDescription": "Try a different search term or filter.",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"username": "Username",
|
||||
"senderId": "Sender ID",
|
||||
"channelType": "Channel",
|
||||
"peerKind": "Type",
|
||||
"lastSeen": "Last Seen"
|
||||
},
|
||||
"filters": {
|
||||
"allChannels": "All Channels",
|
||||
"allTypes": "All Types",
|
||||
"direct": "Direct",
|
||||
"group": "Group"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"sessions": "Sessions",
|
||||
"pendingMessages": "Pending Messages",
|
||||
"channels": "Channels",
|
||||
"contacts": "Contacts",
|
||||
"skills": "Skills",
|
||||
"cron": "Cron",
|
||||
"customTools": "Custom Tools",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"config": "Cấu hình",
|
||||
"credentials": "Thông tin xác thực",
|
||||
"groups": "Nhóm",
|
||||
"writers": "Người viết"
|
||||
"managers": "Quản lý"
|
||||
},
|
||||
"general": {
|
||||
"name": "Tên",
|
||||
@@ -114,37 +114,39 @@
|
||||
"saveGroups": "Lưu nhóm",
|
||||
"saving": "Đang lưu..."
|
||||
},
|
||||
"writers": {
|
||||
"description": "Quản lý người dùng được phép viết/chỉnh sửa tệp trong cuộc trò chuyện nhóm.",
|
||||
"managers": {
|
||||
"description": "Quản lý người dùng có quyền quản lý trong cuộc trò chuyện nhóm.",
|
||||
"groups": "Nhóm",
|
||||
"noWriterGroups": "Không có nhóm người viết",
|
||||
"noWriterGroupsHint": "Dùng biểu mẫu bên dưới để thêm người viết vào nhóm.",
|
||||
"loadingWriters": "Đang tải người viết...",
|
||||
"noWriters": "Không có người viết trong nhóm này.",
|
||||
"writersCount": "{{count}} người viết",
|
||||
"writersCountPlural": "{{count}} người viết",
|
||||
"noManagerGroups": "Không có nhóm quản lý",
|
||||
"noManagerGroupsHint": "Dùng biểu mẫu bên dưới để thêm quản lý viên vào nhóm.",
|
||||
"loadingManagers": "Đang tải quản lý viên...",
|
||||
"noManagers": "Không có quản lý viên trong nhóm này.",
|
||||
"managersCount": "{{count}} quản lý viên",
|
||||
"managersCountPlural": "{{count}} quản lý viên",
|
||||
"searchContacts": "Tìm kiếm liên hệ...",
|
||||
"noResults": "Không tìm thấy liên hệ",
|
||||
"columns": {
|
||||
"userId": "ID người dùng",
|
||||
"name": "Tên",
|
||||
"username": "Tên đăng nhập"
|
||||
},
|
||||
"addForm": {
|
||||
"title": "Thêm người viết vào nhóm mới",
|
||||
"hint": "Thêm người viết vào nhóm chưa xuất hiện trong danh sách trên.",
|
||||
"title": "Thêm quản lý viên vào nhóm mới",
|
||||
"hint": "Thêm quản lý viên vào nhóm chưa xuất hiện trong danh sách trên.",
|
||||
"groupId": "ID nhóm *",
|
||||
"groupIdPlaceholder": "vd: group:telegram:-100123456",
|
||||
"userId": "ID người dùng *",
|
||||
"userIdPlaceholder": "vd: 12345678",
|
||||
"userIdPlaceholder": "Tìm hoặc nhập ID người dùng",
|
||||
"displayName": "Tên hiển thị",
|
||||
"username": "Tên đăng nhập",
|
||||
"optional": "Tùy chọn",
|
||||
"usernameWithout": "Tùy chọn (không có @)",
|
||||
"adding": "Đang thêm...",
|
||||
"addWriter": "Thêm người viết",
|
||||
"addManager": "Thêm quản lý viên",
|
||||
"add": "Thêm",
|
||||
"errors": {
|
||||
"groupUserRequired": "ID nhóm và ID người dùng là bắt buộc",
|
||||
"failedAdd": "Không thể thêm người viết"
|
||||
"failedAdd": "Không thể thêm quản lý viên"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "Liên hệ",
|
||||
"description": "Liên hệ tự động thu thập từ tất cả tương tác qua các channel.",
|
||||
"searchPlaceholder": "Tìm theo tên, username hoặc ID...",
|
||||
"filter": "Lọc",
|
||||
"emptyTitle": "Không có liên hệ",
|
||||
"emptyDescription": "Liên hệ sẽ xuất hiện khi người dùng tương tác với agent qua các channel.",
|
||||
"noMatchTitle": "Không tìm thấy liên hệ phù hợp",
|
||||
"noMatchDescription": "Thử từ khóa tìm kiếm hoặc bộ lọc khác.",
|
||||
"columns": {
|
||||
"name": "Tên",
|
||||
"username": "Tên đăng nhập",
|
||||
"senderId": "ID gửi",
|
||||
"channelType": "Channel",
|
||||
"peerKind": "Loại",
|
||||
"lastSeen": "Lần cuối"
|
||||
},
|
||||
"filters": {
|
||||
"allChannels": "Tất cả Channel",
|
||||
"allTypes": "Tất cả loại",
|
||||
"direct": "Trực tiếp",
|
||||
"group": "Nhóm"
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"sessions": "Session",
|
||||
"pendingMessages": "Tin nhắn chờ",
|
||||
"channels": "Channel",
|
||||
"contacts": "Liên hệ",
|
||||
"skills": "Skill",
|
||||
"cron": "Tác vụ định kỳ",
|
||||
"customTools": "Công cụ tùy chỉnh",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"config": "配置",
|
||||
"credentials": "凭据",
|
||||
"groups": "群组",
|
||||
"writers": "写入权限"
|
||||
"managers": "管理员"
|
||||
},
|
||||
"general": {
|
||||
"name": "名称",
|
||||
@@ -114,37 +114,39 @@
|
||||
"saveGroups": "保存群组",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"writers": {
|
||||
"description": "管理允许在群组聊天中写入/编辑文件的用户。",
|
||||
"managers": {
|
||||
"description": "管理群组聊天中具有管理员权限的用户。",
|
||||
"groups": "群组",
|
||||
"noWriterGroups": "暂无写入权限群组",
|
||||
"noWriterGroupsHint": "使用下方表单将写入权限添加到群组。",
|
||||
"loadingWriters": "正在加载写入者...",
|
||||
"noWriters": "此群组中没有写入者。",
|
||||
"writersCount": "{{count}} 位写入者",
|
||||
"writersCountPlural": "{{count}} 位写入者",
|
||||
"noManagerGroups": "暂无管理员群组",
|
||||
"noManagerGroupsHint": "使用下方表单将管理员添加到群组。",
|
||||
"loadingManagers": "正在加载管理员...",
|
||||
"noManagers": "此群组中没有管理员。",
|
||||
"managersCount": "{{count}} 位管理员",
|
||||
"managersCountPlural": "{{count}} 位管理员",
|
||||
"searchContacts": "搜索联系人...",
|
||||
"noResults": "未找到联系人",
|
||||
"columns": {
|
||||
"userId": "用户 ID",
|
||||
"name": "名称",
|
||||
"username": "用户名"
|
||||
},
|
||||
"addForm": {
|
||||
"title": "将写入者添加到新群组",
|
||||
"hint": "将写入者添加到上方列表中未出现的群组。",
|
||||
"title": "将管理员添加到新群组",
|
||||
"hint": "将管理员添加到上方列表中未出现的群组。",
|
||||
"groupId": "群组 ID *",
|
||||
"groupIdPlaceholder": "例如 group:telegram:-100123456",
|
||||
"userId": "用户 ID *",
|
||||
"userIdPlaceholder": "例如 12345678",
|
||||
"userIdPlaceholder": "搜索或输入用户 ID",
|
||||
"displayName": "显示名称",
|
||||
"username": "用户名",
|
||||
"optional": "可选",
|
||||
"usernameWithout": "可选(不含 @)",
|
||||
"adding": "添加中...",
|
||||
"addWriter": "添加写入者",
|
||||
"addManager": "添加管理员",
|
||||
"add": "添加",
|
||||
"errors": {
|
||||
"groupUserRequired": "群组 ID 和用户 ID 为必填项",
|
||||
"failedAdd": "添加写入者失败"
|
||||
"failedAdd": "添加管理员失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "联系人",
|
||||
"description": "从所有Channel交互中自动收集的联系人。",
|
||||
"searchPlaceholder": "按名称、用户名或 ID 搜索...",
|
||||
"filter": "筛选",
|
||||
"emptyTitle": "暂无联系人",
|
||||
"emptyDescription": "当用户通过Channel与Agent交互时,联系人将自动出现在此处。",
|
||||
"noMatchTitle": "未找到匹配的联系人",
|
||||
"noMatchDescription": "请尝试其他搜索词或筛选条件。",
|
||||
"columns": {
|
||||
"name": "名称",
|
||||
"username": "用户名",
|
||||
"senderId": "发送者 ID",
|
||||
"channelType": "Channel",
|
||||
"peerKind": "类型",
|
||||
"lastSeen": "最后活跃"
|
||||
},
|
||||
"filters": {
|
||||
"allChannels": "所有Channel",
|
||||
"allTypes": "所有类型",
|
||||
"direct": "私聊",
|
||||
"group": "群组"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"approvals": "审批",
|
||||
"builtinTools": "内置工具",
|
||||
"channels": "Channel",
|
||||
"contacts": "联系人",
|
||||
"chat": "对话",
|
||||
"config": "配置",
|
||||
"cron": "定时任务",
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ROUTES = {
|
||||
USAGE: "/usage",
|
||||
CHANNELS: "/channels",
|
||||
CHANNEL_DETAIL: "/channels/:id",
|
||||
CONTACTS: "/contacts",
|
||||
APPROVALS: "/approvals",
|
||||
NODES: "/nodes",
|
||||
LOGS: "/logs",
|
||||
|
||||
@@ -30,6 +30,10 @@ export const queryKeys = {
|
||||
list: (params: Record<string, unknown>) => ["channels", params] as const,
|
||||
detail: (id: string) => ["channels", "detail", id] as const,
|
||||
},
|
||||
contacts: {
|
||||
all: ["contacts"] as const,
|
||||
list: (params: Record<string, unknown>) => ["contacts", params] as const,
|
||||
},
|
||||
skills: {
|
||||
all: ["skills"] as const,
|
||||
agentGrants: (agentId: string) => ["skills", "agent", agentId] as const,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ChannelGeneralTab } from "./channel-general-tab";
|
||||
import { ChannelCredentialsTab } from "./channel-credentials-tab";
|
||||
import { ChannelConfigTab } from "./channel-config-tab";
|
||||
import { ChannelGroupsTab } from "./channel-groups-tab";
|
||||
import { ChannelWritersTab } from "./channel-writers-tab";
|
||||
import { ChannelManagersTab } from "./channel-managers-tab";
|
||||
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { channelTypeLabels } from "../channels-status-view";
|
||||
import { useChannels } from "../hooks/use-channels";
|
||||
@@ -26,10 +26,11 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
|
||||
instance,
|
||||
loading,
|
||||
updateInstance,
|
||||
listWriterGroups,
|
||||
listWriters,
|
||||
addWriter,
|
||||
removeWriter,
|
||||
listManagerGroups,
|
||||
listManagers,
|
||||
addManager,
|
||||
removeManager,
|
||||
listContacts,
|
||||
} = useChannelDetail(instanceId);
|
||||
const { agents } = useAgents();
|
||||
const { channels } = useChannels();
|
||||
@@ -95,7 +96,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
|
||||
<TabsTrigger value="credentials">{t("detail.tabs.credentials")}</TabsTrigger>
|
||||
<TabsTrigger value="config">{t("detail.tabs.config")}</TabsTrigger>
|
||||
{isTelegram && <TabsTrigger value="groups">{t("detail.tabs.groups")}</TabsTrigger>}
|
||||
<TabsTrigger value="writers">{t("detail.tabs.writers")}</TabsTrigger>
|
||||
<TabsTrigger value="managers">{t("detail.tabs.managers")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-4">
|
||||
@@ -129,12 +130,13 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="writers" className="mt-4">
|
||||
<ChannelWritersTab
|
||||
listWriterGroups={listWriterGroups}
|
||||
listWriters={listWriters}
|
||||
addWriter={addWriter}
|
||||
removeWriter={removeWriter}
|
||||
<TabsContent value="managers" className="mt-4">
|
||||
<ChannelManagersTab
|
||||
listManagerGroups={listManagerGroups}
|
||||
listManagers={listManagers}
|
||||
addManager={addManager}
|
||||
removeManager={removeManager}
|
||||
listContacts={listContacts}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from "../hooks/use-channel-detail";
|
||||
|
||||
interface ChannelManagersTabProps {
|
||||
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>;
|
||||
listManagers: (groupId: string) => Promise<GroupManagerData[]>;
|
||||
addManager: (groupId: string, userId: string, displayName?: string, username?: string) => Promise<void>;
|
||||
removeManager: (groupId: string, userId: string) => Promise<void>;
|
||||
listContacts: (search: string, channelType?: string) => Promise<ChannelContact[]>;
|
||||
}
|
||||
|
||||
/** Strips the "group:<channel>:" prefix for display, e.g. "group:telegram:-100123" → "-100123" */
|
||||
function shortGroupId(id: string): string {
|
||||
const m = id.match(/^group:[^:]+:(.+)$/);
|
||||
return m?.[1] ?? id;
|
||||
}
|
||||
|
||||
function contactToOption(c: ChannelContact): ComboboxOption {
|
||||
const parts: string[] = [];
|
||||
if (c.display_name) parts.push(c.display_name);
|
||||
if (c.username) parts.push(`@${c.username}`);
|
||||
parts.push(`(${c.sender_id})`);
|
||||
return { value: c.sender_id, label: parts.join(" — ") };
|
||||
}
|
||||
|
||||
// --- Inline add form (scoped state per instance) ---
|
||||
|
||||
interface InlineAddFormProps {
|
||||
groupId?: string;
|
||||
showGroupField?: boolean;
|
||||
listContacts: (search: string) => Promise<ChannelContact[]>;
|
||||
onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineAddFormProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const [formGroupId, setFormGroupId] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [contactOptions, setContactOptions] = useState<ComboboxOption[]>([]);
|
||||
const contactCacheRef = useRef<Record<string, ChannelContact>>({});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const searchContacts = useCallback(
|
||||
(query: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!query || query.length < 2) {
|
||||
setContactOptions([]);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const contacts = await listContacts(query);
|
||||
const cache: Record<string, ChannelContact> = {};
|
||||
const opts = contacts.map((c) => {
|
||||
cache[c.sender_id] = c;
|
||||
return contactToOption(c);
|
||||
});
|
||||
contactCacheRef.current = { ...contactCacheRef.current, ...cache };
|
||||
setContactOptions(opts);
|
||||
} catch {
|
||||
setContactOptions([]);
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[listContacts],
|
||||
);
|
||||
|
||||
const handleUserIdChange = (val: string) => {
|
||||
setUserId(val);
|
||||
const contact = contactCacheRef.current[val];
|
||||
if (contact) {
|
||||
setDisplayName(contact.display_name ?? "");
|
||||
setUsername(contact.username ?? "");
|
||||
}
|
||||
searchContacts(val);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const gid = groupId || formGroupId.trim();
|
||||
const uid = userId.trim();
|
||||
if (!gid || !uid) {
|
||||
setError(t("detail.managers.addForm.errors.groupUserRequired"));
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError("");
|
||||
try {
|
||||
await onAdd(gid, uid, displayName.trim(), username.trim());
|
||||
setUserId("");
|
||||
setDisplayName("");
|
||||
setUsername("");
|
||||
setContactOptions([]);
|
||||
if (!groupId) setFormGroupId("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd"));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showGroupField) {
|
||||
return (
|
||||
<fieldset className="rounded-md border p-4 space-y-3">
|
||||
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
|
||||
<Input
|
||||
value={formGroupId}
|
||||
onChange={(e) => setFormGroupId(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.groupIdPlaceholder")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.userId")}</Label>
|
||||
<Combobox
|
||||
value={userId}
|
||||
onChange={handleUserIdChange}
|
||||
options={contactOptions}
|
||||
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.displayName")}</Label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.optional")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("detail.managers.addForm.username")}</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.usernameWithout")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={adding || !formGroupId.trim() || !userId.trim()}
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{adding ? t("detail.managers.addForm.adding") : t("detail.managers.addForm.addManager")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.userId")}</Label>
|
||||
<Combobox
|
||||
value={userId}
|
||||
onChange={handleUserIdChange}
|
||||
options={contactOptions}
|
||||
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.displayName")}</Label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.optional")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.username")}</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("detail.managers.addForm.optional")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1 shrink-0"
|
||||
onClick={handleSubmit}
|
||||
disabled={adding || !userId.trim()}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("detail.managers.addForm.add")}
|
||||
</Button>
|
||||
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main tab component ---
|
||||
|
||||
export function ChannelManagersTab({
|
||||
listManagerGroups,
|
||||
listManagers,
|
||||
addManager,
|
||||
removeManager,
|
||||
listContacts,
|
||||
}: ChannelManagersTabProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const [groups, setGroups] = useState<GroupManagerGroupInfo[]>([]);
|
||||
const [loadingGroups, setLoadingGroups] = useState(true);
|
||||
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [managersMap, setManagersMap] = useState<Record<string, GroupManagerData[]>>({});
|
||||
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const refreshGroups = useCallback(async () => {
|
||||
setLoadingGroups(true);
|
||||
try {
|
||||
const g = await listManagerGroups();
|
||||
setGroups(g);
|
||||
} catch {
|
||||
// handled by http hook
|
||||
} finally {
|
||||
setLoadingGroups(false);
|
||||
}
|
||||
}, [listManagerGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGroups();
|
||||
}, [refreshGroups]);
|
||||
|
||||
const loadManagersForGroup = useCallback(
|
||||
async (groupId: string) => {
|
||||
setLoadingMap((prev) => ({ ...prev, [groupId]: true }));
|
||||
try {
|
||||
const w = await listManagers(groupId);
|
||||
setManagersMap((prev) => ({ ...prev, [groupId]: w }));
|
||||
} catch {
|
||||
setManagersMap((prev) => ({ ...prev, [groupId]: [] }));
|
||||
} finally {
|
||||
setLoadingMap((prev) => ({ ...prev, [groupId]: false }));
|
||||
}
|
||||
},
|
||||
[listManagers],
|
||||
);
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const isExpanding = !expanded[groupId];
|
||||
setExpanded((prev) => ({ ...prev, [groupId]: isExpanding }));
|
||||
if (isExpanding && !managersMap[groupId]) {
|
||||
loadManagersForGroup(groupId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveManager = async (groupId: string, userId: string) => {
|
||||
try {
|
||||
await removeManager(groupId, userId);
|
||||
setManagersMap((prev) => ({
|
||||
...prev,
|
||||
[groupId]: (prev[groupId] ?? []).filter((w) => w.user_id !== userId),
|
||||
}));
|
||||
await refreshGroups();
|
||||
} catch {
|
||||
// handled by http hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddManager = async (gid: string, uid: string, dn: string, un: string) => {
|
||||
await addManager(gid, uid, dn, un);
|
||||
if (expanded[gid]) {
|
||||
await loadManagersForGroup(gid);
|
||||
}
|
||||
await refreshGroups();
|
||||
if (!expanded[gid]) {
|
||||
setExpanded((prev) => ({ ...prev, [gid]: true }));
|
||||
loadManagersForGroup(gid);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.managers.description")}
|
||||
</p>
|
||||
|
||||
{/* Groups accordion */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("detail.managers.groups")}
|
||||
{groups.length > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">({groups.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshGroups} disabled={loadingGroups}>
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (loadingGroups ? " animate-spin" : "")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && !loadingGroups ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("detail.managers.noManagerGroups")}
|
||||
description={t("detail.managers.noManagerGroupsHint")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border divide-y">
|
||||
{groups.map((g) => {
|
||||
const isOpen = expanded[g.group_id];
|
||||
const groupManagers = managersMap[g.group_id] ?? [];
|
||||
const isLoading = loadingMap[g.group_id];
|
||||
|
||||
return (
|
||||
<div key={g.group_id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
onClick={() => toggleGroup(g.group_id)}
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-mono text-sm">{shortGroupId(g.group_id)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{g.group_id}</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums">
|
||||
{g.writer_count === 1
|
||||
? t("detail.managers.managersCount", { count: g.writer_count })
|
||||
: t("detail.managers.managersCountPlural", { count: g.writer_count })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t bg-muted/10 px-4 py-3 space-y-3">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-2">{t("detail.managers.loadingManagers")}</p>
|
||||
) : groupManagers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">{t("detail.managers.noManagers")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.userId")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.name")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.username")}</th>
|
||||
<th className="px-3 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupManagers.map((w) => (
|
||||
<tr key={w.user_id} className="border-b last:border-0 hover:bg-muted/20">
|
||||
<td className="px-3 py-2 font-mono text-xs">{w.user_id}</td>
|
||||
<td className="px-3 py-2">{w.display_name || <span className="text-muted-foreground">-</span>}</td>
|
||||
<td className="px-3 py-2">{w.username ? <span className="text-muted-foreground">@{w.username}</span> : <span className="text-muted-foreground">-</span>}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveManager(g.group_id, w.user_id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InlineAddForm
|
||||
groupId={g.group_id}
|
||||
listContacts={listContacts}
|
||||
onAdd={handleAddManager}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to new group */}
|
||||
<InlineAddForm
|
||||
showGroupField
|
||||
listContacts={listContacts}
|
||||
onAdd={handleAddManager}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import type { GroupWriterGroupInfo, GroupFileWriterData } from "../hooks/use-channel-detail";
|
||||
|
||||
interface ChannelWritersTabProps {
|
||||
listWriterGroups: () => Promise<GroupWriterGroupInfo[]>;
|
||||
listWriters: (groupId: string) => Promise<GroupFileWriterData[]>;
|
||||
addWriter: (groupId: string, userId: string, displayName?: string, username?: string) => Promise<void>;
|
||||
removeWriter: (groupId: string, userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Strips the "group:<channel>:" prefix for display, e.g. "group:telegram:-100123" → "-100123" */
|
||||
function shortGroupId(id: string): string {
|
||||
const m = id.match(/^group:[^:]+:(.+)$/);
|
||||
return m?.[1] ?? id;
|
||||
}
|
||||
|
||||
export function ChannelWritersTab({
|
||||
listWriterGroups,
|
||||
listWriters,
|
||||
addWriter,
|
||||
removeWriter,
|
||||
}: ChannelWritersTabProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const [groups, setGroups] = useState<GroupWriterGroupInfo[]>([]);
|
||||
const [loadingGroups, setLoadingGroups] = useState(true);
|
||||
|
||||
// Per-group expanded state & cached writers
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [writersMap, setWritersMap] = useState<Record<string, GroupFileWriterData[]>>({});
|
||||
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Add writer form
|
||||
const [addGroupId, setAddGroupId] = useState("");
|
||||
const [addUserId, setAddUserId] = useState("");
|
||||
const [addDisplayName, setAddDisplayName] = useState("");
|
||||
const [addUsername, setAddUsername] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refreshGroups = useCallback(async () => {
|
||||
setLoadingGroups(true);
|
||||
try {
|
||||
const g = await listWriterGroups();
|
||||
setGroups(g);
|
||||
} catch {
|
||||
// handled by http hook
|
||||
} finally {
|
||||
setLoadingGroups(false);
|
||||
}
|
||||
}, [listWriterGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGroups();
|
||||
}, [refreshGroups]);
|
||||
|
||||
const loadWritersForGroup = useCallback(
|
||||
async (groupId: string) => {
|
||||
setLoadingMap((prev) => ({ ...prev, [groupId]: true }));
|
||||
try {
|
||||
const w = await listWriters(groupId);
|
||||
setWritersMap((prev) => ({ ...prev, [groupId]: w }));
|
||||
} catch {
|
||||
setWritersMap((prev) => ({ ...prev, [groupId]: [] }));
|
||||
} finally {
|
||||
setLoadingMap((prev) => ({ ...prev, [groupId]: false }));
|
||||
}
|
||||
},
|
||||
[listWriters],
|
||||
);
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const isExpanding = !expanded[groupId];
|
||||
setExpanded((prev) => ({ ...prev, [groupId]: isExpanding }));
|
||||
if (isExpanding && !writersMap[groupId]) {
|
||||
loadWritersForGroup(groupId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveWriter = async (groupId: string, userId: string) => {
|
||||
try {
|
||||
await removeWriter(groupId, userId);
|
||||
setWritersMap((prev) => ({
|
||||
...prev,
|
||||
[groupId]: (prev[groupId] ?? []).filter((w) => w.user_id !== userId),
|
||||
}));
|
||||
await refreshGroups();
|
||||
} catch {
|
||||
// handled by http hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddWriter = async (targetGroupId?: string) => {
|
||||
const gid = targetGroupId || addGroupId.trim();
|
||||
const uid = addUserId.trim();
|
||||
if (!gid || !uid) {
|
||||
setError(t("detail.writers.addForm.errors.groupUserRequired"));
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError("");
|
||||
try {
|
||||
await addWriter(gid, uid, addDisplayName.trim(), addUsername.trim());
|
||||
setAddUserId("");
|
||||
setAddDisplayName("");
|
||||
setAddUsername("");
|
||||
if (!targetGroupId) setAddGroupId("");
|
||||
// Reload writers for this group if it's expanded
|
||||
if (expanded[gid] || targetGroupId) {
|
||||
await loadWritersForGroup(gid);
|
||||
}
|
||||
await refreshGroups();
|
||||
// Auto-expand the group we just added to
|
||||
if (!expanded[gid]) {
|
||||
setExpanded((prev) => ({ ...prev, [gid]: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("detail.writers.addForm.errors.failedAdd"));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.writers.description")}
|
||||
</p>
|
||||
|
||||
{/* Groups accordion */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("detail.writers.groups")}
|
||||
{groups.length > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">({groups.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshGroups} disabled={loadingGroups}>
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (loadingGroups ? " animate-spin" : "")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && !loadingGroups ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("detail.writers.noWriterGroups")}
|
||||
description={t("detail.writers.noWriterGroupsHint")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border divide-y">
|
||||
{groups.map((g) => {
|
||||
const isOpen = expanded[g.group_id];
|
||||
const groupWriters = writersMap[g.group_id] ?? [];
|
||||
const isLoading = loadingMap[g.group_id];
|
||||
|
||||
return (
|
||||
<div key={g.group_id}>
|
||||
{/* Group header row */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
onClick={() => toggleGroup(g.group_id)}
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-mono text-sm">{shortGroupId(g.group_id)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{g.group_id}</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums">
|
||||
{g.writer_count === 1
|
||||
? t("detail.writers.writersCount", { count: g.writer_count })
|
||||
: t("detail.writers.writersCountPlural", { count: g.writer_count })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded: writers list */}
|
||||
{isOpen && (
|
||||
<div className="border-t bg-muted/10 px-4 py-3 space-y-3">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-2">{t("detail.writers.loadingWriters")}</p>
|
||||
) : groupWriters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">{t("detail.writers.noWriters")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.writers.columns.userId")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.writers.columns.name")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.writers.columns.username")}</th>
|
||||
<th className="px-3 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupWriters.map((w) => (
|
||||
<tr key={w.user_id} className="border-b last:border-0 hover:bg-muted/20">
|
||||
<td className="px-3 py-2 font-mono text-xs">{w.user_id}</td>
|
||||
<td className="px-3 py-2">{w.display_name || <span className="text-muted-foreground">-</span>}</td>
|
||||
<td className="px-3 py-2">{w.username ? <span className="text-muted-foreground">@{w.username}</span> : <span className="text-muted-foreground">-</span>}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveWriter(g.group_id, w.user_id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add form for this group */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.writers.addForm.userId")}</Label>
|
||||
<Input
|
||||
value={addUserId}
|
||||
onChange={(e) => setAddUserId(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.userIdPlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddWriter(g.group_id))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.writers.addForm.displayName")}</Label>
|
||||
<Input
|
||||
value={addDisplayName}
|
||||
onChange={(e) => setAddDisplayName(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.optional")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1 flex-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("detail.writers.addForm.username")}</Label>
|
||||
<Input
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.optional")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1 shrink-0"
|
||||
onClick={() => handleAddWriter(g.group_id)}
|
||||
disabled={adding || !addUserId.trim()}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("detail.writers.addForm.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to new group */}
|
||||
<fieldset className="rounded-md border p-4 space-y-3">
|
||||
<legend className="px-1 text-sm font-medium">{t("detail.writers.addForm.title")}</legend>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.writers.addForm.hint")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aw-group" className="text-xs">{t("detail.writers.addForm.groupId")}</Label>
|
||||
<Input
|
||||
id="aw-group"
|
||||
value={addGroupId}
|
||||
onChange={(e) => setAddGroupId(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.groupIdPlaceholder")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aw-user" className="text-xs">{t("detail.writers.addForm.userId")}</Label>
|
||||
<Input
|
||||
id="aw-user"
|
||||
value={addUserId}
|
||||
onChange={(e) => setAddUserId(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.userIdPlaceholder")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aw-display" className="text-xs">{t("detail.writers.addForm.displayName")}</Label>
|
||||
<Input
|
||||
id="aw-display"
|
||||
value={addDisplayName}
|
||||
onChange={(e) => setAddDisplayName(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.optional")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aw-username" className="text-xs">{t("detail.writers.addForm.username")}</Label>
|
||||
<Input
|
||||
id="aw-username"
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
placeholder={t("detail.writers.addForm.usernameWithout")}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => handleAddWriter()}
|
||||
disabled={adding || !addGroupId.trim() || !addUserId.trim()}
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{adding ? t("detail.writers.addForm.adding") : t("detail.writers.addForm.addWriter")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ChannelInstanceData } from "@/types/channel";
|
||||
import type { ChannelContact } from "@/types/contact";
|
||||
|
||||
export interface GroupWriterGroupInfo {
|
||||
export type { ChannelContact };
|
||||
|
||||
export interface GroupManagerGroupInfo {
|
||||
group_id: string;
|
||||
writer_count: number;
|
||||
}
|
||||
|
||||
export interface GroupFileWriterData {
|
||||
export interface GroupManagerData {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
username?: string;
|
||||
@@ -43,26 +46,26 @@ export function useChannelDetail(instanceId: string | undefined) {
|
||||
[instanceId, http, invalidate],
|
||||
);
|
||||
|
||||
// Writers API
|
||||
const listWriterGroups = useCallback(
|
||||
async (): Promise<GroupWriterGroupInfo[]> => {
|
||||
// Managers API (backend routes still use /writers paths)
|
||||
const listManagerGroups = useCallback(
|
||||
async (): Promise<GroupManagerGroupInfo[]> => {
|
||||
if (!instanceId) return [];
|
||||
const res = await http.get<{ groups: GroupWriterGroupInfo[] }>(`/v1/channels/instances/${instanceId}/writers/groups`);
|
||||
const res = await http.get<{ groups: GroupManagerGroupInfo[] }>(`/v1/channels/instances/${instanceId}/writers/groups`);
|
||||
return res.groups ?? [];
|
||||
},
|
||||
[instanceId, http],
|
||||
);
|
||||
|
||||
const listWriters = useCallback(
|
||||
async (groupId: string): Promise<GroupFileWriterData[]> => {
|
||||
const listManagers = useCallback(
|
||||
async (groupId: string): Promise<GroupManagerData[]> => {
|
||||
if (!instanceId) return [];
|
||||
const res = await http.get<{ writers: GroupFileWriterData[] }>(`/v1/channels/instances/${instanceId}/writers`, { group_id: groupId });
|
||||
const res = await http.get<{ writers: GroupManagerData[] }>(`/v1/channels/instances/${instanceId}/writers`, { group_id: groupId });
|
||||
return res.writers ?? [];
|
||||
},
|
||||
[instanceId, http],
|
||||
);
|
||||
|
||||
const addWriter = useCallback(
|
||||
const addManager = useCallback(
|
||||
async (groupId: string, userId: string, displayName?: string, username?: string) => {
|
||||
if (!instanceId) return;
|
||||
await http.post(`/v1/channels/instances/${instanceId}/writers`, {
|
||||
@@ -75,7 +78,7 @@ export function useChannelDetail(instanceId: string | undefined) {
|
||||
[instanceId, http],
|
||||
);
|
||||
|
||||
const removeWriter = useCallback(
|
||||
const removeManager = useCallback(
|
||||
async (groupId: string, userId: string) => {
|
||||
if (!instanceId) return;
|
||||
await http.delete(`/v1/channels/instances/${instanceId}/writers/${userId}?group_id=${encodeURIComponent(groupId)}`);
|
||||
@@ -83,14 +86,27 @@ export function useChannelDetail(instanceId: string | undefined) {
|
||||
[instanceId, http],
|
||||
);
|
||||
|
||||
const listContacts = useCallback(
|
||||
async (search: string, channelType?: string): Promise<ChannelContact[]> => {
|
||||
const params: Record<string, string> = {};
|
||||
if (search) params.search = search;
|
||||
if (channelType) params.channel_type = channelType;
|
||||
params.limit = "20";
|
||||
const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", params);
|
||||
return res.contacts ?? [];
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
return {
|
||||
instance,
|
||||
loading,
|
||||
updateInstance,
|
||||
listWriterGroups,
|
||||
listWriters,
|
||||
addWriter,
|
||||
removeWriter,
|
||||
listManagerGroups,
|
||||
listManagers,
|
||||
addManager,
|
||||
removeManager,
|
||||
listContacts,
|
||||
refresh: invalidate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Contact, RefreshCw, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { useContacts } from "./hooks/use-contacts";
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu"];
|
||||
|
||||
export function ContactsPage() {
|
||||
const { t } = useTranslation("contacts");
|
||||
const { t: tc } = useTranslation("common");
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [appliedSearch, setAppliedSearch] = useState("");
|
||||
const [channelType, setChannelType] = useState("");
|
||||
const [peerKind, setPeerKind] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const { contacts, total, loading, fetching, refresh } = useContacts({
|
||||
search: appliedSearch || undefined,
|
||||
channelType: channelType || undefined,
|
||||
peerKind: peerKind || undefined,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
const spinning = useMinLoading(fetching);
|
||||
const showSkeleton = useDeferredLoading(loading && contacts.length === 0);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAppliedSearch(search);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleChannelChange = (val: string) => {
|
||||
setChannelType(val === "all" ? "" : val);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePeerKindChange = (val: string) => {
|
||||
setPeerKind(val === "all" ? "" : val);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {tc("refresh")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 min-w-[200px] max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
{t("filter")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Select value={channelType || "all"} onValueChange={handleChannelChange}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allChannels")}</SelectItem>
|
||||
{CHANNEL_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>{ct}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={peerKind || "all"} onValueChange={handlePeerKindChange}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
|
||||
<SelectItem value="direct">{t("filters.direct")}</SelectItem>
|
||||
<SelectItem value="group">{t("filters.group")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="mt-4">
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={8} />
|
||||
) : contacts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Contact}
|
||||
title={appliedSearch || channelType || peerKind ? t("noMatchTitle") : t("emptyTitle")}
|
||||
description={appliedSearch || channelType || peerKind ? t("noMatchDescription") : t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.name")}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.username")}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.senderId")}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.channelType")}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.peerKind")}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.lastSeen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contacts.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0 hover:bg-muted/20 transition-colors">
|
||||
<td className="px-3 py-2.5">
|
||||
{c.display_name || <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{c.username
|
||||
? <span className="text-muted-foreground">@{c.username}</span>
|
||||
: <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs">{c.sender_id}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className="text-[11px]">{c.channel_type}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{c.peer_kind && (
|
||||
<Badge variant={c.peer_kind === "direct" ? "default" : "secondary"} className="text-[11px]">
|
||||
{c.peer_kind === "direct" ? t("filters.direct") : t("filters.group")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground text-xs">
|
||||
{formatDate(c.last_seen_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ChannelContact } from "@/types/contact";
|
||||
|
||||
export type { ChannelContact };
|
||||
|
||||
export interface ContactFilters {
|
||||
search?: string;
|
||||
channelType?: string;
|
||||
peerKind?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export function useContacts(filters: ContactFilters = {}) {
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryKey = queryKeys.contacts.list({ ...filters });
|
||||
|
||||
const { data, isLoading: loading, isFetching } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const params: Record<string, string> = {};
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.channelType) params.channel_type = filters.channelType;
|
||||
if (filters.peerKind) params.peer_kind = filters.peerKind;
|
||||
if (filters.limit) params.limit = String(filters.limit);
|
||||
if (filters.offset !== undefined) params.offset = String(filters.offset);
|
||||
|
||||
const res = await http.get<{ contacts: ChannelContact[]; total?: number }>("/v1/contacts", params);
|
||||
return { contacts: res.contacts ?? [], total: res.total ?? 0 };
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const contacts = data?.contacts ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const refresh = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all }),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return { contacts, total, loading, fetching: isFetching, refresh };
|
||||
}
|
||||
@@ -84,6 +84,9 @@ const PendingMessagesPage = lazy(() =>
|
||||
const MemoryPage = lazy(() =>
|
||||
import("@/pages/memory/memory-page").then((m) => ({ default: m.MemoryPage })),
|
||||
);
|
||||
const ContactsPage = lazy(() =>
|
||||
import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })),
|
||||
);
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -141,6 +144,7 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.USAGE} element={<UsagePage />} />
|
||||
<Route path={ROUTES.CHANNELS} element={<ChannelsPage key="list" />} />
|
||||
<Route path={ROUTES.CHANNEL_DETAIL} element={<ChannelsPage key="detail" />} />
|
||||
<Route path={ROUTES.CONTACTS} element={<ContactsPage />} />
|
||||
<Route path={ROUTES.APPROVALS} element={<ApprovalsPage />} />
|
||||
<Route path={ROUTES.NODES} element={<NodesPage />} />
|
||||
<Route path={ROUTES.LOGS} element={<LogsPage />} />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface ChannelContact {
|
||||
id: string;
|
||||
channel_type: string;
|
||||
channel_instance?: string;
|
||||
sender_id: string;
|
||||
user_id?: string;
|
||||
display_name?: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
peer_kind?: string;
|
||||
merged_id?: string;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
}
|
||||
Reference in new issue
Block a user