feat(channels): redesign list page — show only configured channels, add hero empty state

This commit is contained in:
matevip 2026-04-30 06:59:47 +08:00
parent a11f0586ba
commit 3db4230142
10 changed files with 359 additions and 91 deletions

View File

@ -184,11 +184,27 @@ public class ChannelController {
});
body.put("name", c.getName());
body.put("enabled", Boolean.TRUE.equals(c.getEnabled()));
body.put("identity", parseIdentity(c.getIdentityJson()));
return body;
})
.toList());
}
/**
* Parse identity_json into a map for the list-page card. Returns an
* empty map for legacy rows that have not been re-verified yet, so the
* frontend can render the type-level description as a fallback.
*/
private Map<String, Object> parseIdentity(String identityJson) {
if (identityJson == null || identityJson.isBlank()) return Collections.emptyMap();
try {
return objectMapper.readValue(identityJson, new TypeReference<>() {});
} catch (Exception e) {
log.debug("identity_json parse failed (treating as empty): {}", e.getMessage());
return Collections.emptyMap();
}
}
@RequireWorkspaceRole("admin")
@Operation(summary = "Pre-flight: validate draft channel config without persisting")
@PostMapping("/preflight")

View File

@ -34,6 +34,16 @@ public class ChannelEntity {
@TableField(value = "config_json", updateStrategy = FieldStrategy.ALWAYS)
private String configJson;
/**
* Identity snapshot from the most recent successful credential probe
* (RFC-084 follow-up). JSON-encoded {@code accountName / accountId / team /
* region / ...} payload. Surfaced on the list page so cards read
* "Connected as @MyBot" instead of the type-level description. Optional
* legacy rows have it null until the next successful connect.
*/
@TableField(value = "identity_json", updateStrategy = FieldStrategy.ALWAYS)
private String identityJson;
/** 是否启用 */
private Boolean enabled;

View File

@ -0,0 +1,6 @@
-- RFC-084 follow-up: persist the identity returned by ChannelVerifier so
-- the channel list can show "Connected as @MyBot" instead of generic
-- type-level descriptions. Populated on wizard create from VerificationResult;
-- refreshed by adapters on first successful connect.
ALTER TABLE mate_channel ADD COLUMN IF NOT EXISTS identity_json TEXT;

View File

@ -0,0 +1,13 @@
-- RFC-084 follow-up: persist the identity returned by ChannelVerifier so
-- the channel list can show "Connected as @MyBot" instead of generic
-- type-level descriptions. Populated on wizard create from VerificationResult;
-- refreshed by adapters on first successful connect.
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_channel'
AND COLUMN_NAME = 'identity_json');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_channel ADD COLUMN identity_json TEXT',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;

View File

@ -631,7 +631,21 @@ async function onDone() {
accessControl: defaultAccessControl(),
renderConfig: defaultRenderConfig(),
})
const payload: Partial<Channel> = { ...form.value, configJson, enabled: true }
// Persist identity from the verifyResult so the list page can show
// "Connected as @MyBot" without waiting for the next adapter probe.
// Skipped channels (web/webchat/webhook) carry an empty identity, so
// the field stays null in the DB and the legacy description path is
// still available as a fallback.
const identityMap = verifyResult.value?.identity || {}
const identityJson = Object.keys(identityMap).length > 0
? JSON.stringify(identityMap)
: undefined
const payload: Partial<Channel> = {
...form.value,
configJson,
identityJson,
enabled: true,
}
const res: any = await channelApi.create(payload)
ElMessage.success(t('channels.messages.saveSuccess'))
emit('created', res.data as Channel)

View File

