diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index d419ae77..26d17cbc 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -670,6 +670,59 @@ function initForCreate() { configTab.value = 'form' showAdvanced.value = false initDefaultFieldValues() + // Pre-fill description with the i18n type-level fallback so the new + // channel card never lands on the list page with an empty middle area. + // The user can keep, edit, or clear it before saving. + applyTypeDescriptionFallback(true) +} + +/** + * Set {@code form.description} to the type-level i18n fallback when + * appropriate. Called on mount and whenever {@code channelType} changes + * (create flow only — never overrides an editing row). + * + * @param force when {@code true}, overwrite an empty / auto-filled + * description regardless. We treat ANY description that + * matches one of the known type fallbacks as "auto", so + * switching from dingtalk → wecom in the wizard updates + * the placeholder cleanly. A description the user typed + * themselves never matches and is preserved. + */ +function applyTypeDescriptionFallback(force = false) { + if (props.editingChannel) return // never touch user data on edit + const type = form.value.channelType + if (!type) return + const candidate = t(`channels.cardDesc.typeFallback.${type}` as any) + // vue-i18n returns the key itself when missing → leave description alone. + const haveTranslation = candidate && candidate !== `channels.cardDesc.typeFallback.${type}` + if (!haveTranslation) return + const current = (form.value.description || '').trim() + if (force && !current) { + form.value.description = candidate + return + } + // Switching channel types — update only if the description is still one + // of the auto-filled fallbacks (user hasn't typed their own). + if (current && isAutoTypeFallback(current)) { + form.value.description = candidate + } else if (!current) { + form.value.description = candidate + } +} + +/** True when the given string equals any of the known channel-type + * fallback i18n strings — used to detect "user hasn't customized this". */ +function isAutoTypeFallback(text: string): boolean { + const trimmed = text.trim() + // Iterate the known channel types we ship i18n for. List mirrors + // CHANNEL_TYPE_OPTIONS but we keep a local copy to avoid coupling. + const known = ['web', 'dingtalk', 'wecom', 'weixin', 'feishu', 'telegram', + 'slack', 'discord', 'qq', 'matrix', 'qqbot', 'yuanbao'] + for (const k of known) { + const s = t(`channels.cardDesc.typeFallback.${k}` as any) + if (s && s === trimmed) return true + } + return false } function initDefaultFieldValues() { @@ -693,6 +746,8 @@ function onChannelTypeChange() { visibleFields.value = {} weixin.reset() initDefaultFieldValues() + // Refresh the auto-filled description if the user hasn't customized it. + applyTypeDescriptionFallback(false) } function switchTab(tab: 'form' | 'json') { diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 344802fe..925e8742 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1731,6 +1731,24 @@ export default { cardDesc: { connectedAs: 'Connected as {account}', connectedAsIn: 'Connected as {account} in {team}', + // Per-type fallback used when the row has no user-provided description + // and the adapter hasn't written identity_json yet. Keeps every card + // visually populated instead of leaving a blank middle area. + typeFallback: { + web: 'Default web console channel, streaming via browser SSE.', + dingtalk: 'DingTalk bot channel, receiving messages via Outgoing Webhook / Stream.', + wecom: 'WeCom (Enterprise WeChat) app channel for member messaging.', + weixin: 'WeChat Official Account channel — handles user messages and event callbacks.', + feishu: 'Feishu / Lark channel via event subscriptions.', + telegram: 'Telegram bot channel via long polling.', + slack: 'Slack bot channel via the Events API.', + discord: 'Discord bot channel via the Gateway connection.', + qq: 'QQ / QQ Channel bot.', + matrix: 'Matrix room channel via the client-server API.', + qqbot: 'QQ Channel bot (official API).', + yuanbao: 'Tencent Yuanbao channel.', + }, + empty: 'Click Configure to add a description for this channel.', }, catalog: { subtitle: 'Pick a service to connect. You can change settings later.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index c6d74804..64a77213 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1743,6 +1743,24 @@ export default { cardDesc: { connectedAs: '已连接:{account}', connectedAsIn: '已连接:{account}({team})', + // Per-type fallback used when the row has no user-provided description + // and the adapter hasn't written identity_json yet. Keeps every card + // visually populated instead of leaving a blank middle area. + typeFallback: { + web: '默认 Web 控制台渠道,通过浏览器 SSE 流式交互', + dingtalk: '钉钉机器人渠道,通过 Outgoing Webhook / Stream 接收消息', + wecom: '企业微信应用渠道,接收并回复成员消息', + weixin: '微信公众号渠道,处理用户消息与事件回调', + feishu: '飞书 / Lark 渠道,通过事件订阅接收消息', + telegram: 'Telegram 机器人渠道,通过 long polling 接收', + slack: 'Slack Bot 渠道,通过 Events API 接收', + discord: 'Discord 机器人渠道,通过 Gateway 长连接', + qq: 'QQ / QQ 频道机器人渠道', + matrix: 'Matrix 房间渠道,通过 client-server API', + qqbot: 'QQ 频道机器人渠道(官方 API)', + yuanbao: '腾讯元宝渠道', + }, + empty: '点「配置」可补充该渠道的描述', }, catalog: { subtitle: '选一个服务接入,配置项稍后还能改。', diff --git a/mateclaw-ui/src/views/Channels.vue b/mateclaw-ui/src/views/Channels.vue index be0cba9f..07ad059f 100644 --- a/mateclaw-ui/src/views/Channels.vue +++ b/mateclaw-ui/src/views/Channels.vue @@ -340,9 +340,21 @@ const stats = computed(() => { /** * 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. + * channel type is. Three-stage fallback so the middle of the card is + * never visually empty: + * + * 1. {@code identity.accountName} (+ optional team) — set by the + * adapter once the channel is bound; produces "Connected as @bot + * in TeamX". Most accurate / current. + * 2. {@code channel.description} — user-edited blurb from the modal, + * or the seeded type-level description for the default Web row. + * 3. {@code channels.cardDesc.typeFallback[channelType]} — i18n + * static string per channel type (dingtalk / wecom / …) so even + * a brand-new user-created row has a sensible one-liner. + * + * If all three miss (unknown channelType, no description, no identity) + * we surface a "click Configure to add a description" hint so the + * empty space remains explainable. */ function getChannelDescription(channel: Channel): string { const identity = channelStatusMap.value[channel.id]?.identity || {} @@ -354,8 +366,27 @@ function getChannelDescription(channel: Channel): string { if (accountName) { return t('channels.cardDesc.connectedAs', { account: accountName }) } - // Fall back to the seeded type-level description. - return channel.description || '' + if (channel.description && channel.description.trim()) { + return channel.description + } + return getTypeFallbackDescription(channel.channelType) +} + +/** + * i18n type-level fallback for channels with no description and no + * identity yet. Returns the empty-state hint when the channelType + * isn't recognised in the i18n table (forward-compat for new types). + */ +function getTypeFallbackDescription(channelType?: string | null): string { + if (!channelType) return t('channels.cardDesc.empty') + const key = `channels.cardDesc.typeFallback.${channelType}` + const translated = t(key) + // vue-i18n returns the key itself when there's no translation; + // detect that and fall back to the generic empty-state copy. + if (!translated || translated === key) { + return t('channels.cardDesc.empty') + } + return translated } // ==================== Connection state helpers ==================== @@ -516,7 +547,10 @@ function getChannelIconPath(type: string) { /* Channel cards */ .channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; } -.channel-card { padding: 20px; transition: all 0.15s; min-height: 238px; display: flex; flex-direction: column; } +/* Card sized to content — header + 2-line desc + footer ≈ 160px. Grid + `align-items: stretch` keeps a row's cards visually aligned without + forcing the 80px of empty space the old 238px floor produced. */ +.channel-card { padding: 16px 18px 14px; transition: all 0.15s; display: flex; flex-direction: column; } .channel-card-skeleton { pointer-events: none; opacity: 0.85; } .channel-card:hover { border-color: var(--mc-primary-light); box-shadow: var(--mc-shadow-medium); transform: translateY(-2px); } .channel-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; } @@ -538,15 +572,35 @@ function getChannelIconPath(type: string) { .conn-disconnected { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); } @keyframes pulse-reconnecting { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } -.channel-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 14px; line-height: 1.6; min-height: 42px; } -.channel-footer { display: flex; gap: 6px; border-top: 1px solid var(--mc-border-light); padding-top: 12px; margin-top: auto; flex-wrap: wrap; } +/* 2-line clamp with ellipsis. The fixed line count keeps a row's + cards visually balanced while ensuring a long type-fallback or a + user blurb never balloons the card. -webkit-line-clamp + -webkit-box + is the standard cross-browser combo (works in Chromium, Safari, + Firefox 68+). */ +.channel-desc { + font-size: 13px; + color: var(--mc-text-secondary); + margin: 0 0 12px; + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} +/* Footer hugs the description directly — no `margin-top: auto`, so the + card collapses to its content height. The divider above the buttons + still gives visual separation without the old 60–80px gap. */ +.channel-footer { display: flex; gap: 6px; border-top: 1px solid var(--mc-border-light); padding-top: 10px; flex-wrap: wrap; } .card-btn { display: flex; align-items: center; gap: 4px; padding: 7px 11px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 10px; font-size: 12px; color: var(--mc-text-primary); cursor: pointer; transition: all 0.15s; font-weight: 600; } .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); } /* 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; } +/* Match the new card-content height so the dashed "+ Add" tile aligns + in the grid row rather than towering over real channel cards. */ +.add-card-compact { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 158px; padding: 16px; 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; }