mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
fix(desktop): fix pairing time scan, i18n keys, convert advanced to tab
SQLite pairing: - Fix rows.Scan failing on Go time.Time strings with monotonic suffix - Scan timestamps as strings, parse with parseTimeToMillis() that strips m=+ - Use time.Now().Round(0) for all SQLite time comparisons - Wire SetOnRequest for SQLite store via interface assertion (not PG-only) Desktop UI: - Fix ChannelManagersTab i18n keys: managers.xxx → detail.managers.xxx - Fix ChannelAdvancedDialog i18n keys and save logic (wrap in config) - Convert Advanced from modal dialog to inline tab (4th tab) - Add "advanced" tab label to all 3 locale files
This commit is contained in:
1 parent
2ba883aff7
commit
0b3be862c9
9 files changed
+239
-232
No files matched your search
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -40,7 +41,7 @@ func (s *SQLitePairingStore) SetOnRequest(cb func(code, senderID, channel, chatI
|
||||
|
||||
func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
now := time.Now()
|
||||
now := time.Now().Round(0) // Strip monotonic clock for correct SQLite string comparison
|
||||
|
||||
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
|
||||
|
||||
@@ -65,7 +66,7 @@ func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, chann
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid,
|
||||
uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL).Round(0), now, metaJSON, tid,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create pairing request: %w", err)
|
||||
@@ -77,7 +78,7 @@ func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, chann
|
||||
}
|
||||
|
||||
func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
|
||||
now := time.Now()
|
||||
now := time.Now().Round(0)
|
||||
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
|
||||
|
||||
var reqID uuid.UUID
|
||||
@@ -149,7 +150,7 @@ func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel str
|
||||
var count int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
senderID, channel, tid, time.Now(),
|
||||
senderID, channel, tid, time.Now().Round(0),
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pairing check query: %w", err)
|
||||
@@ -159,7 +160,7 @@ func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel str
|
||||
|
||||
func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
now := time.Now()
|
||||
now := time.Now().Round(0) // Strip monotonic clock for correct SQLite string comparison
|
||||
|
||||
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
|
||||
|
||||
@@ -174,13 +175,14 @@ func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingReq
|
||||
var result []store.PairingRequestData
|
||||
for rows.Next() {
|
||||
var d store.PairingRequestData
|
||||
var createdAt, expiresAt time.Time
|
||||
var createdAtStr, expiresAtStr string
|
||||
var metaJSON []byte
|
||||
if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAt, &expiresAt, &metaJSON); err != nil {
|
||||
if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAtStr, &expiresAtStr, &metaJSON); err != nil {
|
||||
slog.Warn("pairing: scan error", "error", err)
|
||||
continue
|
||||
}
|
||||
d.CreatedAt = createdAt.UnixMilli()
|
||||
d.ExpiresAt = expiresAt.UnixMilli()
|
||||
d.CreatedAt = parseTimeToMillis(createdAtStr)
|
||||
d.ExpiresAt = parseTimeToMillis(expiresAtStr)
|
||||
if len(metaJSON) > 0 {
|
||||
json.Unmarshal(metaJSON, &d.Metadata)
|
||||
}
|
||||
@@ -197,7 +199,7 @@ func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingReq
|
||||
|
||||
func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
now := time.Now()
|
||||
now := time.Now().Round(0)
|
||||
|
||||
s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now)
|
||||
|
||||
@@ -212,12 +214,13 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic
|
||||
var result []store.PairedDeviceData
|
||||
for rows.Next() {
|
||||
var d store.PairedDeviceData
|
||||
var pairedAt time.Time
|
||||
var pairedAtStr string
|
||||
var metaJSON []byte
|
||||
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAt, &metaJSON); err != nil {
|
||||
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAtStr, &metaJSON); err != nil {
|
||||
slog.Warn("pairing: scan paired error", "error", err)
|
||||
continue
|
||||
}
|
||||
d.PairedAt = pairedAt.UnixMilli()
|
||||
d.PairedAt = parseTimeToMillis(pairedAtStr)
|
||||
if len(metaJSON) > 0 {
|
||||
json.Unmarshal(metaJSON, &d.Metadata)
|
||||
}
|
||||
@@ -232,6 +235,30 @@ func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDevic
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTimeToMillis parses a Go time.Time string (possibly with monotonic clock suffix)
|
||||
// from SQLite and returns Unix milliseconds. Falls back to 0 on parse failure.
|
||||
func parseTimeToMillis(s string) int64 {
|
||||
// Strip monotonic clock suffix "m=+xxx" if present
|
||||
if idx := strings.Index(s, " m="); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
// Try standard Go formats
|
||||
for _, layout := range []string{
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST",
|
||||
"2006-01-02 15:04:05.999999999 -0700 -07",
|
||||
"2006-01-02 15:04:05.999999 -0700 -07",
|
||||
"2006-01-02T15:04:05.999999999-07:00",
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
slog.Warn("pairing: failed to parse time", "value", s)
|
||||
return 0
|
||||
}
|
||||
|
||||
func generatePairingCode() string {
|
||||
b := make([]byte, codeLength)
|
||||
rand.Read(b)
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChannelFields } from './channel-field-renderer'
|
||||
import { configSchema, ESSENTIAL_CONFIG_KEYS, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
|
||||
import type { ChannelInstanceData } from '../../../types/channel'
|
||||
|
||||
interface ChannelAdvancedDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
instance: ChannelInstanceData
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
|
||||
const ESSENTIAL_CONFIG_KEYS_SET = new Set(['dm_policy', 'group_policy', 'require_mention', 'mention_mode'])
|
||||
|
||||
function getAdvancedFields(channelType: string) {
|
||||
const allFields = configSchema[channelType] ?? []
|
||||
const advanced = allFields.filter((f) => !ESSENTIAL_CONFIG_KEYS_SET.has(f.key))
|
||||
return {
|
||||
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
|
||||
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
|
||||
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
|
||||
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
|
||||
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
|
||||
const config = (instance.config ?? {}) as Record<string, unknown>
|
||||
const { groups: _groups, ...rest } = config
|
||||
return Object.fromEntries(
|
||||
Object.entries(rest).filter(([k]) => !ESSENTIAL_CONFIG_KEYS_SET.has(k))
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelAdvancedDialog({ open, onOpenChange, instance, onUpdate }: ChannelAdvancedDialogProps) {
|
||||
const { t } = useTranslation('channels')
|
||||
const [values, setValues] = useState<Record<string, unknown>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(deriveInitialValues(instance))
|
||||
setError('')
|
||||
}
|
||||
}, [open, instance])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const fields = getAdvancedFields(instance.channel_type)
|
||||
const essentialKeys = ESSENTIAL_CONFIG_KEYS[instance.channel_type] ?? ESSENTIAL_CONFIG_KEYS['_default']
|
||||
|
||||
const handleChange = (key: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const existingConfig = (instance.config ?? {}) as Record<string, unknown>
|
||||
// Preserve essential keys + groups key
|
||||
const essential = Object.fromEntries(
|
||||
Object.entries(existingConfig).filter(([k]) => essentialKeys.includes(k) || k === 'groups')
|
||||
)
|
||||
await onUpdate({ ...essential, ...values })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('advanced.saveFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groups = [
|
||||
{ key: 'network', label: t('advanced.network'), fields: fields.network },
|
||||
{ key: 'limits', label: t('advanced.limits'), fields: fields.limits },
|
||||
{ key: 'streaming', label: t('advanced.streaming'), fields: fields.streaming },
|
||||
{ key: 'behavior', label: t('advanced.behavior'), fields: fields.behavior },
|
||||
{ key: 'access', label: t('advanced.access'), fields: fields.access },
|
||||
].filter((g) => g.fields.length > 0)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 flex flex-col" style={{ maxHeight: '85vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('advanced.title')}</h3>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors cursor-pointer">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-5 py-4 space-y-5">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-text-muted text-center py-6">{t('advanced.noFields')}</p>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-3">{g.label}</p>
|
||||
<ChannelFields
|
||||
fields={g.fields}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
idPrefix={`adv-${g.key}`}
|
||||
isEdit
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3 shrink-0">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-accent text-white text-sm rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChannelFields } from './channel-field-renderer'
|
||||
import { configSchema, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
|
||||
import type { ChannelInstanceData } from '../../../types/channel'
|
||||
|
||||
interface ChannelAdvancedTabProps {
|
||||
instance: ChannelInstanceData
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
|
||||
const ESSENTIAL_KEYS = new Set(['dm_policy', 'group_policy', 'require_mention', 'mention_mode'])
|
||||
|
||||
function getAdvancedFields(channelType: string) {
|
||||
const allFields = configSchema[channelType] ?? []
|
||||
const advanced = allFields.filter((f) => !ESSENTIAL_KEYS.has(f.key))
|
||||
return {
|
||||
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
|
||||
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
|
||||
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
|
||||
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
|
||||
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
|
||||
const config = (instance.config ?? {}) as Record<string, unknown>
|
||||
const { groups: _groups, ...rest } = config
|
||||
return Object.fromEntries(
|
||||
Object.entries(rest).filter(([k]) => !ESSENTIAL_KEYS.has(k)),
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelAdvancedTab({ instance, onUpdate }: ChannelAdvancedTabProps) {
|
||||
const { t } = useTranslation('channels')
|
||||
const [values, setValues] = useState<Record<string, unknown>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setValues(deriveInitialValues(instance))
|
||||
}, [instance])
|
||||
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
const fields = getAdvancedFields(instance.channel_type)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const existingConfig = (instance.config ?? {}) as Record<string, unknown>
|
||||
const cleanAdvanced = Object.fromEntries(
|
||||
Object.entries(values).filter(([, v]) => v !== undefined && v !== '' && v !== null),
|
||||
)
|
||||
const merged = { ...existingConfig, ...cleanAdvanced }
|
||||
await onUpdate({ config: merged })
|
||||
} catch {
|
||||
// toast shown by hook
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ key: 'network', label: t('detail.network'), desc: t('detail.networkDesc'), fields: fields.network },
|
||||
{ key: 'limits', label: t('detail.limits'), desc: t('detail.limitsDesc'), fields: fields.limits },
|
||||
{ key: 'streaming', label: t('detail.streaming'), desc: t('detail.streamingDesc'), fields: fields.streaming },
|
||||
{ key: 'behavior', label: t('detail.behavior'), desc: t('detail.behaviorDesc'), fields: fields.behavior },
|
||||
{ key: 'access', label: t('detail.accessControl'), desc: t('detail.accessControlDesc'), fields: fields.access },
|
||||
].filter((s) => s.fields.length > 0)
|
||||
|
||||
if (sections.length === 0) {
|
||||
return <p className="text-xs text-text-muted py-4">{t('detail.noAdvanced')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{sections.map((s) => (
|
||||
<section key={s.key} className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-secondary">{s.label}</h3>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">{s.desc}</p>
|
||||
</div>
|
||||
<ChannelFields fields={s.fields} values={values} onChange={handleChange} idPrefix={`adv-${s.key}`} contextValues={values} />
|
||||
</section>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{saving ? t('form.saving') : t('detail.saveConfig')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import { ChannelGeneralTab } from './ChannelGeneralTab'
|
||||
import { ChannelCredentialsTab } from './ChannelCredentialsTab'
|
||||
import { ChannelManagersTab } from './ChannelManagersTab'
|
||||
import { ChannelAdvancedDialog } from './ChannelAdvancedDialog'
|
||||
import { ChannelAdvancedTab } from './ChannelAdvancedTab'
|
||||
import type { ChannelStatus } from '../../../types/channel'
|
||||
|
||||
interface ChannelDetailPanelProps {
|
||||
@@ -15,7 +15,7 @@ interface ChannelDetailPanelProps {
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const TABS = ['general', 'credentials', 'managers'] as const
|
||||
const TABS = ['general', 'credentials', 'managers', 'advanced'] as const
|
||||
type TabKey = (typeof TABS)[number]
|
||||
|
||||
export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: ChannelDetailPanelProps) {
|
||||
@@ -23,7 +23,6 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
|
||||
const { instance, loading, updateInstance, listManagerGroups, listManagers, addManager, removeManager, listContacts } = useChannelDetail(instanceId)
|
||||
const { agents } = useAgentCrud()
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('general')
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
if (loading || !instance) {
|
||||
return (
|
||||
@@ -65,14 +64,9 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
|
||||
<span className="text-[11px] text-text-muted shrink-0">{statusText}</span>
|
||||
<span className="text-[11px] text-text-muted shrink-0">· {agentName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button onClick={() => setAdvancedOpen(true)} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
|
||||
{t('detail.advanced')}
|
||||
</button>
|
||||
<button onClick={onDelete} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer">
|
||||
{t('delete.confirmLabel')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={onDelete} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer shrink-0">
|
||||
{t('delete.confirmLabel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -105,15 +99,8 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
|
||||
listContacts={listContacts}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'advanced' && <ChannelAdvancedTab instance={instance} onUpdate={updateInstance} />}
|
||||
</div>
|
||||
|
||||
{/* Advanced dialog */}
|
||||
<ChannelAdvancedDialog
|
||||
open={advancedOpen}
|
||||
onOpenChange={setAdvancedOpen}
|
||||
instance={instance}
|
||||
onUpdate={updateInstance}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,9 +24,7 @@ export function ChannelManagersTab({
|
||||
const [managersMap, setManagersMap] = useState<Record<string, GroupManagerData[]>>({})
|
||||
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({})
|
||||
const [contactOptions, setContactOptions] = useState<{ value: string; label: string }[]>([])
|
||||
// Per-group inline add: userId
|
||||
const [inlineUserId, setInlineUserId] = useState<Record<string, string>>({})
|
||||
// Standalone add form
|
||||
const [newGroupId, setNewGroupId] = useState('')
|
||||
const [newUserId, setNewUserId] = useState('')
|
||||
const [addingMap, setAddingMap] = useState<Record<string, boolean>>({})
|
||||
@@ -34,8 +32,7 @@ export function ChannelManagersTab({
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const data = await listManagerGroups()
|
||||
setGroups(data)
|
||||
setGroups(await listManagerGroups())
|
||||
} catch {
|
||||
setGroups([])
|
||||
}
|
||||
@@ -49,6 +46,7 @@ export function ChannelManagersTab({
|
||||
if (next && !managersMap[groupId]) {
|
||||
setLoadingMap((prev) => ({ ...prev, [groupId]: true }))
|
||||
try {
|
||||
setManagersMap((prev) => ({ ...prev, [groupId]: [] }))
|
||||
const data = await listManagers(groupId)
|
||||
setManagersMap((prev) => ({ ...prev, [groupId]: data }))
|
||||
} finally {
|
||||
@@ -58,6 +56,7 @@ export function ChannelManagersTab({
|
||||
}
|
||||
|
||||
const handleContactSearch = useCallback(async (search: string) => {
|
||||
if (!search || search.length < 2) return
|
||||
try {
|
||||
const contacts = await listContacts(search)
|
||||
setContactOptions(contacts.map((c) => ({
|
||||
@@ -77,11 +76,10 @@ export function ChannelManagersTab({
|
||||
try {
|
||||
await addManager(groupId, userId)
|
||||
setInlineUserId((prev) => ({ ...prev, [groupId]: '' }))
|
||||
const data = await listManagers(groupId)
|
||||
setManagersMap((prev) => ({ ...prev, [groupId]: data }))
|
||||
setManagersMap((prev) => ({ ...prev, [groupId]: [...(prev[groupId] ?? []), { user_id: userId }] }))
|
||||
await loadGroups()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('managers.addFailed'))
|
||||
setError(err instanceof Error ? err.message : t('detail.managers.addForm.errors.failedAdd'))
|
||||
} finally {
|
||||
setAddingMap((prev) => ({ ...prev, [groupId]: false }))
|
||||
}
|
||||
@@ -97,7 +95,7 @@ export function ChannelManagersTab({
|
||||
}))
|
||||
await loadGroups()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('managers.removeFailed'))
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,73 +110,98 @@ export function ChannelManagersTab({
|
||||
setNewGroupId('')
|
||||
setNewUserId('')
|
||||
await loadGroups()
|
||||
// Auto-expand new group
|
||||
setExpanded((prev) => ({ ...prev, [gid]: true }))
|
||||
const data = await listManagers(gid)
|
||||
setManagersMap((prev) => ({ ...prev, [gid]: data }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('managers.addFailed'))
|
||||
setError(err instanceof Error ? err.message : t('detail.managers.addForm.errors.failedAdd'))
|
||||
} finally {
|
||||
setAddingMap((prev) => ({ ...prev, _new: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = 'bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent'
|
||||
const inputClass = 'w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-text-muted">{t('detail.managers.description')}</p>
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-text-muted py-6 text-center">{t('managers.empty')}</p>
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-xs text-text-muted">{t('detail.managers.noManagerGroups')}</p>
|
||||
<p className="text-[11px] text-text-muted/70 mt-1">{t('detail.managers.noManagerGroupsHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((g) => (
|
||||
<div key={g.group_id} className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleToggle(g.group_id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-surface-secondary hover:bg-surface-tertiary transition-colors text-left cursor-pointer"
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-tertiary/30 transition-colors text-left cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
|
||||
<span className="text-xs font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-text-muted">{g.writer_count} {t('managers.writers')}</span>
|
||||
<svg className={`w-4 h-4 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="m6 9 6 6 6-6" /></svg>
|
||||
<span className="rounded-full bg-surface-tertiary px-2 py-0.5 text-[11px] text-text-muted tabular-nums">
|
||||
{g.writer_count === 1
|
||||
? t('detail.managers.managersCount', { count: g.writer_count })
|
||||
: t('detail.managers.managersCountPlural', { count: g.writer_count })}
|
||||
</span>
|
||||
<svg className={`w-3.5 h-3.5 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-90' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded[g.group_id] && (
|
||||
<div className="px-4 pb-3 pt-2 bg-surface-primary space-y-3">
|
||||
<div className="border-t border-border bg-surface-tertiary/10 px-4 pb-3 pt-2 space-y-3">
|
||||
{loadingMap[g.group_id] ? (
|
||||
<p className="text-xs text-text-muted py-2">{t('common.loading')}</p>
|
||||
<p className="text-xs text-text-muted py-2">{t('detail.managers.loadingManagers')}</p>
|
||||
) : (managersMap[g.group_id] ?? []).length === 0 ? (
|
||||
<p className="text-xs text-text-muted py-2">{t('managers.noManagers')}</p>
|
||||
<p className="text-xs text-text-muted py-2">{t('detail.managers.noManagers')}</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{(managersMap[g.group_id] ?? []).map((m) => (
|
||||
<div key={m.user_id} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<span className="text-xs font-mono text-text-primary">{m.user_id}</span>
|
||||
{m.display_name && <span className="text-xs text-text-muted ml-2">{m.display_name}</span>}
|
||||
{m.username && <span className="text-xs text-text-muted ml-1">@{m.username}</span>}
|
||||
</div>
|
||||
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-xs text-error hover:opacity-80 cursor-pointer transition-opacity">
|
||||
{t('managers.remove')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-md border border-border bg-surface-secondary overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.userId')}</th>
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.name')}</th>
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.username')}</th>
|
||||
<th className="px-3 py-1.5 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(managersMap[g.group_id] ?? []).map((m) => (
|
||||
<tr key={m.user_id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
|
||||
<td className="px-3 py-1.5 font-mono">{m.user_id}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{m.display_name || '—'}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{m.username ? `@${m.username}` : '—'}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-1">
|
||||
{/* Inline add */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={inlineUserId[g.group_id] ?? ''}
|
||||
onChange={(v) => { setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }}
|
||||
options={contactOptions}
|
||||
placeholder={t('managers.userIdPlaceholder')}
|
||||
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInlineAdd(g.group_id)}
|
||||
disabled={addingMap[g.group_id] || !inlineUserId[g.group_id]?.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
|
||||
>
|
||||
{t('managers.add')}
|
||||
{t('detail.managers.addForm.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,29 +213,32 @@ export function ChannelManagersTab({
|
||||
|
||||
{/* Standalone add form for new groups */}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-text-secondary">{t('managers.addToGroup')}</p>
|
||||
<input
|
||||
value={newGroupId}
|
||||
onChange={(e) => setNewGroupId(e.target.value)}
|
||||
placeholder={t('managers.groupIdPlaceholder')}
|
||||
className={`w-full ${inputClass}`}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={newUserId}
|
||||
onChange={(v) => { setNewUserId(v); handleContactSearch(v) }}
|
||||
options={contactOptions}
|
||||
placeholder={t('managers.userIdPlaceholder')}
|
||||
/>
|
||||
<p className="text-xs font-medium text-text-secondary">{t('detail.managers.addForm.title')}</p>
|
||||
<p className="text-[11px] text-text-muted">{t('detail.managers.addForm.hint')}</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={newGroupId}
|
||||
onChange={(e) => setNewGroupId(e.target.value)}
|
||||
placeholder={t('detail.managers.addForm.groupIdPlaceholder')}
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={newUserId}
|
||||
onChange={(v) => { setNewUserId(v); handleContactSearch(v) }}
|
||||
options={contactOptions}
|
||||
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStandaloneAdd}
|
||||
disabled={addingMap['_new'] || !newGroupId.trim() || !newUserId.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
|
||||
>
|
||||
{t('detail.managers.addForm.addManager')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStandaloneAdd}
|
||||
disabled={addingMap['_new'] || !newGroupId.trim() || !newUserId.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('managers.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,14 @@ export function usePairedDevices() {
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const res = (await ws.call('device.pair.list')) as { pending: PendingPairing[]; paired: PairedDevice[] }
|
||||
const raw = await ws.call('device.pair.list')
|
||||
console.log('[paired-devices] raw response:', raw)
|
||||
const res = raw as { pending: PendingPairing[]; paired: PairedDevice[] }
|
||||
console.log('[paired-devices] pending:', res.pending?.length, 'paired:', res.paired?.length)
|
||||
setPendingPairings(res.pending ?? [])
|
||||
setPairedDevices(res.paired ?? [])
|
||||
} catch {
|
||||
// ignore — gateway may not be connected
|
||||
} catch (err) {
|
||||
console.error('[paired-devices] error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"credentials": "Credentials",
|
||||
"managers": "Managers"
|
||||
"managers": "Managers",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"general": {
|
||||
"identity": "Identity",
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"tabs": {
|
||||
"general": "Chung",
|
||||
"credentials": "Xác thực",
|
||||
"managers": "Quản lý"
|
||||
"managers": "Quản lý",
|
||||
"advanced": "Nâng cao"
|
||||
},
|
||||
"general": {
|
||||
"identity": "Thông tin",
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"tabs": {
|
||||
"general": "常规",
|
||||
"credentials": "凭据",
|
||||
"managers": "管理员"
|
||||
"managers": "管理员",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"general": {
|
||||
"identity": "身份信息",
|
||||
|
||||
Reference in new issue
Block a user