@ -2,23 +2,38 @@
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="picker">
<div class="picker-header">
<h2 class="picker-title">{{ t('channels.newChannel') }}</h2>
<div>
<h2 class="picker-title">{{ t('channels.newChannel') }}</h2>
<p class="picker-subtitle">{{ t('channels.catalog.subtitle') }}</p>
</div>
<button class="picker-close" @click="close" :title="t('common.cancel')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="picker-grid">
<button
v-for="type in types"
:key="type"
class="picker-card"
@click="pick(type)"
>
<img :src="`/icons/channels/${type}.svg`" :alt="type" class="picker-icon" />
<span class="picker-name">{{ t(`channels.types.${type}`) }}</span>
</button>
<!-- Catalog grouped by category. The catalog only renders here in
the picker never on the main list page so users browse the
full surface area exactly when they're trying to add something. -->
<div class="picker-scroll">
<section v-for="group in groups" :key="group.key" class="picker-section">
<h3 class="picker-section-title">{{ t(`channels.catalog.groups.${group.key}`) }}</h3>
<div class="picker-grid">
<button
v-for="type in group.types"
:key="type"
class="picker-card"
@click="pick(type)"
>
<img :src="`/icons/channels/${type}.svg`" :alt="type" class="picker-icon" />
<div class="picker-text">
<span class="picker-name">{{ t(`channels.types.${type}`) }}</span>
<span class="picker-desc">{{ t(`channels.catalog.descriptions.${type}`) }}</span>
</div>
</button>
</div>
</section>
</div>
</div>
</div>
@ -36,13 +51,13 @@ const emit = defineEmits<{
const { t } = useI18n()
// Order matches the dropdown in ChannelEditModal so users see the same
// ordering. Web/WebChat/Webhook last because they're "platform" rather
// than "messaging service".
const types = [
'telegram', 'discord', 'slack',
'dingtalk', 'feishu', 'wecom', 'weixin', 'qq',
'web', 'webchat', 'webhook',
// Three categories so the catalog reads as a curated shelf, not a flat
// dump. Order within each group is from "most common" to "edge" so first
// glance lands on the right thing.
const groups = [
{ key: 'im', types: ['telegram', 'discord', 'slack', 'qq'] },
{ key: 'enterprise', types: ['wecom', 'weixin', 'feishu', 'dingtalk'] },
{ key: 'web', types: ['web', 'webchat', 'webhook'] },
]
function pick(type: string) {
@ -56,14 +71,23 @@ function close() {
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.picker { background: var(--mc-bg-elevated); border-radius: 18px; width: 100%; max-width: 520px; max-height: 88vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.18); overflow: hidden; }
.picker-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
.picker-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
.picker-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 8px; }
.picker { background: var(--mc-bg-elevated); border-radius: 18px; width: 100%; max-width: 640px; max-height: 88vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.18); overflow: hidden; }
.picker-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 22px 26px 16px; border-bottom: 1px solid var(--mc-border-light); }
.picker-title { font-size: 19px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
.picker-subtitle { font-size: 13px; color: var(--mc-text-secondary); margin: 4px 0 0; line-height: 1.5; }
.picker-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 8px; flex-shrink: 0; }
.picker-close:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.picker-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 20px 24px; overflow-y: auto; }
.picker-card { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px 8px; background: var(--mc-bg-sunken); border: 1.5px solid transparent; border-radius: 12px; cursor: pointer; transition: all 0.15s; font-family: inherit; }
.picker-scroll { flex: 1; overflow-y: auto; padding: 18px 22px 22px; }
.picker-section { margin-top: 18px; }
.picker-section:first-child { margin-top: 4px; }
.picker-section-title { font-size: 12px; font-weight: 700; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.6px; margin: 0 4px 10px; }
.picker-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.picker-card { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; background: var(--mc-bg-sunken); border: 1.5px solid transparent; border-radius: 12px; cursor: pointer; transition: all 0.15s; font-family: inherit; text-align: left; }
.picker-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg, rgba(217,119,87,0.06)); transform: translateY(-1px); }
.picker-icon { width: 36px; height: 36px; border-radius: 8px; }
.picker-name { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); text-align: center; }
.picker-icon { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
.picker-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.picker-name { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); line-height: 1.3; }
.picker-desc { font-size: 12px; color: var(--mc-text-secondary); line-height: 1.4; }
</style>

View File

