Files
geo/apps/admin-web/src/views/MediaView.vue
T
root 93ff3c773f fix(admin-web): set no-referrer policy on cross-origin platform avatars
Platforms like Bilibili refuse to serve avatar images when the
Referer points at our admin origin, so the article publish status
panel and the media account cards now request avatars with a
no-referrer policy to stop the placeholder from showing instead of
the user's real avatar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 01:00:04 +08:00

716 lines
19 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, isPublishPlatformId, 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)
.filter((account) => isPublishPlatformId(account.platform))
.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 "";
}
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>
<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" referrerpolicy="no-referrer" />
<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-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-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 v-if="account.deleteRequestedAt || account.publishHint" class="media-card__footer">
<span v-if="account.deleteRequestedAt" class="footer-badge footer-badge--warning">
已申请解绑
</span>
<span v-else-if="account.publishHint" 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;
}
.client-status-tag {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.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>