diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue index 4571618..422678d 100644 --- a/apps/admin-web/src/components/GenerateTaskDrawer.vue +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -3,20 +3,20 @@ import { PlusOutlined } from "@ant-design/icons-vue"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { message } from "ant-design-vue"; import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types"; -import { computed, onBeforeUnmount, reactive, ref, watch } from "vue"; +import { computed, reactive, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; +import CoverPickerModal from "@/components/CoverPickerModal.vue"; import PromptRuleModal from "@/components/PromptRuleModal.vue"; -import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue"; -import { generateApi, mediaApi, promptRulesApi, schedulesApi, tenantAccountsApi } from "@/lib/api"; +import { generateApi, imagesApi, mediaApi, promptRulesApi, resolveApiURL, schedulesApi, tenantAccountsApi } from "@/lib/api"; +import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements"; import { formatError } from "@/lib/errors"; -import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress, validateImageUploadFile } from "@/lib/image-webp"; import { - buildPublishPlatformOptions, - normalizePublishPlatformIds, - parseTargetPlatforms, - serializeTargetPlatforms, -} from "@/lib/publish-platforms"; + buildPublishAccountCards, + buildPublishPlatformMap, + publishStatusShortLabel, + type PublishAccountCard, +} from "@/lib/publish-account-cards"; const props = defineProps<{ open: boolean; @@ -30,17 +30,19 @@ const emit = defineEmits<{ const queryClient = useQueryClient(); const { t } = useI18n(); -const imageUploadProgress = useImageUploadProgress(); const promptDrawerOpen = ref(false); -const coverPreviewUrl = ref(""); -const coverFileName = ref(""); +const coverPickerOpen = ref(false); const form = reactive({ name: "", promptRuleId: undefined as number | undefined, - platformIds: [] as string[], - coverEnabled: true, + autoPublish: false, + publishAccountIds: [] as string[], + coverEnabled: false, + coverAssetUrl: "", + coverFileName: "", + coverImageAssetId: null as number | null, enableWebSearch: false, generateCount: 1, scheduleTime: "08:00:00", @@ -48,31 +50,25 @@ const form = reactive({ const isSchedule = computed(() => props.mode === "schedule"); +const rulesQuery = useQuery({ + queryKey: ["promptRules", "simple"], + queryFn: () => promptRulesApi.listSimple(), +}); + +const schedulePublishQueryEnabled = computed(() => props.open && isSchedule.value); + const platformsQuery = useQuery({ - queryKey: ["media", "platforms", "task-drawer"], - enabled: computed(() => props.open), + queryKey: ["media", "platforms", "schedule-task-drawer"], + enabled: schedulePublishQueryEnabled, queryFn: () => mediaApi.platforms(), }); const accountsQuery = useQuery({ - queryKey: ["tenant", "desktop-accounts", "task-drawer"], - enabled: computed(() => props.open), + queryKey: ["tenant", "desktop-accounts", "schedule-task-drawer"], + enabled: schedulePublishQueryEnabled, queryFn: () => tenantAccountsApi.list(), }); -const allPlatforms = computed(() => - buildPublishPlatformOptions(platformsQuery.data.value ?? [], accountsQuery.data.value), -); - -const platformsLoading = computed( - () => platformsQuery.isPending.value || accountsQuery.isPending.value, -); - -const rulesQuery = useQuery({ - queryKey: ["promptRules", "simple"], - queryFn: () => promptRulesApi.listSimple(), -}); - const promptOptions = computed(() => (rulesQuery.data.value ?? []).map((rule: PromptRuleSimple) => ({ label: rule.name, @@ -80,6 +76,40 @@ const promptOptions = computed(() => })), ); +const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? [])); + +const schedulePublishAccounts = computed(() => + buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value), +); + +const schedulePublishLoading = computed( + () => platformsQuery.isPending.value || accountsQuery.isPending.value, +); + +const selectedSchedulePublishPlatformIds = computed(() => { + const selected = new Set(form.publishAccountIds); + return Array.from( + new Set( + schedulePublishAccounts.value + .filter((account) => selected.has(account.id)) + .map((account) => account.platformId), + ), + ); +}); + +const scheduleCoverRequired = computed(() => coverUploadRequired(selectedSchedulePublishPlatformIds.value)); +const effectiveScheduleCoverEnabled = computed(() => scheduleCoverRequired.value || form.coverEnabled); + +watch( + scheduleCoverRequired, + (required) => { + if (required) { + form.coverEnabled = true; + } + }, + { immediate: true }, +); + const drawerTitle = computed(() => { if (isSchedule.value) { return props.task?.id ? t("custom.schedule.editTitle") : t("custom.schedule.createTitle"); @@ -99,16 +129,10 @@ watch( (open) => { if (open) { hydrateForm(); - return; } - resetCoverState(); }, ); -onBeforeUnmount(() => { - resetCoverState(); -}); - const createScheduleMutation = useMutation({ mutationFn: () => schedulesApi.create(buildSchedulePayload()), @@ -151,94 +175,101 @@ const submitLoading = computed( updateScheduleMutation.isPending.value || instantGenerateMutation.isPending.value, ); -const imageUploadBusy = computed(() => imageUploadProgress.visible); function hydrateForm(): void { form.name = props.task?.name ?? ""; form.promptRuleId = props.task?.prompt_rule_id ?? undefined; - form.platformIds = parseTargetPlatforms(props.task?.target_platform); - form.coverEnabled = true; + form.autoPublish = isSchedule.value ? Boolean(props.task?.auto_publish) : false; + form.publishAccountIds = isSchedule.value ? [...(props.task?.publish_account_ids ?? [])] : []; + form.coverAssetUrl = isSchedule.value ? resolveApiURL(props.task?.cover_asset_url) : ""; + form.coverFileName = deriveCoverFileName(form.coverAssetUrl); + form.coverImageAssetId = isSchedule.value ? props.task?.cover_image_asset_id ?? null : null; + form.coverEnabled = Boolean(form.coverAssetUrl); form.enableWebSearch = props.task?.enable_web_search ?? false; form.generateCount = props.task?.generate_count ?? 1; form.scheduleTime = parseCronToDailyTime(props.task?.cron_expr); - resetCoverState(); -} - -function getSelectedPlatformIds(): string[] { - return normalizePublishPlatformIds(form.platformIds); } function buildSchedulePayload() { + const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value; + const coverAssetUrl = coverEnabled ? form.coverAssetUrl.trim() : ""; + return { name: form.name.trim(), prompt_rule_id: form.promptRuleId!, cron_expr: buildDailyCron(form.scheduleTime), - target_platform: serializeTargetPlatforms(getSelectedPlatformIds()), + auto_publish: form.autoPublish, + publish_account_ids: form.autoPublish ? form.publishAccountIds : [], + cover_asset_url: coverAssetUrl || null, + cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : null, enable_web_search: form.enableWebSearch, generate_count: form.generateCount, }; } function buildInstantPayload() { - const platformIds = getSelectedPlatformIds(); - return { prompt_rule_id: form.promptRuleId!, - target_platform: serializeTargetPlatforms(platformIds), extra_params: { task_name: form.name.trim(), generation_mode: "instant", enable_web_search: form.enableWebSearch, generate_count: form.generateCount, - target_platforms: platformIds, - cover_file_name: coverFileName.value || null, }, }; } -function resetCoverState(): void { - if (coverPreviewUrl.value) { - URL.revokeObjectURL(coverPreviewUrl.value); - } - coverPreviewUrl.value = ""; - coverFileName.value = ""; -} - -function handleCoverChange(event: Event): void { - if (imageUploadBusy.value) { - (event.target as HTMLInputElement).value = ""; - return; - } - - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - input.value = ""; - - if (!file) { - return; - } - - try { - validateImageUploadFile(file); - } catch (error) { - message.warning(formatError(error)); - return; - } - - if (coverPreviewUrl.value) { - URL.revokeObjectURL(coverPreviewUrl.value); - } - - coverFileName.value = file.name; - coverPreviewUrl.value = URL.createObjectURL(file); -} - function handlePromptSaved(ruleId: number): void { form.promptRuleId = ruleId; promptDrawerOpen.value = false; void queryClient.invalidateQueries({ queryKey: ["promptRules", "simple"] }); } +function toggleSchedulePublishAccount(account: PublishAccountCard): void { + if (!account.selectable) { + return; + } + if (form.publishAccountIds.includes(account.id)) { + form.publishAccountIds = form.publishAccountIds.filter((id) => id !== account.id); + return; + } + form.publishAccountIds = [...form.publishAccountIds, account.id]; +} + +function isSchedulePublishAccountSelected(accountId: string): boolean { + return form.publishAccountIds.includes(accountId); +} + +async function uploadScheduleCover(file: File): Promise<{ url: string; fileName: string; assetId?: number | null }> { + const uploaded = await imagesApi.upload(file); + return { + url: uploaded.url, + fileName: uploaded.name, + assetId: uploaded.id, + }; +} + +function handleScheduleCoverPicked(payload: { url: string; fileName: string; assetId?: number | null }): void { + form.coverAssetUrl = payload.url; + form.coverFileName = payload.fileName; + form.coverImageAssetId = payload.assetId ?? null; + form.coverEnabled = true; +} + +function handleRemoveScheduleCover(): void { + form.coverAssetUrl = ""; + form.coverFileName = ""; + form.coverImageAssetId = null; +} + +function handleScheduleCoverToggle(checked: boolean): void { + if (scheduleCoverRequired.value) { + form.coverEnabled = true; + return; + } + form.coverEnabled = checked; +} + function validateForm(): boolean { if (!form.name.trim()) { message.warning(t("custom.messages.missingTaskName")); @@ -260,6 +291,17 @@ function validateForm(): boolean { return false; } + if (isSchedule.value && form.autoPublish) { + if (form.publishAccountIds.length === 0) { + message.warning(t("custom.messages.missingPublishAccounts")); + return false; + } + if (scheduleCoverRequired.value && !form.coverAssetUrl.trim()) { + message.warning(t("custom.messages.missingScheduleCover")); + return false; + } + } + return true; } @@ -368,48 +410,123 @@ function padTime(value: string): string { -
- -

