Files
geo/apps/admin-web/src/views/MediaView.vue
T
root a617d39a4a feat(desktop): drop parked review flow, add publish management
SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
2026-04-20 09:52:48 +08:00

710 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons-vue";
import { useQuery } from "@tanstack/vue-query";
import type { DesktopAccountInfo, MediaPlatform } from "@geo/shared-types";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { mediaApi, resolveApiURL, tenantAccountsApi } from "@/lib/api";
import { formatDateTime } from "@/lib/display";
import { getPublishPlatformMeta, normalizePublishPlatformId } from "@/lib/publish-platforms";
type AccountHealthFilter = "all" | "live" | "captcha" | "risk" | "expired";
type PublishState = "immediate" | "queued" | "unavailable";
interface MediaAccountCard {
id: string;
platform: string;
platformLabel: 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;
publishHint: string;
deleteRequestedAt: string | null;
}
const platformLogoCatalog: Record<string, string> = {
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",
};
const { t } = useI18n();
const searchQuery = ref("");
const selectedHealth = ref<AccountHealthFilter>("all");
const platformsQuery = useQuery({
queryKey: ["media", "platforms", "media-view"],
queryFn: () => mediaApi.platforms(),
});
const accountsQuery = useQuery({
queryKey: ["tenant", "desktop-accounts", "media-view"],
queryFn: () => tenantAccountsApi.list(),
});
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value);
const refreshing = computed(() => platformsQuery.isFetching.value || accountsQuery.isFetching.value);
const platformMap = computed(() => {
return new Map(
(platformsQuery.data.value ?? []).map((platform: MediaPlatform) => [
normalizePublishPlatformId(platform.platform_id),
platform,
]),
);
});
const mediaAccounts = computed<MediaAccountCard[]>(() => {
return [...(accountsQuery.data.value ?? [])]
.filter((account) => !account.deleted_at)
.map((account) => {
const platformId = normalizePublishPlatformId(account.platform);
const platform = platformMap.value.get(platformId);
const fallback = getPublishPlatformMeta(platformId);
const publishState = resolvePublishState(account);
return {
id: account.id,
platform: platformId,
platformLabel: 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: account.health,
verifiedAt: account.verified_at,
clientId: account.client_id,
clientOnline: account.client_online,
clientDeviceName: account.client_device_name,
clientLastSeenAt: account.client_last_seen_at,
publishState,
publishHint: resolvePublishHint(account, publishState),
deleteRequestedAt: account.delete_requested_at,
};
})
.sort((left, right) => {
const publishGap = publishRank(left.publishState) - publishRank(right.publishState);
if (publishGap !== 0) {
return publishGap;
}
const leftAt = Date.parse(left.verifiedAt ?? "") || 0;
const rightAt = Date.parse(right.verifiedAt ?? "") || 0;
return rightAt - leftAt;
});
});
const overview = computed(() => ({
total: mediaAccounts.value.length,
live: mediaAccounts.value.filter((item) => item.health === "live").length,
immediate: mediaAccounts.value.filter((item) => item.publishState === "immediate").length,
queued: mediaAccounts.value.filter((item) => item.publishState === "queued").length,
attention: mediaAccounts.value.filter((item) => item.publishState === "unavailable").length,
}));
const filteredAccounts = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase();
return mediaAccounts.value.filter((account) => {
if (selectedHealth.value !== "all" && account.health !== selectedHealth.value) {
return false;
}
if (!keyword) {
return true;
}
return [
account.displayName,
account.platformLabel,
account.platformUid,
account.clientDeviceName ?? "",
].join(" ").toLowerCase().includes(keyword);
});
});
function resolvePublishState(account: DesktopAccountInfo): PublishState {
if (account.health !== "live") {
return "unavailable";
}
if (!account.client_id) {
return "unavailable";
}
return account.client_online === true ? "immediate" : "queued";
}
function resolvePublishHint(account: DesktopAccountInfo, publishState: PublishState): string {
if (publishState === "immediate") {
return "客户端在线,RabbitMQ 事件会立即唤醒桌面端消费发布任务。";
}
if (publishState === "queued") {
return "客户端离线,发布任务会先落库保留,等桌面端恢复在线后继续消费。";
}
if (account.health !== "live") {
return "授权状态异常,先在桌面端重新校验后再发布。";
}
return "还没有绑定桌面客户端,当前无法加入发布队列。";
}
function normalizePlatformUid(value?: string | null): string {
let normalized = String(value ?? "").trim();
while (normalized.toLowerCase().startsWith("platform:")) {
normalized = normalized.slice("platform:".length).trim();
}
return normalized || "--";
}
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 accountInitial(account: MediaAccountCard): string {
const name = account.displayName.trim();
return name ? name.slice(0, 1).toUpperCase() : account.platformShortName;
}
function publishRank(state: PublishState): number {
switch (state) {
case "immediate":
return 0;
case "queued":
return 1;
default:
return 2;
}
}
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(account: MediaAccountCard): string {
if (!account.clientId) {
return "未绑定";
}
return account.clientOnline ? "在线" : "离线";
}
function clientStatusColor(account: MediaAccountCard): string {
if (!account.clientId) {
return "default";
}
return account.clientOnline ? "green" : "gold";
}
function clientStatusText(account: MediaAccountCard): string {
if (!account.clientId) {
return "未绑定桌面客户端";
}
const device = account.clientDeviceName?.trim();
return device ? `${clientStatusLabel(account)} · ${device}` : clientStatusLabel(account);
}
function publishStateLabel(state: PublishState): string {
switch (state) {
case "immediate":
return "可立即发布";
case "queued":
return "离线排队";
default:
return "不可发布";
}
}
function publishStateColor(state: PublishState): string {
switch (state) {
case "immediate":
return "green";
case "queued":
return "gold";
default:
return "red";
}
}
async function refreshAll(): Promise<void> {
await Promise.all([
platformsQuery.refetch(),
accountsQuery.refetch(),
]);
}
</script>
<template>
<div class="media-view">
<section class="media-view__top-card">
<div class="media-view__header">
<div class="media-view__header-title">
<h2>{{ t("route.media.title") }}</h2>
<p>这里展示 `platform_accounts` 里当前用户已绑定的桌面媒体账号以及它们是否能立即发布离线排队或需要处理</p>
</div>
<div class="media-view__header-actions">
<a-button :loading="refreshing" @click="refreshAll">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
</div>
</div>
<div class="overview-strip border-t">
<div class="overview-chip">
<span>账号总数</span>
<strong>{{ overview.total }}</strong>
</div>
<div class="overview-chip">
<span>可立即发布</span>
<strong>{{ overview.immediate }}</strong>
</div>
<div class="overview-chip">
<span>离线排队</span>
<strong>{{ overview.queued }}</strong>
</div>
<div class="overview-chip">
<span>需处理</span>
<strong>{{ overview.attention }}</strong>
</div>
</div>
</section>
<a-alert
type="info"
show-icon
message="桌面端是账号和会话的真实持有者"
description="页面在线态由 Redis presence 和最近心跳综合判断。在线客户端会被 RabbitMQ 立即唤醒;离线客户端的任务会先落库排队,等桌面端恢复在线后继续消费。"
/>
<section class="panel media-list-panel">
<div class="media-list-toolbar">
<a-input
v-model:value="searchQuery"
placeholder="搜索昵称、UID、平台、客户端"
class="media-search"
allow-clear
>
<template #prefix>
<SearchOutlined style="color: #8c8c8c" />
</template>
</a-input>
<a-select v-model:value="selectedHealth" style="width: 160px">
<a-select-option value="all">全部状态</a-select-option>
<a-select-option value="live">授权正常</a-select-option>
<a-select-option value="captcha">待处理</a-select-option>
<a-select-option value="risk">风险观察</a-select-option>
<a-select-option value="expired">授权异常</a-select-option>
</a-select>
</div>
<div class="media-list-header">
<h3 class="panel-title">已绑定媒体账号</h3>
<p class="panel-desc">只展示当前用户已经写入 `platform_accounts` 的账号本地没有 session / partition 的账号不会出现在这里</p>
</div>
<a-spin :spinning="loading">
<a-empty
v-if="!filteredAccounts.length"
description="当前还没有可展示的媒体账号,请先在桌面端绑定。"
/>
<section v-else class="media-grid">
<article
v-for="account in filteredAccounts"
:key="account.id"
class="media-card"
>
<div class="media-card__head">
<div class="media-card__identity">
<span class="media-card__avatar" :style="{ background: account.platformAccent }">
<img v-if="account.avatarUrl" :src="account.avatarUrl" :alt="account.displayName" />
<span v-else>{{ accountInitial(account) }}</span>
</span>
<div class="media-card__identity-copy">
<h3>{{ account.displayName }}</h3>
<p>{{ account.platformLabel }}</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) }}</a-tag>
</div>
<div class="meta-row">
<span class="meta-label">最近校验</span>
<span class="meta-value">{{ account.verifiedAt ? formatDateTime(account.verifiedAt) : "--" }}</span>
</div>
<div class="meta-row">
<span class="meta-label">客户端状态</span>
<a-tag :color="clientStatusColor(account)">{{ clientStatusText(account) }}</a-tag>
</div>
<div class="meta-row">
<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>
</div>
</div>
<div class="media-card__footer">
<span v-if="account.deleteRequestedAt" class="footer-badge footer-badge--warning">
已申请解绑
</span>
<span v-else class="footer-badge">
{{ account.publishHint }}
</span>
</div>
</article>
</section>
</a-spin>
</section>
</div>
</template>
<style scoped>
.media-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.media-view__top-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.media-view__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.media-view__header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.media-view__header-title p {
margin: 6px 0 0;
font-size: 13px;
color: #8c8c8c;
line-height: 1.7;
}
.media-view__header-actions {
display: flex;
gap: 12px;
}
.overview-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.overview-chip {
padding: 18px 24px;
border-top: 1px solid #eef2f7;
border-right: 1px solid #eef2f7;
}
.overview-chip:last-child {
border-right: none;
}
.overview-chip span {
display: block;
color: #8c8c8c;
font-size: 12px;
}
.overview-chip strong {
display: block;
margin-top: 8px;
color: #1a1a1a;
font-size: 24px;
font-weight: 700;
}
.panel {
background: #fff;
border-radius: 12px;
padding: 24px;
border: 1px solid #e6edf5;
}
.media-list-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.media-list-toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.media-search {
width: 320px;
}
.media-list-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
.panel-desc {
margin: 0;
color: #8c8c8c;
font-size: 13px;
line-height: 1.7;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px;
}
.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%);
display: flex;
flex-direction: column;
gap: 18px;
}
.media-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.media-card__identity {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.media-card__avatar {
width: 44px;
height: 44px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: 700;
overflow: hidden;
flex-shrink: 0;
}
.media-card__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-card__identity-copy {
min-width: 0;
}
.media-card__identity-copy h3 {
margin: 0;
color: #1a1a1a;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.media-card__identity-copy p {
margin: 4px 0 0;
color: #8c8c8c;
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__platform-badge img {
width: 20px;
height: 20px;
object-fit: contain;
}
.media-card__meta {
display: flex;
flex-direction: column;
gap: 10px;
}
.meta-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.meta-label {
color: #8c8c8c;
font-size: 12px;
flex-shrink: 0;
}
.meta-value {
color: #1a1a1a;
font-size: 13px;
font-weight: 500;
text-align: right;
word-break: break-all;
}
.media-card__footer {
padding-top: 4px;
}
.footer-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f3f6fb;
color: #516074;
font-size: 12px;
font-weight: 600;
line-height: 1.6;
}
.footer-badge--warning {
background: #fff7e6;
color: #d48806;
}
@media (max-width: 900px) {
.overview-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-chip:nth-child(2) {
border-right: none;
}
.overview-chip:nth-child(n + 3) {
border-top: 1px solid #eef2f7;
}
.media-list-toolbar {
flex-direction: column;
align-items: stretch;
}
.media-search {
width: 100%;
}
}
</style>