diff --git a/internal/store/sqlitestore/pairing.go b/internal/store/sqlitestore/pairing.go index 0d93c4c1..ba8a3491 100644 --- a/internal/store/sqlitestore/pairing.go +++ b/internal/store/sqlitestore/pairing.go @@ -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) diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedDialog.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedDialog.tsx deleted file mode 100644 index eea756fb..00000000 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedDialog.tsx +++ /dev/null @@ -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) => Promise -} - -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 { - const config = (instance.config ?? {}) as Record - 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>({}) - 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 - // 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 ( -
-
- {/* Header */} -
-

{t('advanced.title')}

- -
- - {/* Scrollable body */} -
- {groups.length === 0 ? ( -

{t('advanced.noFields')}

- ) : ( - groups.map((g) => ( -
-

{g.label}

- -
- )) - )} - {error &&

{error}

} -
- - {/* Footer */} -
- - -
-
-
- ) -} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedTab.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedTab.tsx new file mode 100644 index 00000000..ea4bf3c4 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedTab.tsx @@ -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) => Promise +} + +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 { + const config = (instance.config ?? {}) as Record + 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>({}) + 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 + 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

{t('detail.noAdvanced')}

+ } + + return ( +
+ {sections.map((s) => ( +
+
+

{s.label}

+

{s.desc}

+
+ +
+ ))} + + +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx index 3e510d40..ca1baa15 100644 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx @@ -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('general') - const [advancedOpen, setAdvancedOpen] = useState(false) if (loading || !instance) { return ( @@ -65,14 +64,9 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha {statusText} · {agentName} -
- - -
+ {/* Tabs */} @@ -105,15 +99,8 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha listContacts={listContacts} /> )} + {activeTab === 'advanced' && } - - {/* Advanced dialog */} - ) } diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx index 8bb74779..26e6a3d3 100644 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx @@ -24,9 +24,7 @@ export function ChannelManagersTab({ const [managersMap, setManagersMap] = useState>({}) const [loadingMap, setLoadingMap] = useState>({}) const [contactOptions, setContactOptions] = useState<{ value: string; label: string }[]>([]) - // Per-group inline add: userId const [inlineUserId, setInlineUserId] = useState>({}) - // Standalone add form const [newGroupId, setNewGroupId] = useState('') const [newUserId, setNewUserId] = useState('') const [addingMap, setAddingMap] = useState>({}) @@ -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 (
+

{t('detail.managers.description')}

{error &&

{error}

} {groups.length === 0 ? ( -

{t('managers.empty')}

+
+

{t('detail.managers.noManagerGroups')}

+

{t('detail.managers.noManagerGroupsHint')}

+
) : (
{groups.map((g) => (
{expanded[g.group_id] && ( -
+
{loadingMap[g.group_id] ? ( -

{t('common.loading')}

+

{t('detail.managers.loadingManagers')}

) : (managersMap[g.group_id] ?? []).length === 0 ? ( -

{t('managers.noManagers')}

+

{t('detail.managers.noManagers')}

) : ( -
- {(managersMap[g.group_id] ?? []).map((m) => ( -
-
- {m.user_id} - {m.display_name && {m.display_name}} - {m.username && @{m.username}} -
- -
- ))} +
+ + + + + + + + + + {(managersMap[g.group_id] ?? []).map((m) => ( + + + + + + + ))} + +
{t('detail.managers.columns.userId')}{t('detail.managers.columns.name')}{t('detail.managers.columns.username')} +
{m.user_id}{m.display_name || '—'}{m.username ? `@${m.username}` : '—'} + +
)} -
+ {/* Inline add */} +
{ setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }} options={contactOptions} - placeholder={t('managers.userIdPlaceholder')} + placeholder={t('detail.managers.addForm.userIdPlaceholder')} />
@@ -190,29 +213,32 @@ export function ChannelManagersTab({ {/* Standalone add form for new groups */}
-

{t('managers.addToGroup')}

- setNewGroupId(e.target.value)} - placeholder={t('managers.groupIdPlaceholder')} - className={`w-full ${inputClass}`} - /> -
-
- { setNewUserId(v); handleContactSearch(v) }} - options={contactOptions} - placeholder={t('managers.userIdPlaceholder')} - /> +

{t('detail.managers.addForm.title')}

+

{t('detail.managers.addForm.hint')}

+
+ setNewGroupId(e.target.value)} + placeholder={t('detail.managers.addForm.groupIdPlaceholder')} + className={inputClass} + /> +
+
+ { setNewUserId(v); handleContactSearch(v) }} + options={contactOptions} + placeholder={t('detail.managers.addForm.userIdPlaceholder')} + /> +
+
-
diff --git a/ui/desktop/frontend/src/hooks/use-paired-devices.ts b/ui/desktop/frontend/src/hooks/use-paired-devices.ts index 3edd2d9e..ad4b8105 100644 --- a/ui/desktop/frontend/src/hooks/use-paired-devices.ts +++ b/ui/desktop/frontend/src/hooks/use-paired-devices.ts @@ -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) } diff --git a/ui/desktop/frontend/src/i18n/locales/en/channels.json b/ui/desktop/frontend/src/i18n/locales/en/channels.json index daa80c9d..d8dbd0af 100644 --- a/ui/desktop/frontend/src/i18n/locales/en/channels.json +++ b/ui/desktop/frontend/src/i18n/locales/en/channels.json @@ -41,7 +41,8 @@ "tabs": { "general": "General", "credentials": "Credentials", - "managers": "Managers" + "managers": "Managers", + "advanced": "Advanced" }, "general": { "identity": "Identity", diff --git a/ui/desktop/frontend/src/i18n/locales/vi/channels.json b/ui/desktop/frontend/src/i18n/locales/vi/channels.json index e44b73b5..9ae5ed98 100644 --- a/ui/desktop/frontend/src/i18n/locales/vi/channels.json +++ b/ui/desktop/frontend/src/i18n/locales/vi/channels.json @@ -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", diff --git a/ui/desktop/frontend/src/i18n/locales/zh/channels.json b/ui/desktop/frontend/src/i18n/locales/zh/channels.json index 4d447d02..034d97f9 100644 --- a/ui/desktop/frontend/src/i18n/locales/zh/channels.json +++ b/ui/desktop/frontend/src/i18n/locales/zh/channels.json @@ -41,7 +41,8 @@ "tabs": { "general": "常规", "credentials": "凭据", - "managers": "管理员" + "managers": "管理员", + "advanced": "高级" }, "general": { "identity": "身份信息",