mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(channels): redesign list page — show only configured channels, add hero empty state
This commit is contained in:
parent
a11f0586ba
commit
3db4230142
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '新建渠道',
|
||||
|
||||
@ -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
|
||||
// 前端扩展字段
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user