diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 4fb5611..9113e8c 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -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: { diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 6982fbb..1d00783 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -1331,6 +1331,8 @@ const zhCN = { ai: { note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。", seedTopic: "扩展主题", + seedTopicPlaceholder: "输入主题后按回车,可添加多个", + seedTopicHint: "支持回车、逗号或分号分隔多个主题;系统会逐个扩展并自动合并去重。", generate: "AI 生成候选", waitingTitle: "AI 正在生成候选问题,请稍后…", waitingStages: { diff --git a/apps/admin-web/src/views/BrandQuestionCreateView.vue b/apps/admin-web/src/views/BrandQuestionCreateView.vue index 0a5cce1..7d39221 100644 --- a/apps/admin-web/src/views/BrandQuestionCreateView.vue +++ b/apps/admin-web/src/views/BrandQuestionCreateView.vue @@ -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() + 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() + 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>[], +) { + const seen = new Set() + 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 + const inputs = document.querySelectorAll( + `.word-column[data-col="${column}"] .word-column__input`, + ) as NodeListOf 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 + const inputs = document.querySelectorAll( + `.word-column[data-col="${column}"] .word-column__input`, + ) as NodeListOf const target = inputs[index - 1 >= 0 ? index - 1 : 0] if (target) { target.focus() @@ -552,7 +639,7 @@ async function generateCandidates(): Promise { 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 {
-
-
- -
-

{{ t('brands.questions.ai.generate') }}

-

{{ t('brands.questions.ai.note') }}

-
-
-
- + +

+ {{ t('brands.questions.ai.seedTopicHint') }} +

@@ -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)); diff --git a/apps/admin-web/src/views/MediaView.vue b/apps/admin-web/src/views/MediaView.vue index 4467900..9a69d3a 100644 --- a/apps/admin-web/src/views/MediaView.vue +++ b/apps/admin-web/src/views/MediaView.vue @@ -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 {
-

{{ account.displayName }}

-

{{ account.platformLabel }}

+

+ {{ account.displayName }} + + {{ account.platformShortName }} +

+

+ {{ account.platformLabel }} + | + UID: {{ account.platformUid }} +

- -
- - {{ account.platformShortName }} -
-
-
- 平台 UID - {{ account.platformUid }} -
-
- 授权状态 - - {{ healthLabel(account.health) }} +
+ + {{ healthLabel(account.health) }} + + + {{ publishStateLabel(account.publishState) }} + + + + {{ clientStatusText(account) }} -
-
+ +
+ +
+
最近校验 - - {{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }} - + {{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }}
-
- 客户端状态 - - - {{ clientStatusText(account) }} - - -
-
+
最近心跳 - - {{ account.clientLastSeenAt ? formatDateTime(account.clientLastSeenAt) : '--' }} - -
-
- 发布状态 - - {{ publishStateLabel(account.publishState) }} - + {{ account.clientLastSeenAt ? formatDateTime(account.clientLastSeenAt) : '--' }}
@@ -564,43 +557,47 @@ async function refreshAll(): Promise { } .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 { .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; diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 35b4d4f..090eeae 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -1061,8 +1061,8 @@ if (!hasSingleInstanceLock) { void Promise.race([ (async () => { - await clearRendererDesktopSessions() await releaseRuntimeSession() + await clearRendererDesktopSessions() })(), new Promise((resolve) => setTimeout(resolve, 1500)), ]).finally(() => { diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 2ba8312..7888173 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -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 diff --git a/server/internal/tenant/app/desktop_account_service.go b/server/internal/tenant/app/desktop_account_service.go index bbd2b2d..af5f037 100644 --- a/server/internal/tenant/app/desktop_account_service.go +++ b/server/internal/tenant/app/desktop_account_service.go @@ -656,8 +656,8 @@ func resolveDesktopClientOnline( if client == nil || client.RevokedAt != nil { return false } - if presenceKnown && presence { - return true + if presenceKnown { + return presence } if client.LastSeenAt == nil { return false diff --git a/server/internal/tenant/app/desktop_account_service_test.go b/server/internal/tenant/app/desktop_account_service_test.go index 8e13b31..2f178c4 100644 --- a/server/internal/tenant/app/desktop_account_service_test.go +++ b/server/internal/tenant/app/desktop_account_service_test.go @@ -21,15 +21,27 @@ func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) { assert.True(t, online) } -func TestResolveDesktopClientOnline_FallsBackToRecentHeartbeatWhenPresenceMisses(t *testing.T) { +func TestResolveDesktopClientOnline_PresenceMissWithKnownPresenceIsOffline(t *testing.T) { now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC) - lastSeen := now.Add(-30 * time.Second) + lastSeen := now.Add(-desktopClientPresenceTTL / 2) online := resolveDesktopClientOnline(&repository.DesktopClient{ ID: uuid.New(), LastSeenAt: &lastSeen, }, false, true, now) + assert.False(t, online) +} + +func TestResolveDesktopClientOnline_FallsBackToRecentHeartbeatWhenPresenceUnknown(t *testing.T) { + now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC) + lastSeen := now.Add(-desktopClientPresenceTTL / 2) + + online := resolveDesktopClientOnline(&repository.DesktopClient{ + ID: uuid.New(), + LastSeenAt: &lastSeen, + }, false, false, now) + assert.True(t, online) } diff --git a/server/internal/tenant/app/desktop_presence.go b/server/internal/tenant/app/desktop_presence.go index f5eb8c3..d71e816 100644 --- a/server/internal/tenant/app/desktop_presence.go +++ b/server/internal/tenant/app/desktop_presence.go @@ -10,10 +10,10 @@ import ( ) // desktopClientPresenceTTL is also used as the monitoring primary-client lease TTL. -// Desktop heartbeat interval must stay below TTL/2, so one missed heartbeat does -// not make another client eligible to steal a still-healthy primary lease. -const desktopClientPresenceTTL = 90 * time.Second -const desktopAccountPresenceTTL = 90 * time.Second +// Desktop heartbeat interval is TTL/2, so unexpected client exits are reflected +// as offline within one TTL even when the explicit offline request is missed. +const desktopClientPresenceTTL = 30 * time.Second +const desktopAccountPresenceTTL = 30 * time.Second func desktopClientPresenceKey(clientID uuid.UUID) string { return "desktop:presence:client:" + clientID.String() diff --git a/server/migrations/20260424130000_add_desktop_primary_leases.up.sql b/server/migrations/20260424130000_add_desktop_primary_leases.up.sql index 02d3c7d..777cd6d 100644 --- a/server/migrations/20260424130000_add_desktop_primary_leases.up.sql +++ b/server/migrations/20260424130000_add_desktop_primary_leases.up.sql @@ -1,7 +1,7 @@ --- Backfilled primary leases use the same 90s TTL as +-- Backfilled primary leases use the same 30s TTL as -- server/internal/tenant/app/desktop_presence.go:desktopClientPresenceTTL. --- Keep these values aligned; desktop heartbeat interval must be < TTL/2 so --- a single missed heartbeat does not let another client steal primary. +-- Keep these values aligned; desktop heartbeat interval is TTL/2 so unexpected +-- client exits are reflected within one TTL even if explicit offline is missed. CREATE TABLE IF NOT EXISTS desktop_client_primary_leases ( tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, @@ -33,7 +33,7 @@ SELECT tenant_id, workspace_id, id, - COALESCE(last_seen_at, created_at) + INTERVAL '90 seconds' + COALESCE(last_seen_at, created_at) + INTERVAL '30 seconds' FROM ( SELECT dc.id,