feat(channels): card description fallback + tighter card layout

This commit is contained in:
matevip 2026-05-02 07:39:33 +08:00
parent ba167e09f4
commit 8ead7b4254
4 changed files with 154 additions and 9 deletions

View File

@ -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') {

View File

@ -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.',

View File

@ -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: '选一个服务接入,配置项稍后还能改。',

View File

@ -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 6080px 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; }