@ -1537,6 +1537,42 @@ export default {
configure: 'Configure',
enable: 'Enable',
disable: 'Disable',
empty: {
title: 'Connect your first channel',
desc: 'Bring your agents into the apps your team already uses — Slack, WeChat Work, Telegram, and more.',
cta: 'Connect a Channel',
},
stats: {
active: '{n} active',
reconnecting: '{n} reconnecting',
errors: '{n} {n, plural, one {error} other {errors}}',
disabled: '{n} disabled',
},
cardDesc: {
connectedAs: 'Connected as {account}',
connectedAsIn: 'Connected as {account} in {team}',
},
catalog: {
subtitle: 'Pick a service to connect. You can change settings later.',
groups: {
im: 'Messaging',
enterprise: 'Enterprise platforms',
web: 'Web & API',
},
descriptions: {
telegram: 'Bot via long-poll. No public IP needed.',
discord: 'Bot via Gateway WebSocket. No public IP needed.',
slack: 'Slack app via Socket Mode.',
qq: 'QQ Bot via WebSocket. No public IP needed.',
wecom: 'WeCom smart bot. Scan QR to connect.',
weixin: 'WeChat personal account via iLink.',
feishu: 'Feishu / Lark app. One-click QR creation.',
dingtalk: 'DingTalk bot. One-click QR creation.',
web: 'Default browser channel. Always on.',
webchat: 'Embeddable chat widget for your site.',
webhook: 'Generic HTTP inbound endpoint.',
},
},
modal: {
editTitle: 'Edit Channel',
newTitle: 'New Channel',

View File

@ -1547,6 +1547,42 @@ export default {
configure: '配置',
enable: '启用',
disable: '停用',
empty: {
title: '连接第一个渠道',
desc: '把你的 Agent 接入团队已经在用的 IM 工具——Slack、企业微信、Telegram 等。',
cta: '连接一个渠道',
},
stats: {
active: '{n} 个运行中',
reconnecting: '{n} 个重连中',
errors: '{n} 个出错',
disabled: '{n} 个已停用',
},
cardDesc: {
connectedAs: '已连接:{account}',
connectedAsIn: '已连接:{account}{team}',
},
catalog: {
subtitle: '选一个服务接入,配置项稍后还能改。',
groups: {
im: 'IM 平台',
enterprise: '企业平台',
web: '网页与 API',
},
descriptions: {
telegram: '长轮询接入,无需公网 IP',
discord: 'Gateway WebSocket无需公网 IP',
slack: 'Socket Mode 接入',
qq: 'WebSocket 长连接,无需公网 IP',
wecom: '企业微信智能机器人,扫码即用',
weixin: '微信个人号iLink Bot',
feishu: '飞书 / Lark 应用,扫码一键创建',
dingtalk: '钉钉机器人,扫码一键创建',
web: '默认浏览器渠道,始终可用',
webchat: '可嵌入网页的聊天小组件',
webhook: '通用 HTTP 接入端点',
},
},
modal: {
editTitle: '编辑渠道',
newTitle: '新建渠道',

View File

@ -336,6 +336,9 @@ export interface Channel {
agentId?: string | number
botPrefix?: string
configJson?: string
/** Identity snapshot from the most recent successful credential verify
* (RFC-084). JSON-encoded {accountName, accountId, team, region, ...}. */
identityJson?: string
enabled: boolean
description?: string
// 前端扩展字段

View File

@ -8,7 +8,7 @@
<h1 class="mc-page-title">{{ t('channels.title') }}</h1>
<p class="mc-page-desc">{{ t('channels.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<button v-if="channels.length > 0" class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
@ -16,71 +16,119 @@
</button>
</div>
<!-- 加载中骨架 -->
<!-- Loading skeleton (initial fetch) -->
<div v-if="isInitialLoading" class="channel-grid">
<div v-for="i in 4" :key="i" class="channel-card mc-surface-card channel-card-skeleton">
<div v-for="i in 3" :key="i" class="channel-card mc-surface-card channel-card-skeleton">
<el-skeleton :rows="3" animated />
</div>
</div>
<!-- 渠道卡片 -->
<div v-else class="channel-grid">
<div v-for="channel in channels" :key="channel.id" class="channel-card mc-surface-card">
<div class="channel-header">
<div class="channel-icon-wrap">
<img class="channel-icon-img" :src="getChannelIconPath(channel.channelType)" :alt="channel.channelType" />
</div>
<div class="channel-meta">
<h3 class="channel-name">{{ channel.name }}</h3>
<span class="channel-type">{{ channel.channelType }}</span>
</div>
<div class="channel-status-group">
<div class="channel-status" :class="channel.enabled ? 'status-on' : 'status-off'">
{{ channel.enabled ? t('channels.status.active') : t('channels.status.inactive') }}
</div>
<div
v-if="channel.enabled"
class="connection-indicator"
:class="getConnectionClass(channel)"
:title="getConnectionTooltip(channel)"
>
{{ getConnectionIcon(channel) }} {{ getConnectionLabel(channel) }}
</div>
</div>
</div>
<p class="channel-desc">{{ channel.description }}</p>
<div class="channel-footer">
<button class="card-btn" @click="openEditModal(channel)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ t('channels.configure') }}
</button>
<button class="card-btn" @click="toggleChannel(channel)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line v-if="channel.enabled" x1="8" y1="12" x2="16" y2="12"/>
<polyline v-else points="10 8 16 12 10 16"/>
</svg>
{{ channel.enabled ? t('channels.disable') : t('channels.enable') }}
</button>
<button class="card-btn danger" @click="deleteChannel(channel.id)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
{{ t('common.delete') }}
</button>
</div>
<!-- 0-channel hero empty state.
Replaces the 11-card "catalog soup" with a single CTA. The
type catalog moves into ChannelTypePicker (one click away),
so the default view is silent until the user has actually
configured something. -->
<div v-else-if="channels.length === 0" class="empty-hero mc-surface-card">
<div class="empty-hero-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</div>
<h2 class="empty-hero-title">{{ t('channels.empty.title') }}</h2>
<p class="empty-hero-desc">{{ t('channels.empty.desc') }}</p>
<button class="btn-primary empty-hero-cta" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('channels.empty.cta') }}
</button>
</div>
<!-- Configured-channels view: stats + cards -->
<template v-else>
<!-- Stats bar small one-liner so "is anything broken?" is
answerable at a glance without scanning every card. -->
<div class="stats-bar">
<span class="stat" :class="{ ok: stats.active > 0 }">
<span class="stat-dot conn-connected"></span>
{{ t('channels.stats.active', { n: stats.active }) }}
</span>
<span v-if="stats.reconnecting > 0" class="stat warn">
<span class="stat-dot conn-reconnecting"></span>
{{ t('channels.stats.reconnecting', { n: stats.reconnecting }) }}
</span>
<span v-if="stats.errors > 0" class="stat danger">
<span class="stat-dot conn-error"></span>
{{ t('channels.stats.errors', { n: stats.errors }) }}
</span>
<span v-if="stats.disabled > 0" class="stat muted">
{{ t('channels.stats.disabled', { n: stats.disabled }) }}
</span>
</div>
<!-- 添加渠道卡片 -->
<div class="channel-card add-card mc-surface-card" @click="openCreateModal">
<div class="add-icon">+</div>
<p class="add-label">{{ t('channels.addChannel') }}</p>
<div class="channel-grid">
<div v-for="channel in channels" :key="channel.id" class="channel-card mc-surface-card">
<div class="channel-header">
<div class="channel-icon-wrap">
<img class="channel-icon-img" :src="getChannelIconPath(channel.channelType)" :alt="channel.channelType" />
</div>
<div class="channel-meta">
<h3 class="channel-name">{{ channel.name }}</h3>
<span class="channel-type">{{ channel.channelType }}</span>
</div>
<div class="channel-status-group">
<div
v-if="channel.enabled"
class="connection-indicator"
:class="getConnectionClass(channel)"
:title="getConnectionTooltip(channel)"
>
{{ getConnectionIcon(channel) }} {{ getConnectionLabel(channel) }}
</div>
<div v-else class="channel-status status-off">
{{ t('channels.status.inactive') }}
</div>
</div>
</div>
<!-- Identity-driven description: "Connected as @MyBot in TeamX".
Falls back to the type-level description for legacy rows
that have not been re-verified yet. -->
<p class="channel-desc">{{ getChannelDescription(channel) }}</p>
<div class="channel-footer">
<button class="card-btn" @click="openEditModal(channel)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ t('channels.configure') }}
</button>
<button class="card-btn" @click="toggleChannel(channel)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line v-if="channel.enabled" x1="8" y1="12" x2="16" y2="12"/>
<polyline v-else points="10 8 16 12 10 16"/>
</svg>
{{ channel.enabled ? t('channels.disable') : t('channels.enable') }}
</button>
<button class="card-btn danger" @click="deleteChannel(channel.id)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
{{ t('common.delete') }}
</button>
</div>
</div>
<!-- Compact "+ add another" tail card. iOS Mail pattern: it
stays visible only when the user already has channels, so
it's a familiar repeat-action shortcut, not a hero CTA. -->
<button class="add-card-compact mc-surface-card" @click="openCreateModal">
<div class="add-icon-compact">+</div>
<span class="add-label-compact">{{ t('channels.addChannel') }}</span>
</button>
</div>
</div>
</template>
</div>
</div>
@ -120,7 +168,7 @@
</template>
<script setup lang="ts">
import { ref, defineAsyncComponent, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { ref, computed, defineAsyncComponent, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { channelApi, agentApi } from '@/api'
@ -163,6 +211,7 @@ const channelStatusMap = ref<Record<string | number, {
connectionState: string
lastError: string | null
reconnectAttempts: number
identity: Record<string, any>
}>>({})
let statusPollTimer: ReturnType<typeof setInterval> | null = null
@ -260,6 +309,7 @@ async function loadStatus() {
connectionState,
lastError: h.detail || null,
reconnectAttempts: 0,
identity: h.identity || {},
}
}
channelStatusMap.value = map
@ -268,6 +318,45 @@ async function loadStatus() {
}
}
// ==================== Stats / description ====================
// Top-of-page summary so the "is anything broken?" question is answerable
// without scanning every card. Disabled channels are surfaced too they're
// "my channels, paused", not noise.
const stats = computed(() => {
let active = 0, reconnecting = 0, errors = 0, disabled = 0
for (const ch of channels.value) {
if (!ch.enabled) { disabled += 1; continue }
const state = channelStatusMap.value[ch.id]?.connectionState
if (state === 'CONNECTED') active += 1
else if (state === 'RECONNECTING') reconnecting += 1
else if (state === 'ERROR') errors += 1
// OUT_OF_SERVICE / DISCONNECTED are transient startup states don't
// pollute the top bar with them; they show on the card itself.
}
return { active, reconnecting, errors, disabled }
})
/**
* Identity-driven description. Shows what THIS bot is, not what the
* channel type is. Falls back to channel.description (the type-level
* blurb seeded by DatabaseBootstrapRunner) for legacy rows that haven't
* been re-verified since RFC-084 landed.
*/
function getChannelDescription(channel: Channel): string {
const identity = channelStatusMap.value[channel.id]?.identity || {}
const accountName = identity.accountName as string | undefined
const team = identity.team as string | undefined
if (accountName && team) {
return t('channels.cardDesc.connectedAsIn', { account: accountName, team })
}
if (accountName) {
return t('channels.cardDesc.connectedAs', { account: accountName })
}
// Fall back to the seeded type-level description.
return channel.description || ''
}
// ==================== Connection state helpers ====================
function getConnectionState(channel: Channel): string {
@ -453,9 +542,30 @@ function getChannelIconPath(type: string) {
.card-btn:hover { background: var(--mc-bg-sunken); }
.card-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
.add-card { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 238px; border: 2px dashed var(--mc-border); cursor: pointer; background: transparent; }
.add-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.add-icon { font-size: 28px; color: var(--mc-text-tertiary); margin-bottom: 8px; }
.add-label { font-size: 14px; color: var(--mc-text-tertiary); }
.add-card:hover .add-icon, .add-card:hover .add-label { color: var(--mc-primary); }
/* Compact add-another tail card shown only when the user has at least
one channel. The 0-channel hero owns the primary CTA in that case. */
.add-card-compact { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 238px; padding: 20px; border: 2px dashed var(--mc-border); border-radius: 16px; cursor: pointer; background: transparent; transition: all 0.15s; font-family: inherit; }
.add-card-compact:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.add-icon-compact { font-size: 22px; color: var(--mc-text-tertiary); line-height: 1; }
.add-label-compact { font-size: 14px; color: var(--mc-text-tertiary); font-weight: 600; }
.add-card-compact:hover .add-icon-compact, .add-card-compact:hover .add-label-compact { color: var(--mc-primary); }
/* Empty hero — single CTA, no decorative grid of inactive types. */
.empty-hero { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 32px; text-align: center; gap: 14px; }
.empty-hero-icon { width: 84px; height: 84px; border-radius: 24px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, rgba(217,119,87,0.08), rgba(24,74,69,0.05)); color: var(--mc-primary); margin-bottom: 4px; }
.empty-hero-title { font-size: 22px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
.empty-hero-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; max-width: 480px; line-height: 1.6; }
.empty-hero-cta { margin-top: 6px; padding: 12px 24px; font-size: 15px; }
/* Stats bar — small one-liner summarizing the whole list. */
.stats-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 0 4px 4px; }
.stat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; color: var(--mc-text-tertiary); }
.stat.ok { color: var(--mc-text-primary); }
.stat.warn { color: var(--mc-primary-hover); }
.stat.danger { color: var(--mc-danger, #ef4444); }
.stat.muted { color: var(--mc-text-tertiary); }
.stat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.stat-dot.conn-connected { background: var(--mc-primary); }
.stat-dot.conn-reconnecting { background: var(--mc-primary-hover); animation: pulse-reconnecting 1.5s ease-in-out infinite; }
.stat-dot.conn-error { background: var(--mc-danger, #ef4444); }
</style>