{{ t("custom.task.platformsHint") }}

- - + + +
+
+
+

{{ t("custom.task.publishSection") }}

-
- -

{{ t("custom.task.coverHint") }}

+
+
+ +

+ {{ t("custom.task.autoPublishHint") }} +

+
+ +
- -
+
+
+ + +
+

+ {{ scheduleCoverRequired ? t("custom.task.scheduleCoverRequiredHint") : t("custom.task.scheduleCoverHint") }} +

+ +
+ + +
+ {{ form.coverFileName || t("custom.task.cover") }} + + {{ t("article.editor.coverReplace") }} + + + {{ t("article.editor.coverRemove") }} + +
+
+
+
@@ -462,6 +579,17 @@ function padTime(value: string): string { + + @@ -581,6 +709,179 @@ function padTime(value: string): string { color: #355dff; } +.generate-task-drawer__account-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} + +.generate-task-drawer__account { + display: grid; + grid-template-columns: 20px 36px minmax(0, 1fr); + gap: 12px; + align-items: center; + min-height: 76px; + padding: 12px 14px; + border: 1px solid #e1e8f2; + border-radius: 8px; + background: #fff; + text-align: left; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.generate-task-drawer__account:hover { + border-color: #b7c7ec; + background: #fbfdff; +} + +.generate-task-drawer__account--active { + border-color: #355dff; + background: #f4f7ff; + box-shadow: 0 4px 12px rgba(53, 93, 255, 0.08); +} + +.generate-task-drawer__account--disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.generate-task-drawer__account-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px solid #d0d7e5; + border-radius: 4px; + background: #fff; +} + +.generate-task-drawer__account--active .generate-task-drawer__account-check { + border-color: #355dff; + background: #355dff; +} + +.generate-task-drawer__account-check span { + width: 6px; + height: 6px; + border-radius: 1px; + background: #fff; +} + +.generate-task-drawer__account-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid #e4e9f2; + background: #fff; + color: var(--account-accent); + font-size: 12px; + font-weight: 800; + overflow: hidden; +} + +.generate-task-drawer__account-logo img { + width: 24px; + height: 24px; + object-fit: contain; +} + +.generate-task-drawer__account-logo span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: color-mix(in srgb, var(--account-accent) 10%, #ffffff); +} + +.generate-task-drawer__account-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.generate-task-drawer__account-copy strong, +.generate-task-drawer__cover-meta strong { + overflow: hidden; + color: #1f2937; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.generate-task-drawer__account-copy small, +.generate-task-drawer__account-status { + overflow: hidden; + color: #667085; + font-size: 12px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.generate-task-drawer__account-status { + grid-column: 3; + color: #355dff; +} + +.generate-task-drawer__account-status--immediate { + color: #039855; +} + +.generate-task-drawer__account-status--queued { + color: #dc6803; +} + +.generate-task-drawer__account-status--unavailable { + color: #d92d20; +} + +.generate-task-drawer__cover-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.generate-task-drawer__cover-head .generate-task-drawer__label { + margin-bottom: 0; +} + +.generate-task-drawer__empty { + padding: 18px 20px; + border: 1px dashed #cfd9e8; + border-radius: 8px; + background: #fbfcff; +} + +.generate-task-drawer__empty strong { + display: block; + color: #1f2937; + font-size: 15px; + font-weight: 700; +} + +.generate-task-drawer__empty p { + margin: 6px 0 0; + color: #667085; + font-size: 13px; + line-height: 1.7; +} + +.generate-task-drawer__cover-row { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + .generate-task-drawer__cover { display: flex; flex-direction: column; @@ -589,16 +890,20 @@ function padTime(value: string): string { gap: 8px; width: 228px; min-height: 172px; + padding: 0; border: 1px dashed #cfd9e8; - border-radius: 20px; - background: - radial-gradient(circle at top, rgba(53, 93, 255, 0.08), transparent 48%), - #fbfcff; + border-radius: 8px; + background: #fbfcff; color: #1f2937; cursor: pointer; overflow: hidden; } +.generate-task-drawer__cover:hover { + border-color: #9db7ff; + background: #f7faff; +} + .generate-task-drawer__cover img { width: 100%; height: 172px; @@ -615,11 +920,6 @@ function padTime(value: string): string { font-size: 13px; } -.generate-task-drawer__cover--disabled { - opacity: 0.55; - cursor: not-allowed; -} - .generate-task-drawer__cover-plus { display: inline-flex; align-items: center; @@ -633,6 +933,15 @@ function padTime(value: string): string { line-height: 1; } +.generate-task-drawer__cover-meta { + display: flex; + min-width: 220px; + max-width: 360px; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + .generate-task-drawer__time-wrapper { display: flex; align-items: center; diff --git a/apps/admin-web/src/components/PublishArticleModal.vue b/apps/admin-web/src/components/PublishArticleModal.vue index 93cfd68..c84852a 100644 --- a/apps/admin-web/src/components/PublishArticleModal.vue +++ b/apps/admin-web/src/components/PublishArticleModal.vue @@ -2,61 +2,29 @@ import { MinusCircleFilled } from "@ant-design/icons-vue"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { message, notification } from "ant-design-vue"; -import type { ArticleDetail, DesktopAccountInfo, MediaPlatform } from "@geo/shared-types"; +import type { ArticleDetail } from "@geo/shared-types"; import { computed, ref, watch, watchEffect } from "vue"; import { useI18n } from "vue-i18n"; import CoverPickerModal from "@/components/CoverPickerModal.vue"; import { articlesApi, mediaApi, publishJobsApi, resolveApiURL, tenantAccountsApi } from "@/lib/api"; import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements"; -import { resolveAccountCheckedAt, resolveAccountHealth } from "@/lib/desktop-account-runtime"; import { formatError } from "@/lib/errors"; import { - getPublishPlatformMeta, - isPublishPlatformId, - normalizePublishPlatformId, + accountInitial, + buildPublishAccountCards, + buildPublishPlatformMap, + clientStatusColor, + clientStatusLabel, + healthColor, + healthLabel, + publishStateLabel, + type PublishAccountCard, +} from "@/lib/publish-account-cards"; +import { normalizePublishPlatformIds, } from "@/lib/publish-platforms"; -type PublishState = "immediate" | "queued" | "unavailable"; - -const platformLogoCatalog: Record = { - toutiaohao: "/logos/logo_toutiao.png", - baijiahao: "/logos/logo_baijiahao.png", - sohuhao: "/logos/logo_souhu.png", - qiehao: "/logos/logo_qiehao.png", - zhihu: "/logos/logo_zhihu.png", - wangyihao: "/logos/logo_wangyihao.png", - jianshu: "/logos/logo_jianshu.svg", - bilibili: "/logos/logo_bilibili.png", - juejin: "/logos/logo_juejin.png", - smzdm: "/logos/logo_smzdm.svg", - weixin_gzh: "/logos/logo_weixin_gzh.svg", - zol: "/logos/logo_zol.png", - dongchedi: "/logos/logo_dongchedi.svg", -}; - -interface PublishAccountCard { - id: string; - platformId: string; - platformName: string; - platformShortName: string; - platformAccent: string; - platformLogoUrl: string | null; - displayName: string; - platformUid: string; - avatarUrl: string | null; - health: DesktopAccountInfo["health"]; - verifiedAt: string | null; - clientId: string | null; - clientOnline: boolean | null; - clientDeviceName: string | null; - clientLastSeenAt: string | null; - publishState: PublishState; - selectable: boolean; - statusText: string; -} - const props = defineProps<{ open: boolean; articleId: number | null; @@ -123,77 +91,13 @@ watch( { immediate: true }, ); -const platformMap = computed>(() => { - return new Map( - (platformsQuery.data.value ?? []).map((platform: MediaPlatform) => [ - normalizePublishPlatformId(platform.platform_id), - platform, - ]), - ); -}); - -const publishAccounts = computed(() => { - return (accountsQuery.data.value ?? []).filter((account) => { - if (account.deleted_at) { - return false; - } - - const platformId = normalizePublishPlatformId(account.platform); - return platformMap.value.has(platformId) || isPublishPlatformId(platformId); - }); -}); +const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? [])); const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? [])); -const accountCards = computed(() => { - const preferredPlatforms = new Set(articlePlatformIds.value); - - return [...publishAccounts.value] - .map((account) => { - const platformId = normalizePublishPlatformId(account.platform); - const platform = platformMap.value.get(platformId); - const fallback = getPublishPlatformMeta(platformId); - const publishState = resolvePublishState(account); - const health = resolveAccountHealth(account); - - return { - id: account.id, - platformId, - platformName: platform?.name || fallback.name, - platformShortName: platform?.short_name || fallback.shortName, - platformAccent: platform?.accent_color || fallback.accent, - platformLogoUrl: resolvePlatformLogoUrl(platformId, platform?.logo_url), - displayName: account.display_name, - platformUid: normalizePlatformUid(account.platform_uid), - avatarUrl: resolveApiURL(account.avatar_url), - health, - verifiedAt: resolveAccountCheckedAt(account), - clientId: account.client_id, - clientOnline: account.client_online, - clientDeviceName: account.client_device_name, - clientLastSeenAt: account.client_last_seen_at, - publishState, - selectable: publishState !== "unavailable", - statusText: resolveStatusText(account, publishState), - }; - }) - .sort((left, right) => { - const preferredGap = - Number(preferredPlatforms.has(right.platformId)) - Number(preferredPlatforms.has(left.platformId)); - if (preferredGap !== 0) { - return preferredGap; - } - - const publishGap = publishRank(left.publishState) - publishRank(right.publishState); - if (publishGap !== 0) { - return publishGap; - } - - const leftVerifiedAt = Date.parse(left.verifiedAt ?? "") || 0; - const rightVerifiedAt = Date.parse(right.verifiedAt ?? "") || 0; - return rightVerifiedAt - leftVerifiedAt; - }); -}); +const accountCards = computed(() => + buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value, articlePlatformIds.value), +); watch( accountCards, @@ -391,112 +295,6 @@ function handleRemoveCover(): void { coverImageAssetId.value = null; } -function resolvePublishState(account: DesktopAccountInfo): PublishState { - if (resolveAccountHealth(account) !== "live") { - return "unavailable"; - } - if (!account.client_id) { - return "unavailable"; - } - return account.client_online === true ? "immediate" : "queued"; -} - -function resolveStatusText(account: DesktopAccountInfo, publishState: PublishState): string { - if (publishState === "immediate") { - return "客户端在线,RabbitMQ 会立即唤醒桌面端开始发布。"; - } - if (publishState === "queued") { - return "客户端当前离线,任务会先落库排队,等桌面端上线后自动继续发布。"; - } - if (resolveAccountHealth(account) !== "live") { - return "该账号授权状态异常,请先在桌面端重新校验或重新授权。"; - } - return "该账号还没有绑定桌面客户端,当前不能进入发布队列。"; -} - -function publishRank(state: PublishState): number { - switch (state) { - case "immediate": - return 0; - case "queued": - return 1; - default: - return 2; - } -} - -function publishStateLabel(state: PublishState): string { - switch (state) { - case "immediate": - return "立即发布"; - case "queued": - return "离线排队"; - default: - return "不可发布"; - } -} - -function healthLabel(health: DesktopAccountInfo["health"]): string { - switch (health) { - case "live": - return "授权正常"; - case "captcha": - return "待处理"; - case "risk": - return "风险观察"; - default: - return "授权异常"; - } -} - -function healthColor(health: DesktopAccountInfo["health"]): string { - switch (health) { - case "live": - return "green"; - case "captcha": - return "orange"; - case "risk": - return "gold"; - default: - return "red"; - } -} - -function clientStatusLabel(card: PublishAccountCard): string { - if (!card.clientId) { - return "未绑定客户端"; - } - return card.clientOnline ? "客户端在线" : "客户端离线"; -} - -function clientStatusColor(card: PublishAccountCard): string { - if (!card.clientId) { - return "default"; - } - return card.clientOnline ? "green" : "gold"; -} - -function accountInitial(card: PublishAccountCard): string { - const trimmed = card.displayName.trim(); - return trimmed ? trimmed.slice(0, 1).toUpperCase() : card.platformShortName; -} - -function resolvePlatformLogoUrl(platformId: string, remoteLogoUrl?: string | null): string | null { - const localLogoUrl = platformLogoCatalog[platformId]; - if (localLogoUrl) { - return localLogoUrl; - } - const resolvedRemoteLogoUrl = resolveApiURL(remoteLogoUrl); - return resolvedRemoteLogoUrl || null; -} - -function normalizePlatformUid(value?: string | null): string { - let normalized = String(value ?? "").trim(); - while (normalized.toLowerCase().startsWith("platform:")) { - normalized = normalized.slice("platform:".length).trim(); - } - return normalized || "--"; -}