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
@@ -1403,6 +1403,9 @@ const enUS = {
ai: {
note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.',
seedTopic: 'Seed topic',
seedTopicPlaceholder: 'Enter a topic and press Enter; multiple topics supported',
seedTopicHint:
'Use Enter, commas, or semicolons to add multiple topics. Candidates are merged and deduplicated automatically.',
generate: 'Generate with AI',
waitingTitle: 'AI is generating candidate questions. Please wait...',
waitingStages: {
@@ -1331,6 +1331,8 @@ const zhCN = {
ai: {
note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。",
seedTopic: "扩展主题",
seedTopicPlaceholder: "输入主题后按回车,可添加多个",
seedTopicHint: "支持回车、逗号或分号分隔多个主题;系统会逐个扩展并自动合并去重。",
generate: "AI 生成候选",
waitingTitle: "AI 正在生成候选问题,请稍后…",
waitingStages: {
@@ -2,7 +2,6 @@
import {
DeleteOutlined,
EditOutlined,
BulbOutlined,
LeftOutlined,
RobotOutlined,
SaveOutlined,
@@ -70,9 +69,11 @@ const expansionForm = reactive({
})
const aiForm = reactive({
seed_topic: '',
seed_topics: [] as string[],
})
const aiTopicTokenSeparators = [',', '', ';', '', '\n']
const batchForm = reactive({
text: '',
})
@@ -241,10 +242,17 @@ const previewMutations = {
},
}),
ai: useMutation({
mutationFn: () =>
brandsApi.distillQuestions(brandId.value, {
seed_topic: aiForm.seed_topic.trim(),
}),
mutationFn: async () => {
const topics = normalizedAiSeedTopics.value
const results = await Promise.all(
topics.map((topic) =>
brandsApi.distillQuestions(brandId.value, {
seed_topic: topic,
}),
),
)
return mergeQuestionCandidateResults(results)
},
onSuccess: (result) => {
candidateRows.value = toCandidateRows(result.candidates)
if (result.cache_hit) {
@@ -333,6 +341,81 @@ function splitLines(value: string | string[]): string[] {
.filter(Boolean)
}
function normalizedTopicParts(value: string): string[] {
return value
.split(/\r?\n|,||;|/)
.map((item) => item.trim())
.filter(Boolean)
}
const normalizedAiSeedTopics = computed(() => {
const seen = new Set<string>()
const topics: string[] = []
for (const rawTopic of aiForm.seed_topics) {
for (const topic of normalizedTopicParts(rawTopic)) {
const key = topic.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
topics.push(topic)
}
}
return topics
})
function updateAiSeedTopics(value: string[]): void {
aiForm.seed_topics.splice(0, aiForm.seed_topics.length, ...dedupeTopicParts(value))
}
function dedupeTopicParts(values: string[]): string[] {
const seen = new Set<string>()
const topics: string[] = []
for (const value of values) {
for (const topic of normalizedTopicParts(value)) {
const key = topic.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
topics.push(topic)
}
}
return topics
}
function mergeQuestionCandidateResults(
results: Awaited<ReturnType<typeof brandsApi.distillQuestions>>[],
) {
const seen = new Set<string>()
const candidates: QuestionCandidate[] = []
let aiPointsCharged = 0
let cacheHit = false
let truncated = false
for (const result of results) {
aiPointsCharged += result.ai_points_charged ?? 0
cacheHit = cacheHit || Boolean(result.cache_hit)
truncated = truncated || Boolean(result.truncated)
for (const candidate of result.candidates) {
const key = normalizeQuestionKey(candidate.text)
if (!key || seen.has(key)) {
continue
}
seen.add(key)
candidates.push(candidate)
}
}
return {
candidates,
ai_points_charged: aiPointsCharged || undefined,
cache_hit: cacheHit,
truncated,
}
}
type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix'
function handleInput(column: ColumnKey, index: number) {
@@ -356,7 +439,9 @@ function handleEnter(column: ColumnKey, index: number) {
arr.splice(index + 1, 0, '')
}
setTimeout(() => {
const inputs = document.querySelectorAll(`.word-column[data-col="${column}"] .word-column__input`) as NodeListOf<HTMLInputElement>
const inputs = document.querySelectorAll(
`.word-column[data-col="${column}"] .word-column__input`,
) as NodeListOf<HTMLInputElement>
if (inputs[index + 1]) {
inputs[index + 1].focus()
}
@@ -369,7 +454,9 @@ function handleBackspace(column: ColumnKey, index: number, event: KeyboardEvent)
event.preventDefault()
arr.splice(index, 1)
setTimeout(() => {
const inputs = document.querySelectorAll(`.word-column[data-col="${column}"] .word-column__input`) as NodeListOf<HTMLInputElement>
const inputs = document.querySelectorAll(
`.word-column[data-col="${column}"] .word-column__input`,
) as NodeListOf<HTMLInputElement>
const target = inputs[index - 1 >= 0 ? index - 1 : 0]
if (target) {
target.focus()
@@ -552,7 +639,7 @@ async function generateCandidates(): Promise<void> {
return
}
if (!aiForm.seed_topic.trim()) {
if (!normalizedAiSeedTopics.value.length) {
pageNotice.value = t('brands.questions.errors.emptyInput')
return
}
@@ -835,18 +922,20 @@ function backToBrands(): void {
</div>
<div v-else-if="createMode === 'ai'" class="wizard-card">
<div class="wizard-ai-banner">
<div class="wizard-ai-banner__info">
<RobotOutlined class="wizard-ai-banner__icon" />
<div class="wizard-ai-banner__text">
<h4>{{ t('brands.questions.ai.generate') }}</h4>
<p>{{ t('brands.questions.ai.note') }}</p>
</div>
</div>
</div>
<div class="field-item field-item--compact">
<label>{{ t('brands.questions.ai.seedTopic') }}</label>
<a-input v-model:value="aiForm.seed_topic" />
<a-select
class="seed-topic-select"
mode="tags"
:value="aiForm.seed_topics"
:placeholder="t('brands.questions.ai.seedTopicPlaceholder')"
:token-separators="aiTopicTokenSeparators"
:options="[]"
@change="(value: string[]) => updateAiSeedTopics(value)"
/>
<p class="field-item__hint">
{{ t('brands.questions.ai.seedTopicHint') }}
</p>
</div>
</div>
@@ -1167,6 +1256,39 @@ function backToBrands(): void {
content: '';
}
.field-item__hint {
margin: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.seed-topic-select {
width: 100%;
}
.seed-topic-select :deep(.ant-select-selector) {
min-height: 52px;
padding: 8px 12px;
border-color: #d9d9d9 !important;
border-radius: 8px;
}
.seed-topic-select :deep(.ant-select-selection-overflow) {
gap: 6px;
}
.seed-topic-select :deep(.ant-select-selection-item) {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 28px;
color: #1e293b;
background: #f1f5f9;
border-color: #dbe3ee;
border-radius: 6px;
}
.tool-header-actions {
display: flex;
flex-wrap: wrap;
@@ -1359,41 +1481,6 @@ function backToBrands(): void {
color: #ef4444;
}
.wizard-ai-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, rgba(22, 119, 255, 0.04), rgba(22, 119, 255, 0.08));
border: 1px solid rgba(22, 119, 255, 0.15);
border-radius: 16px;
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.05);
}
.wizard-ai-banner__info {
display: flex;
align-items: center;
gap: 16px;
}
.wizard-ai-banner__icon {
font-size: 28px;
color: #1677ff;
}
.wizard-ai-banner__text h4 {
margin: 0 0 4px;
color: #1f2f4d;
font-size: 15px;
font-weight: 700;
}
.wizard-ai-banner__text p {
margin: 0;
color: #6b7a99;
font-size: 13px;
}
.candidate-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+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;
+1 -1
View File
@@ -1061,8 +1061,8 @@ if (!hasSingleInstanceLock) {
void Promise.race([
(async () => {
await clearRendererDesktopSessions()
await releaseRuntimeSession()
await clearRendererDesktopSessions()
})(),
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
]).finally(() => {
@@ -145,7 +145,7 @@ type MonitorExecutionPhase =
| 'PARSING'
| 'POSTING'
const heartbeatIntervalMs = 25_000
const heartbeatIntervalMs = 15_000
const pullIntervalMs = 60_000
const leaseExtendIntervalMs = 60_000
const maxActivityItems = 60