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
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:
@@ -1403,6 +1403,9 @@ const enUS = {
|
|||||||
ai: {
|
ai: {
|
||||||
note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.',
|
note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.',
|
||||||
seedTopic: 'Seed topic',
|
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',
|
generate: 'Generate with AI',
|
||||||
waitingTitle: 'AI is generating candidate questions. Please wait...',
|
waitingTitle: 'AI is generating candidate questions. Please wait...',
|
||||||
waitingStages: {
|
waitingStages: {
|
||||||
|
|||||||
@@ -1331,6 +1331,8 @@ const zhCN = {
|
|||||||
ai: {
|
ai: {
|
||||||
note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。",
|
note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。",
|
||||||
seedTopic: "扩展主题",
|
seedTopic: "扩展主题",
|
||||||
|
seedTopicPlaceholder: "输入主题后按回车,可添加多个",
|
||||||
|
seedTopicHint: "支持回车、逗号或分号分隔多个主题;系统会逐个扩展并自动合并去重。",
|
||||||
generate: "AI 生成候选",
|
generate: "AI 生成候选",
|
||||||
waitingTitle: "AI 正在生成候选问题,请稍后…",
|
waitingTitle: "AI 正在生成候选问题,请稍后…",
|
||||||
waitingStages: {
|
waitingStages: {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
BulbOutlined,
|
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
@@ -70,9 +69,11 @@ const expansionForm = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const aiForm = reactive({
|
const aiForm = reactive({
|
||||||
seed_topic: '',
|
seed_topics: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const aiTopicTokenSeparators = [',', ',', ';', ';', '\n']
|
||||||
|
|
||||||
const batchForm = reactive({
|
const batchForm = reactive({
|
||||||
text: '',
|
text: '',
|
||||||
})
|
})
|
||||||
@@ -241,10 +242,17 @@ const previewMutations = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ai: useMutation({
|
ai: useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: async () => {
|
||||||
brandsApi.distillQuestions(brandId.value, {
|
const topics = normalizedAiSeedTopics.value
|
||||||
seed_topic: aiForm.seed_topic.trim(),
|
const results = await Promise.all(
|
||||||
}),
|
topics.map((topic) =>
|
||||||
|
brandsApi.distillQuestions(brandId.value, {
|
||||||
|
seed_topic: topic,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return mergeQuestionCandidateResults(results)
|
||||||
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
candidateRows.value = toCandidateRows(result.candidates)
|
candidateRows.value = toCandidateRows(result.candidates)
|
||||||
if (result.cache_hit) {
|
if (result.cache_hit) {
|
||||||
@@ -333,6 +341,81 @@ function splitLines(value: string | string[]): string[] {
|
|||||||
.filter(Boolean)
|
.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'
|
type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix'
|
||||||
|
|
||||||
function handleInput(column: ColumnKey, index: number) {
|
function handleInput(column: ColumnKey, index: number) {
|
||||||
@@ -356,7 +439,9 @@ function handleEnter(column: ColumnKey, index: number) {
|
|||||||
arr.splice(index + 1, 0, '')
|
arr.splice(index + 1, 0, '')
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
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]) {
|
if (inputs[index + 1]) {
|
||||||
inputs[index + 1].focus()
|
inputs[index + 1].focus()
|
||||||
}
|
}
|
||||||
@@ -369,7 +454,9 @@ function handleBackspace(column: ColumnKey, index: number, event: KeyboardEvent)
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
arr.splice(index, 1)
|
arr.splice(index, 1)
|
||||||
setTimeout(() => {
|
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]
|
const target = inputs[index - 1 >= 0 ? index - 1 : 0]
|
||||||
if (target) {
|
if (target) {
|
||||||
target.focus()
|
target.focus()
|
||||||
@@ -552,7 +639,7 @@ async function generateCandidates(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!aiForm.seed_topic.trim()) {
|
if (!normalizedAiSeedTopics.value.length) {
|
||||||
pageNotice.value = t('brands.questions.errors.emptyInput')
|
pageNotice.value = t('brands.questions.errors.emptyInput')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -835,18 +922,20 @@ function backToBrands(): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="createMode === 'ai'" class="wizard-card">
|
<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">
|
<div class="field-item field-item--compact">
|
||||||
<label>{{ t('brands.questions.ai.seedTopic') }}</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1167,6 +1256,39 @@ function backToBrands(): void {
|
|||||||
content: ':';
|
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 {
|
.tool-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1359,41 +1481,6 @@ function backToBrands(): void {
|
|||||||
color: #ef4444;
|
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 {
|
.candidate-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ const platformsQuery = useQuery({
|
|||||||
const accountsQuery = useQuery({
|
const accountsQuery = useQuery({
|
||||||
queryKey: ['tenant', 'desktop-accounts', 'media-view'],
|
queryKey: ['tenant', 'desktop-accounts', 'media-view'],
|
||||||
queryFn: () => tenantAccountsApi.list(),
|
queryFn: () => tenantAccountsApi.list(),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
|
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
|
||||||
@@ -374,57 +377,47 @@ async function refreshAll(): Promise<void> {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="media-card__identity-copy">
|
<div class="media-card__identity-copy">
|
||||||
<h3>{{ account.displayName }}</h3>
|
<h3>
|
||||||
<p>{{ account.platformLabel }}</p>
|
{{ 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>
|
</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>
|
||||||
|
|
||||||
<div class="media-card__meta">
|
<div class="media-card__tags">
|
||||||
<div class="meta-row">
|
<a-tag :color="healthColor(account.health)">
|
||||||
<span class="meta-label">平台 UID</span>
|
{{ healthLabel(account.health) }}
|
||||||
<span class="meta-value">{{ account.platformUid }}</span>
|
</a-tag>
|
||||||
</div>
|
<a-tag :color="publishStateColor(account.publishState)">
|
||||||
<div class="meta-row">
|
{{ publishStateLabel(account.publishState) }}
|
||||||
<span class="meta-label">授权状态</span>
|
</a-tag>
|
||||||
<a-tag :color="healthColor(account.health)">
|
<a-tooltip :title="clientStatusText(account)" placement="top">
|
||||||
{{ healthLabel(account.health) }}
|
<a-tag :color="clientStatusColor(account)" class="client-status-tag">
|
||||||
|
{{ clientStatusText(account) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</div>
|
</a-tooltip>
|
||||||
<div class="meta-row">
|
</div>
|
||||||
|
|
||||||
|
<div class="media-card__meta-box">
|
||||||
|
<div class="meta-box-item">
|
||||||
<span class="meta-label">最近校验</span>
|
<span class="meta-label">最近校验</span>
|
||||||
<span class="meta-value">
|
<span class="meta-value">{{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }}</span>
|
||||||
{{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-box-item">
|
||||||
<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">
|
|
||||||
<span class="meta-label">最近心跳</span>
|
<span class="meta-label">最近心跳</span>
|
||||||
<span class="meta-value">
|
<span class="meta-value">{{ account.clientLastSeenAt ? formatDateTime(account.clientLastSeenAt) : '--' }}</span>
|
||||||
{{ 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -564,43 +557,47 @@ async function refreshAll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-card {
|
.media-card {
|
||||||
border: 1px solid #e6edf5;
|
border: 1px solid #f0f0f0;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
padding: 18px;
|
padding: 20px;
|
||||||
background:
|
background: #ffffff;
|
||||||
radial-gradient(circle at top right, rgba(31, 92, 255, 0.06), transparent 28%),
|
|
||||||
linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.media-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__identity {
|
.media-card__identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__avatar {
|
.media-card__avatar {
|
||||||
width: 44px;
|
width: 48px;
|
||||||
height: 44px;
|
height: 48px;
|
||||||
border-radius: 14px;
|
border-radius: 50%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__avatar img {
|
.media-card__avatar img {
|
||||||
@@ -615,68 +612,93 @@ async function refreshAll(): Promise<void> {
|
|||||||
|
|
||||||
.media-card__identity-copy h3 {
|
.media-card__identity-copy h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #1a1a1a;
|
color: #1e293b;
|
||||||
font-size: 15px;
|
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;
|
font-weight: 700;
|
||||||
line-height: 1.3;
|
background: #f8fafc;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__identity-copy p {
|
.media-card__identity-copy p {
|
||||||
margin: 4px 0 0;
|
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;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__platform-badge {
|
.media-card__tags {
|
||||||
width: 36px;
|
display: flex;
|
||||||
height: 36px;
|
flex-wrap: wrap;
|
||||||
border-radius: 12px;
|
gap: 8px;
|
||||||
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__platform-badge img {
|
.media-card__tags :deep(.ant-tag) {
|
||||||
width: 20px;
|
margin: 0;
|
||||||
height: 20px;
|
border: none;
|
||||||
object-fit: contain;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-row {
|
.meta-box-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-label {
|
.meta-label {
|
||||||
color: #8c8c8c;
|
color: #64748b;
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-value {
|
.meta-value {
|
||||||
color: #1a1a1a;
|
color: #334155;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: right;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-status-tag {
|
.client-status-tag {
|
||||||
max-width: 160px;
|
max-width: 140px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1061,8 +1061,8 @@ if (!hasSingleInstanceLock) {
|
|||||||
|
|
||||||
void Promise.race([
|
void Promise.race([
|
||||||
(async () => {
|
(async () => {
|
||||||
await clearRendererDesktopSessions()
|
|
||||||
await releaseRuntimeSession()
|
await releaseRuntimeSession()
|
||||||
|
await clearRendererDesktopSessions()
|
||||||
})(),
|
})(),
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ type MonitorExecutionPhase =
|
|||||||
| 'PARSING'
|
| 'PARSING'
|
||||||
| 'POSTING'
|
| 'POSTING'
|
||||||
|
|
||||||
const heartbeatIntervalMs = 25_000
|
const heartbeatIntervalMs = 15_000
|
||||||
const pullIntervalMs = 60_000
|
const pullIntervalMs = 60_000
|
||||||
const leaseExtendIntervalMs = 60_000
|
const leaseExtendIntervalMs = 60_000
|
||||||
const maxActivityItems = 60
|
const maxActivityItems = 60
|
||||||
|
|||||||
@@ -656,8 +656,8 @@ func resolveDesktopClientOnline(
|
|||||||
if client == nil || client.RevokedAt != nil {
|
if client == nil || client.RevokedAt != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if presenceKnown && presence {
|
if presenceKnown {
|
||||||
return true
|
return presence
|
||||||
}
|
}
|
||||||
if client.LastSeenAt == nil {
|
if client.LastSeenAt == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -21,15 +21,27 @@ func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
|
|||||||
assert.True(t, online)
|
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)
|
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{
|
online := resolveDesktopClientOnline(&repository.DesktopClient{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
LastSeenAt: &lastSeen,
|
LastSeenAt: &lastSeen,
|
||||||
}, false, true, now)
|
}, 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)
|
assert.True(t, online)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// desktopClientPresenceTTL is also used as the monitoring primary-client lease TTL.
|
// desktopClientPresenceTTL is also used as the monitoring primary-client lease TTL.
|
||||||
// Desktop heartbeat interval must stay below TTL/2, so one missed heartbeat does
|
// Desktop heartbeat interval is TTL/2, so unexpected client exits are reflected
|
||||||
// not make another client eligible to steal a still-healthy primary lease.
|
// as offline within one TTL even when the explicit offline request is missed.
|
||||||
const desktopClientPresenceTTL = 90 * time.Second
|
const desktopClientPresenceTTL = 30 * time.Second
|
||||||
const desktopAccountPresenceTTL = 90 * time.Second
|
const desktopAccountPresenceTTL = 30 * time.Second
|
||||||
|
|
||||||
func desktopClientPresenceKey(clientID uuid.UUID) string {
|
func desktopClientPresenceKey(clientID uuid.UUID) string {
|
||||||
return "desktop:presence:client:" + clientID.String()
|
return "desktop:presence:client:" + clientID.String()
|
||||||
|
|||||||
@@ -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.
|
-- server/internal/tenant/app/desktop_presence.go:desktopClientPresenceTTL.
|
||||||
-- Keep these values aligned; desktop heartbeat interval must be < TTL/2 so
|
-- Keep these values aligned; desktop heartbeat interval is TTL/2 so unexpected
|
||||||
-- a single missed heartbeat does not let another client steal primary.
|
-- client exits are reflected within one TTL even if explicit offline is missed.
|
||||||
CREATE TABLE IF NOT EXISTS desktop_client_primary_leases (
|
CREATE TABLE IF NOT EXISTS desktop_client_primary_leases (
|
||||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
@@ -33,7 +33,7 @@ SELECT
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
id,
|
id,
|
||||||
COALESCE(last_seen_at, created_at) + INTERVAL '90 seconds'
|
COALESCE(last_seen_at, created_at) + INTERVAL '30 seconds'
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
dc.id,
|
dc.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user