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("name", c.getName());
body.put("enabled", Boolean.TRUE.equals(c.getEnabled())); body.put("enabled", Boolean.TRUE.equals(c.getEnabled()));
body.put("identity", parseIdentity(c.getIdentityJson()));
return body; return body;
}) })
.toList()); .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") @RequireWorkspaceRole("admin")
@Operation(summary = "Pre-flight: validate draft channel config without persisting") @Operation(summary = "Pre-flight: validate draft channel config without persisting")
@PostMapping("/preflight") @PostMapping("/preflight")

View File

@ -34,6 +34,16 @@ public class ChannelEntity {
@TableField(value = "config_json", updateStrategy = FieldStrategy.ALWAYS) @TableField(value = "config_json", updateStrategy = FieldStrategy.ALWAYS)
private String configJson; 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; 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(), accessControl: defaultAccessControl(),
renderConfig: defaultRenderConfig(), 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) const res: any = await channelApi.create(payload)
ElMessage.success(t('channels.messages.saveSuccess')) ElMessage.success(t('channels.messages.saveSuccess'))
emit('created', res.data as Channel) emit('created', res.data as Channel)

View File

@ -2,23 +2,38 @@
<div v-if="modelValue" class="modal-overlay" @click.self="close"> <div v-if="modelValue" class="modal-overlay" @click.self="close">
<div class="picker"> <div class="picker">
<div class="picker-header"> <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')"> <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"> <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"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
</div> </div>
<div class="picker-grid">
<button <!-- Catalog grouped by category. The catalog only renders here in
v-for="type in types" the picker never on the main list page so users browse the
:key="type" full surface area exactly when they're trying to add something. -->
class="picker-card" <div class="picker-scroll">
@click="pick(type)" <section v-for="group in groups" :key="group.key" class="picker-section">
> <h3 class="picker-section-title">{{ t(`channels.catalog.groups.${group.key}`) }}</h3>
<img :src="`/icons/channels/${type}.svg`" :alt="type" class="picker-icon" /> <div class="picker-grid">
<span class="picker-name">{{ t(`channels.types.${type}`) }}</span> <button
</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> </div>
</div> </div>
@ -36,13 +51,13 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
// Order matches the dropdown in ChannelEditModal so users see the same // Three categories so the catalog reads as a curated shelf, not a flat
// ordering. Web/WebChat/Webhook last because they're "platform" rather // dump. Order within each group is from "most common" to "edge" so first
// than "messaging service". // glance lands on the right thing.
const types = [ const groups = [
'telegram', 'discord', 'slack', { key: 'im', types: ['telegram', 'discord', 'slack', 'qq'] },
'dingtalk', 'feishu', 'wecom', 'weixin', 'qq', { key: 'enterprise', types: ['wecom', 'weixin', 'feishu', 'dingtalk'] },
'web', 'webchat', 'webhook', { key: 'web', types: ['web', 'webchat', 'webhook'] },
] ]
function pick(type: string) { function pick(type: string) {
@ -56,14 +71,23 @@ function close() {
<style scoped> <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; } .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 { 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: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); } .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: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0; } .picker-title { font-size: 19px; 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-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-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-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-icon { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
.picker-name { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); text-align: center; } .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> </style>

View File

@ -1537,6 +1537,42 @@ export default {
configure: 'Configure', configure: 'Configure',
enable: 'Enable', enable: 'Enable',
disable: 'Disable', 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: { modal: {
editTitle: 'Edit Channel', editTitle: 'Edit Channel',
newTitle: 'New Channel', newTitle: 'New Channel',

View File

@ -1547,6 +1547,42 @@ export default {
configure: '配置', configure: '配置',
enable: '启用', enable: '启用',
disable: '停用', 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: { modal: {
editTitle: '编辑渠道', editTitle: '编辑渠道',
newTitle: '新建渠道', newTitle: '新建渠道',

View File

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

View File

@ -8,7 +8,7 @@
<h1 class="mc-page-title">{{ t('channels.title') }}</h1> <h1 class="mc-page-title">{{ t('channels.title') }}</h1>
<p class="mc-page-desc">{{ t('channels.desc') }}</p> <p class="mc-page-desc">{{ t('channels.desc') }}</p>
</div> </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"> <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"/> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
@ -16,71 +16,119 @@
</button> </button>
</div> </div>
<!-- 加载中骨架 --> <!-- Loading skeleton (initial fetch) -->
<div v-if="isInitialLoading" class="channel-grid"> <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 /> <el-skeleton :rows="3" animated />
</div> </div>
</div> </div>
<!-- 渠道卡片 --> <!-- 0-channel hero empty state.
<div v-else class="channel-grid"> Replaces the 11-card "catalog soup" with a single CTA. The
<div v-for="channel in channels" :key="channel.id" class="channel-card mc-surface-card"> type catalog moves into ChannelTypePicker (one click away),
<div class="channel-header"> so the default view is silent until the user has actually
<div class="channel-icon-wrap"> configured something. -->
<img class="channel-icon-img" :src="getChannelIconPath(channel.channelType)" :alt="channel.channelType" /> <div v-else-if="channels.length === 0" class="empty-hero mc-surface-card">
</div> <div class="empty-hero-icon">
<div class="channel-meta"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<h3 class="channel-name">{{ channel.name }}</h3> <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"/>
<span class="channel-type">{{ channel.channelType }}</span> </svg>
</div> </div>
<div class="channel-status-group"> <h2 class="empty-hero-title">{{ t('channels.empty.title') }}</h2>
<div class="channel-status" :class="channel.enabled ? 'status-on' : 'status-off'"> <p class="empty-hero-desc">{{ t('channels.empty.desc') }}</p>
{{ channel.enabled ? t('channels.status.active') : t('channels.status.inactive') }} <button class="btn-primary empty-hero-cta" @click="openCreateModal">
</div> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
v-if="channel.enabled" </svg>
class="connection-indicator" {{ t('channels.empty.cta') }}
:class="getConnectionClass(channel)" </button>
:title="getConnectionTooltip(channel)" </div>
>
{{ getConnectionIcon(channel) }} {{ getConnectionLabel(channel) }} <!-- Configured-channels view: stats + cards -->
</div> <template v-else>
</div> <!-- Stats bar small one-liner so "is anything broken?" is
</div> answerable at a glance without scanning every card. -->
<p class="channel-desc">{{ channel.description }}</p> <div class="stats-bar">
<div class="channel-footer"> <span class="stat" :class="{ ok: stats.active > 0 }">
<button class="card-btn" @click="openEditModal(channel)"> <span class="stat-dot conn-connected"></span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> {{ t('channels.stats.active', { n: stats.active }) }}
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> </span>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> <span v-if="stats.reconnecting > 0" class="stat warn">
</svg> <span class="stat-dot conn-reconnecting"></span>
{{ t('channels.configure') }} {{ t('channels.stats.reconnecting', { n: stats.reconnecting }) }}
</button> </span>
<button class="card-btn" @click="toggleChannel(channel)"> <span v-if="stats.errors > 0" class="stat danger">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <span class="stat-dot conn-error"></span>
<circle cx="12" cy="12" r="10"/> {{ t('channels.stats.errors', { n: stats.errors }) }}
<line v-if="channel.enabled" x1="8" y1="12" x2="16" y2="12"/> </span>
<polyline v-else points="10 8 16 12 10 16"/> <span v-if="stats.disabled > 0" class="stat muted">
</svg> {{ t('channels.stats.disabled', { n: stats.disabled }) }}
{{ channel.enabled ? t('channels.disable') : t('channels.enable') }} </span>
</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> </div>
<!-- 添加渠道卡片 --> <div class="channel-grid">
<div class="channel-card add-card mc-surface-card" @click="openCreateModal"> <div v-for="channel in channels" :key="channel.id" class="channel-card mc-surface-card">
<div class="add-icon">+</div> <div class="channel-header">
<p class="add-label">{{ t('channels.addChannel') }}</p> <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>
</div> </template>
</div> </div>
</div> </div>
@ -120,7 +168,7 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { channelApi, agentApi } from '@/api' import { channelApi, agentApi } from '@/api'
@ -163,6 +211,7 @@ const channelStatusMap = ref<Record<string | number, {
connectionState: string connectionState: string
lastError: string | null lastError: string | null
reconnectAttempts: number reconnectAttempts: number
identity: Record<string, any>
}>>({}) }>>({})
let statusPollTimer: ReturnType<typeof setInterval> | null = null let statusPollTimer: ReturnType<typeof setInterval> | null = null
@ -260,6 +309,7 @@ async function loadStatus() {
connectionState, connectionState,
lastError: h.detail || null, lastError: h.detail || null,
reconnectAttempts: 0, reconnectAttempts: 0,
identity: h.identity || {},
} }
} }
channelStatusMap.value = map 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 ==================== // ==================== Connection state helpers ====================
function getConnectionState(channel: Channel): string { function getConnectionState(channel: Channel): string {
@ -453,9 +542,30 @@ function getChannelIconPath(type: string) {
.card-btn:hover { background: var(--mc-bg-sunken); } .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); } .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; } /* Compact add-another tail card shown only when the user has at least
.add-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); } one channel. The 0-channel hero owns the primary CTA in that case. */
.add-icon { font-size: 28px; color: var(--mc-text-tertiary); margin-bottom: 8px; } .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-label { font-size: 14px; color: var(--mc-text-tertiary); } .add-card-compact:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.add-card:hover .add-icon, .add-card:hover .add-label { color: var(--mc-primary); } .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> </style>