feat: tighten desktop presence loop and refine admin-web UX
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m46s
Backend CI / Backend (push) Failing after 7m52s
Desktop Client Build / Build Desktop Client (push) Successful in 27m13s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 59s

- Shrink desktop presence TTL to 30s and heartbeat to 15s so unexpected
  client exits surface within one TTL even if the explicit offline call
  is missed; keep migration default aligned.
- Trust a known presence miss in resolveDesktopClientOnline instead of
  falling back to the recent-heartbeat heuristic, so a still-cached
  LastSeenAt cannot mask an offline client.
- Release the runtime session before clearing renderer desktop sessions
  on shutdown to avoid leaving stale leases behind.
- Auto-refresh the MediaView account list every 5s and revamp card
  layout (inline platform badge, status tags, UID row, meta box).
- Let the brand-question AI wizard accept multiple seed topics via a
  tag-style input; candidates from each topic are merged and deduped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 19:36:24 +08:00
parent 18cdbf25f8
commit 0823e47d46
10 changed files with 285 additions and 159 deletions
+113 -91
View File
@@ -67,6 +67,9 @@ const platformsQuery = useQuery({
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'media-view'],
queryFn: () => tenantAccountsApi.list(),
staleTime: 0,
refetchInterval: 5_000,
refetchOnWindowFocus: true,
})
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
@@ -374,57 +377,47 @@ async function refreshAll(): Promise<void> {
</span>
<div class="media-card__identity-copy">
<h3>{{ account.displayName }}</h3>
<p>{{ account.platformLabel }}</p>
<h3>
{{ account.displayName }}
<img
v-if="account.platformLogoUrl"
:src="account.platformLogoUrl"
:alt="account.platformLabel"
class="media-card__inline-platform-icon"
/>
<span v-else class="media-card__inline-platform-text" :style="{ color: account.platformAccent }">{{ account.platformShortName }}</span>
</h3>
<p>
<span class="media-card__platform-name">{{ account.platformLabel }}</span>
<span class="media-card__uid-divider">|</span>
<span class="media-card__uid">UID: {{ account.platformUid }}</span>
</p>
</div>
</div>
<div class="media-card__platform-badge" :style="{ color: account.platformAccent }">
<img
v-if="account.platformLogoUrl"
:src="account.platformLogoUrl"
:alt="account.platformLabel"
/>
<span v-else>{{ account.platformShortName }}</span>
</div>
</div>
<div class="media-card__meta">
<div class="meta-row">
<span class="meta-label">平台 UID</span>
<span class="meta-value">{{ account.platformUid }}</span>
</div>
<div class="meta-row">
<span class="meta-label">授权状态</span>
<a-tag :color="healthColor(account.health)">
{{ healthLabel(account.health) }}
<div class="media-card__tags">
<a-tag :color="healthColor(account.health)">
{{ healthLabel(account.health) }}
</a-tag>
<a-tag :color="publishStateColor(account.publishState)">
{{ publishStateLabel(account.publishState) }}
</a-tag>
<a-tooltip :title="clientStatusText(account)" placement="top">
<a-tag :color="clientStatusColor(account)" class="client-status-tag">
{{ clientStatusText(account) }}
</a-tag>
</div>
<div class="meta-row">
</a-tooltip>
</div>
<div class="media-card__meta-box">
<div class="meta-box-item">
<span class="meta-label">最近校验</span>
<span class="meta-value">
{{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }}
</span>
<span class="meta-value">{{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">客户端状态</span>
<a-tooltip :title="clientStatusText(account)" placement="topLeft">
<a-tag :color="clientStatusColor(account)" class="client-status-tag">
{{ clientStatusText(account) }}
</a-tag>
</a-tooltip>
</div>
<div class="meta-row">
<div class="meta-box-item">
<span class="meta-label">最近心跳</span>
<span class="meta-value">
{{ account.clientLastSeenAt ? formatDateTime(account.clientLastSeenAt) : '--' }}
</span>
</div>
<div class="meta-row">
<span class="meta-label">发布状态</span>
<a-tag :color="publishStateColor(account.publishState)">
{{ publishStateLabel(account.publishState) }}
</a-tag>
<span class="meta-value">{{ account.clientLastSeenAt ? formatDateTime(account.clientLastSeenAt) : '--' }}</span>
</div>
</div>
@@ -564,43 +557,47 @@ async function refreshAll(): Promise<void> {
}
.media-card {
border: 1px solid #e6edf5;
border-radius: 16px;
padding: 18px;
background:
radial-gradient(circle at top right, rgba(31, 92, 255, 0.06), transparent 28%),
linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 20px;
background: #ffffff;
display: flex;
flex-direction: column;
gap: 18px;
gap: 16px;
transition: all 0.3s ease;
}
.media-card:hover {
box-shadow: 0 12px 28px -6px rgba(0, 0, 0, 0.05);
border-color: transparent;
transform: translateY(-2px);
}
.media-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.media-card__identity {
display: flex;
align-items: center;
gap: 12px;
gap: 14px;
min-width: 0;
}
.media-card__avatar {
width: 44px;
height: 44px;
border-radius: 14px;
width: 48px;
height: 48px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: 700;
font-weight: 600;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.media-card__avatar img {
@@ -615,68 +612,93 @@ async function refreshAll(): Promise<void> {
.media-card__identity-copy h3 {
margin: 0;
color: #1a1a1a;
font-size: 15px;
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
display: flex;
align-items: center;
gap: 6px;
}
.media-card__inline-platform-icon {
width: 16px;
height: 16px;
object-fit: contain;
}
.media-card__inline-platform-text {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
background: #f8fafc;
padding: 2px 6px;
border-radius: 4px;
}
.media-card__identity-copy p {
margin: 4px 0 0;
color: #8c8c8c;
color: #64748b;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.media-card__platform-name {
font-weight: 500;
}
.media-card__uid-divider {
color: #cbd5e1;
}
.media-card__uid {
color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.media-card__platform-badge {
width: 36px;
height: 36px;
border-radius: 12px;
background: #f7f9fc;
border: 1px solid #e6edf5;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
.media-card__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.media-card__platform-badge img {
width: 20px;
height: 20px;
object-fit: contain;
.media-card__tags :deep(.ant-tag) {
margin: 0;
border: none;
border-radius: 6px;
font-weight: 500;
padding: 2px 8px;
}
.media-card__meta {
.media-card__meta-box {
background: #f8fafc;
border-radius: 8px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.meta-row {
.meta-box-item {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
font-size: 12px;
}
.meta-label {
color: #8c8c8c;
font-size: 12px;
flex-shrink: 0;
color: #64748b;
}
.meta-value {
color: #1a1a1a;
font-size: 13px;
color: #334155;
font-weight: 500;
text-align: right;
word-break: break-all;
}
.client-status-tag {
max-width: 160px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;