feat: support media account unbind requests

This commit is contained in:
2026-06-18 23:49:01 +08:00
parent fd37263116
commit 889a575188
7 changed files with 708 additions and 27 deletions
+549 -16
View File
@@ -11,6 +11,7 @@ import {
ExclamationCircleOutlined,
GlobalOutlined,
LinkOutlined,
LockOutlined,
MoreOutlined,
PlusOutlined,
ReloadOutlined,
@@ -107,6 +108,19 @@ const activeMediaScope = ref<MediaScope>('ugc')
const searchQuery = ref('')
const selectedHealth = ref<AccountHealthFilter>('all')
const downloadingReleaseKey = ref<string | null>(null)
const requestingAccountDeleteId = ref<string | null>(null)
const unbindModalOpen = ref(false)
const unbindAccountTarget = ref<MediaAccountCard | null>(null)
const unbindVerifyInput = ref('')
const unbindConfirmLoading = ref(false)
const isUnbindVerified = computed(() => {
if (!unbindAccountTarget.value) return false
const targetName = unbindAccountTarget.value.displayName.trim()
const inputVal = unbindVerifyInput.value.trim()
return inputVal === targetName || inputVal === '解绑'
})
const enterpriseSiteModalOpen = ref(false)
const editingEnterpriseSiteId = ref<number | null>(null)
const deletingEnterpriseSiteId = ref<number | null>(null)
@@ -212,6 +226,19 @@ const deleteEnterpriseSiteMutation = useMutation({
},
})
const requestAccountDeleteMutation = useMutation({
mutationFn: (payload: { id: string; undo?: boolean }) =>
tenantAccountsApi.requestDelete(payload.id, { undo: payload.undo }),
onSuccess: async (_account, payload) => {
message.success(payload.undo ? '已撤销解绑申请' : '已申请解绑,客户端会自动清理本地授权')
await accountsQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
onSettled: () => {
requestingAccountDeleteId.value = null
},
})
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
const refreshing = computed(
() =>
@@ -555,7 +582,7 @@ function handleEnterpriseSiteCMSTypeChange(value: CmsType): void {
}
function getEnterpriseSiteSelectPopupContainer(triggerNode: HTMLElement): HTMLElement {
return triggerNode.closest('.ant-modal-content') as HTMLElement || document.body
return (triggerNode.closest('.ant-modal-content') as HTMLElement) || document.body
}
function normalizeSiteUrl(value: string): string {
@@ -663,6 +690,39 @@ function deleteEnterpriseSite(site: EnterpriseSiteConnection): void {
})
}
function requestUnbindAccount(account: MediaAccountCard): void {
unbindAccountTarget.value = account
unbindVerifyInput.value = ''
unbindConfirmLoading.value = false
unbindModalOpen.value = true
}
async function handleConfirmUnbind(): Promise<void> {
if (!isUnbindVerified.value || !unbindAccountTarget.value) return
unbindConfirmLoading.value = true
try {
requestingAccountDeleteId.value = unbindAccountTarget.value.id
await requestAccountDeleteMutation.mutateAsync({ id: unbindAccountTarget.value.id })
unbindModalOpen.value = false
} catch (err) {
// Error is handled by requestAccountDeleteMutation onError
} finally {
unbindConfirmLoading.value = false
}
}
function undoUnbindAccount(account: MediaAccountCard): void {
requestingAccountDeleteId.value = account.id
requestAccountDeleteMutation.mutate({ id: account.id, undo: true })
}
function isAccountDeleteRequestPending(accountId: string): boolean {
return (
requestAccountDeleteMutation.isPending.value && requestingAccountDeleteId.value === accountId
)
}
function enterpriseSiteFaviconURL(site: EnterpriseSiteConnection): string {
try {
const parsed = new URL(site.site_url)
@@ -939,7 +999,10 @@ function releaseSize(value?: number | null): string {
<PlusOutlined class="empty-plus" />
</div>
<h3>暂无企业自建站点</h3>
<p>对接您的 CMS 站点 PBootCMSWordPress 系统将能够通过 API 自动将文章推送到您的自建网站实现多渠道一键发布与同步</p>
<p>
对接您的 CMS 站点 PBootCMSWordPress 系统将能够通过 API
自动将文章推送到您的自建网站实现多渠道一键发布与同步
</p>
<a-button
type="primary"
size="large"
@@ -955,7 +1018,7 @@ function releaseSize(value?: number | null): string {
<article v-for="site in enterpriseSites" :key="site.id" class="site-connection-card">
<!-- Top accent based on status -->
<div class="site-card-accent" :class="`accent-${site.status || 'pending'}`"></div>
<div class="site-connection-card__header">
<span class="site-connection-card__icon">
<img
@@ -967,7 +1030,7 @@ function releaseSize(value?: number | null): string {
/>
<GlobalOutlined v-else />
</span>
<div class="site-connection-card__header-main">
<div class="site-name-row">
<h4>{{ site.name }}</h4>
@@ -975,7 +1038,7 @@ function releaseSize(value?: number | null): string {
{{ cmsTypeLabel(site.cms_type as CmsType) }}
</span>
</div>
<span
class="site-badge site-badge--status"
:class="`site-badge--status-${site.status || 'pending'}`"
@@ -993,7 +1056,7 @@ function releaseSize(value?: number | null): string {
<span>{{ site.site_url }}</span>
</a>
</div>
<div class="site-meta-grid">
<div class="meta-cell">
<span class="meta-label">{{ enterpriseSiteCredentialLabel(site) }}</span>
@@ -1039,7 +1102,7 @@ function releaseSize(value?: number | null): string {
<template #icon><SyncOutlined /></template>
同步栏目
</a-button>
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-button size="middle" class="action-more-btn" type="text" @click.stop>
<template #icon><MoreOutlined /></template>
@@ -1290,6 +1353,38 @@ function releaseSize(value?: number | null): string {
</p>
</div>
</div>
<a-dropdown :trigger="['click']" placement="bottomRight" overlayClassName="premium-unbind-dropdown">
<a-button
type="text"
class="media-card__more action-more-btn"
:loading="isAccountDeleteRequestPending(account.id)"
@click.stop
>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
v-if="account.deleteRequestedAt"
key="undo-unbind"
@click="undoUnbindAccount(account)"
>
<SyncOutlined />
<span>撤销解绑申请</span>
</a-menu-item>
<a-menu-item
v-else
key="unbind"
danger
@click="requestUnbindAccount(account)"
>
<DeleteOutlined />
<span>解绑账号</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="media-card__tags">
@@ -1365,6 +1460,120 @@ function releaseSize(value?: number | null): string {
</section>
</template>
<!-- Custom Account Unbind Modal -->
<a-modal
v-model:open="unbindModalOpen"
:destroy-on-close="true"
:footer="null"
:width="480"
class="premium-unbind-modal"
:closable="true"
>
<div class="unbind-modal-header">
<div class="unbind-warning-icon-wrapper">
<DeleteOutlined class="unbind-warning-icon" />
</div>
<h3 class="unbind-modal-title">申请解绑媒体账号</h3>
<p class="unbind-modal-subtitle">请仔细阅读以下安全提示解绑后可重新绑定</p>
</div>
<div class="unbind-account-preview" v-if="unbindAccountTarget">
<div class="preview-avatar-wrapper">
<img
v-if="unbindAccountTarget.avatarUrl"
:src="unbindAccountTarget.avatarUrl"
class="preview-avatar"
/>
<span
v-else
class="preview-avatar-placeholder"
:style="{ background: unbindAccountTarget.platformAccent }"
>
{{ accountInitial(unbindAccountTarget) }}
</span>
<img
v-if="unbindAccountTarget.platformLogoUrl"
:src="unbindAccountTarget.platformLogoUrl"
class="preview-platform-icon"
/>
</div>
<div class="preview-info">
<span class="preview-name">{{ unbindAccountTarget.displayName }}</span>
<span class="preview-meta">
{{ unbindAccountTarget.platformLabel }} · ID: {{ unbindAccountTarget.platformUid }}
</span>
</div>
<div class="preview-status">
<span
class="badge-status"
:class="`badge-status--${unbindAccountTarget.clientOnline ? 'success' : 'warning'}`"
>
<span class="indicator-dot"></span>
{{ unbindAccountTarget.clientOnline ? '在线' : '离线' }}
</span>
</div>
</div>
<div class="unbind-consequences" v-if="unbindAccountTarget">
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<ExclamationCircleOutlined />
</div>
<div class="consequence-text">
<strong>解除服务绑定</strong>系统服务端将彻底清除此账号的授权同步及分发配置
</div>
</div>
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<LockOutlined />
</div>
<div class="consequence-text">
<strong>清理会话缓存</strong>
<span v-if="unbindAccountTarget.clientOnline">
在线客户端将即时收到通知并清理所有登录会话 Cookie
</span>
<span v-else>
客户端不在线系统已将解绑申请加入队列客户端上线后将自动同步清理本地会话
</span>
</div>
</div>
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<CloseCircleOutlined />
</div>
<div class="consequence-text">
<strong>分发任务失效</strong>该账号将无法接收并执行任何发布任务再次使用必须重新扫码登录授权
</div>
</div>
</div>
<div class="unbind-verification" v-if="unbindAccountTarget">
<div class="verification-label">
请输入账号名称 <span class="highlight-name">{{ unbindAccountTarget.displayName }}</span> 或输入 <span class="highlight-keyword">解绑</span> 进行确认
</div>
<a-input
v-model:value="unbindVerifyInput"
placeholder="请输入名称或“解绑”"
class="unbind-input"
@pressEnter="handleConfirmUnbind"
/>
</div>
<div class="unbind-modal-footer">
<a-button @click="unbindModalOpen = false" class="btn-cancel">取消</a-button>
<a-button
type="primary"
danger
:disabled="!isUnbindVerified"
:loading="unbindConfirmLoading"
@click="handleConfirmUnbind"
class="btn-confirm"
>
确认解绑
</a-button>
</div>
</a-modal>
<a-modal
v-model:open="enterpriseSiteModalOpen"
:title="enterpriseSiteModalTitle"
@@ -2203,12 +2412,16 @@ function releaseSize(value?: number | null): string {
padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -1px rgba(0, 0, 0, 0.01);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px -1px rgba(0, 0, 0, 0.01);
}
.site-connection-card:hover {
border-color: #cbd5e1;
box-shadow: 0 20px 25px -5px rgba(15, 23, 42, 0.08), 0 10px 10px -5px rgba(15, 23, 42, 0.04);
box-shadow:
0 20px 25px -5px rgba(15, 23, 42, 0.08),
0 10px 10px -5px rgba(15, 23, 42, 0.04);
transform: translateY(-4px);
}
@@ -2795,6 +3008,8 @@ function releaseSize(value?: number | null): string {
.media-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.media-card__identity {
@@ -2802,7 +3017,24 @@ function releaseSize(value?: number | null): string {
align-items: center;
gap: 14px;
min-width: 0;
width: 100%;
flex: 1;
}
.media-card__more.ant-btn-text {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 8px;
color: #94a3b8;
background: transparent;
border: 1px solid transparent;
}
.media-card__more.ant-btn-text:hover,
.media-card__more.ant-btn-text:focus {
color: #334155;
background: #f8fafc;
border-color: #e2e8f0;
}
/* Circular Avatar glow container */
@@ -3236,32 +3468,32 @@ function releaseSize(value?: number | null): string {
.site-connection-card {
padding: 16px;
}
.site-meta-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.meta-cell.full-width {
grid-column: span 1;
}
.site-connection-card__footer-actions {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.btn-action-ping.ant-btn {
width: 100%;
justify-content: center;
}
.footer-actions-right {
justify-content: space-between;
width: 100%;
}
.btn-action-sync.ant-btn {
flex: 1;
justify-content: center;
@@ -3304,4 +3536,305 @@ function releaseSize(value?: number | null): string {
:deep(.ant-dropdown-menu-item-danger:hover .anticon) {
color: #ffffff;
}
/* Custom Account Unbind Modal Styles */
.premium-unbind-modal :deep(.ant-modal-content) {
border-radius: 16px;
padding: 24px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.premium-unbind-modal :deep(.ant-modal-header) {
margin-bottom: 20px;
}
.unbind-modal-header {
text-align: center;
margin-bottom: 24px;
}
.unbind-warning-icon-wrapper {
width: 56px;
height: 56px;
background-color: #fef2f2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
border: 1px solid #fee2e2;
animation: pulse-warning 2s infinite ease-in-out;
}
@keyframes pulse-warning {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.15);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
.unbind-warning-icon {
font-size: 24px;
color: #ef4444;
}
.unbind-modal-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
margin: 0 0 8px;
}
.unbind-modal-subtitle {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* Account Preview inside Modal */
.unbind-account-preview {
display: flex;
align-items: center;
gap: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 20px;
}
.preview-avatar-wrapper {
position: relative;
width: 40px;
height: 40px;
flex-shrink: 0;
}
.preview-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1px solid #cbd5e1;
}
.preview-avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 14px;
}
.preview-platform-icon {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
padding: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.preview-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.preview-name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-status {
flex-shrink: 0;
}
/* Consequences List */
.unbind-consequences {
display: flex;
flex-direction: column;
gap: 12px;
background-color: #fffaf0;
border: 1px dashed #feebc8;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
}
.consequence-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.consequence-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 2px;
color: #dd6b20;
}
.consequence-icon-wrapper .anticon {
font-size: 14px;
}
.consequence-text {
font-size: 13px;
color: #7b341e;
line-height: 1.5;
}
.consequence-text strong {
color: #dd6b20;
}
/* Verification Input */
.unbind-verification {
margin-bottom: 24px;
}
.verification-label {
font-size: 13px;
color: #475569;
margin-bottom: 8px;
line-height: 1.5;
}
.highlight-name {
font-family: monospace;
font-weight: 700;
color: #ef4444;
background-color: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #fee2e2;
margin: 0 2px;
}
.highlight-keyword {
font-weight: 700;
color: #3b82f6;
background-color: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #dbeafe;
margin: 0 2px;
}
.unbind-input.ant-input {
height: 40px;
border-radius: 8px;
border-color: #cbd5e1;
font-size: 14px;
transition: all 0.2s ease;
}
.unbind-input.ant-input:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
/* Footer Actions */
.unbind-modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.unbind-modal-footer .btn-cancel {
height: 40px;
border-radius: 8px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
color: #475569;
border-color: #cbd5e1;
}
.unbind-modal-footer .btn-cancel:hover {
color: #1e293b;
border-color: #94a3b8;
background: #f8fafc;
}
.unbind-modal-footer .btn-confirm {
height: 40px;
border-radius: 8px;
padding: 0 20px;
font-size: 14px;
font-weight: 600;
}
/* Premium Dropdown styles */
:global(.premium-unbind-dropdown .ant-dropdown-menu) {
border-radius: 12px !important;
padding: 6px !important;
border: 1px solid #f1f5f9 !important;
box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.08), 0 8px 16px -6px rgba(15, 23, 42, 0.04) !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item) {
border-radius: 8px !important;
padding: 8px 16px !important;
font-size: 13.5px !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover) {
background-color: #eff6ff !important;
color: #2563eb !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover .anticon) {
color: #2563eb !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger) {
color: #ef4444 !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger .anticon) {
color: #ef4444 !important;
font-size: 15px !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger:hover) {
background-color: #fef2f2 !important;
color: #dc2626 !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger:hover .anticon) {
color: #dc2626 !important;
}
</style>