feat: support media account unbind requests
This commit is contained in:
@@ -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 站点(如 PBootCMS、WordPress 等)后,系统将能够通过 API 自动将文章推送到您的自建网站,实现多渠道一键发布与同步。</p>
|
||||
<p>
|
||||
对接您的 CMS 站点(如 PBootCMS、WordPress 等)后,系统将能够通过 API
|
||||
自动将文章推送到您的自建网站,实现多渠道一键发布与同步。
|
||||
</p>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@@ -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 */
|
||||
@@ -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>
|
||||
|
||||
@@ -150,6 +150,7 @@ type MonitorExecutionPhase =
|
||||
const heartbeatIntervalMs = 15_000
|
||||
const pullIntervalMs = 60_000
|
||||
const publishPullIntervalMs = 30_000
|
||||
const accountSyncIntervalMs = 30_000
|
||||
const pumpWatchdogIntervalMs = 30_000
|
||||
const leaseExtendIntervalMs = 60_000
|
||||
const defaultPublishTaskTimeoutMs = 5 * 60_000
|
||||
@@ -258,11 +259,13 @@ interface RuntimeState {
|
||||
publishFallbackBackoffUntil: number
|
||||
heartbeatTimer: ReturnType<typeof setInterval> | null
|
||||
pullTimer: ReturnType<typeof setInterval> | null
|
||||
accountSyncTimer: ReturnType<typeof setInterval> | null
|
||||
pumpWatchdogTimer: ReturnType<typeof setInterval> | null
|
||||
accountHealthReportTimer: ReturnType<typeof setTimeout> | null
|
||||
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
|
||||
accountHealthReportBadAccounts: Set<string>
|
||||
accountRemoteHealth: Map<string, DesktopAccountInfo['health']>
|
||||
accountSyncRunning: boolean
|
||||
dispatchWsClient: DispatchWsClient | null
|
||||
dispatchWsConnected: boolean
|
||||
lastHeartbeatAt: number
|
||||
@@ -289,11 +292,13 @@ const state: RuntimeState = {
|
||||
publishFallbackBackoffUntil: 0,
|
||||
heartbeatTimer: null,
|
||||
pullTimer: null,
|
||||
accountSyncTimer: null,
|
||||
pumpWatchdogTimer: null,
|
||||
accountHealthReportTimer: null,
|
||||
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||||
accountHealthReportBadAccounts: new Set<string>(),
|
||||
accountRemoteHealth: new Map<string, DesktopAccountInfo['health']>(),
|
||||
accountSyncRunning: false,
|
||||
dispatchWsClient: null,
|
||||
dispatchWsConnected: false,
|
||||
lastHeartbeatAt: 0,
|
||||
@@ -712,6 +717,7 @@ function startRuntime(): void {
|
||||
connectDispatchWs()
|
||||
scheduleHeartbeatLoop()
|
||||
schedulePullLoop()
|
||||
scheduleAccountSyncLoop()
|
||||
schedulePumpWatchdogLoop()
|
||||
|
||||
void sendHeartbeat('startup')
|
||||
@@ -753,6 +759,10 @@ function stopRuntime(): void {
|
||||
clearInterval(state.pullTimer)
|
||||
state.pullTimer = null
|
||||
}
|
||||
if (state.accountSyncTimer) {
|
||||
clearInterval(state.accountSyncTimer)
|
||||
state.accountSyncTimer = null
|
||||
}
|
||||
if (state.pumpWatchdogTimer) {
|
||||
clearInterval(state.pumpWatchdogTimer)
|
||||
state.pumpWatchdogTimer = null
|
||||
@@ -824,6 +834,16 @@ function schedulePullLoop(): void {
|
||||
}, publishPullIntervalMs)
|
||||
}
|
||||
|
||||
function scheduleAccountSyncLoop(): void {
|
||||
if (state.accountSyncTimer) {
|
||||
clearInterval(state.accountSyncTimer)
|
||||
}
|
||||
|
||||
state.accountSyncTimer = setInterval(() => {
|
||||
void syncAccounts('timer')
|
||||
}, accountSyncIntervalMs)
|
||||
}
|
||||
|
||||
function schedulePumpWatchdogLoop(): void {
|
||||
if (state.pumpWatchdogTimer) {
|
||||
clearInterval(state.pumpWatchdogTimer)
|
||||
@@ -1180,13 +1200,17 @@ async function sendHeartbeat(source: 'startup' | 'timer'): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
||||
async function syncAccounts(source: 'startup' | 'manual' | 'timer'): Promise<void> {
|
||||
if (!canRunLive(state.session)) {
|
||||
return
|
||||
}
|
||||
if (state.accountSyncRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
state.accountSyncRunning = true
|
||||
try {
|
||||
const accounts = await listDesktopAccounts()
|
||||
const accounts = await consumeDeleteRequestedAccounts(await listDesktopAccounts())
|
||||
state.lastAccountsSyncAt = Date.now()
|
||||
state.lastError = null
|
||||
|
||||
@@ -1298,9 +1322,47 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
||||
setSchedulerError(message)
|
||||
|
||||
recordActivity('warn', '账号同步失败', message)
|
||||
} finally {
|
||||
state.accountSyncRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeDeleteRequestedAccounts(
|
||||
accounts: DesktopAccountInfo[],
|
||||
): Promise<DesktopAccountInfo[]> {
|
||||
const remainingAccounts: DesktopAccountInfo[] = []
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account.delete_requested_at) {
|
||||
remainingAccounts.push(account)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDesktopAccount(account.id, account.sync_version)
|
||||
await clearSessionHandle(account.id)
|
||||
forgetTrackedAccountHealth(account.id)
|
||||
state.accountProfiles.delete(account.id)
|
||||
recordActivity(
|
||||
'info',
|
||||
'账号已自动解绑',
|
||||
`${account.display_name} 已按服务端解绑申请移除,并清理了当前机器上的本地会话缓存。`,
|
||||
)
|
||||
} catch (error) {
|
||||
remainingAccounts.push(account)
|
||||
console.warn('[desktop-runtime] requested desktop account cleanup failed', {
|
||||
accountId: account.id,
|
||||
platform: account.platform,
|
||||
platformUid: account.platform_uid,
|
||||
message: errorMessage(error),
|
||||
})
|
||||
recordActivity('warn', '账号自动解绑失败', `${account.display_name}:${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return remainingAccounts
|
||||
}
|
||||
|
||||
async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
|
||||
@@ -387,7 +387,7 @@ func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repositor
|
||||
}
|
||||
|
||||
func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Actor, desktopID uuid.UUID, undo bool) (*DesktopAccountView, error) {
|
||||
if actor.PrimaryWorkspaceID == 0 {
|
||||
if actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
|
||||
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
||||
}
|
||||
|
||||
@@ -397,9 +397,9 @@ func (s *DesktopAccountService) RequestDelete(ctx context.Context, actor auth.Ac
|
||||
)
|
||||
|
||||
if undo {
|
||||
account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID)
|
||||
account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID)
|
||||
} else {
|
||||
account, err = s.repo.MarkDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID)
|
||||
account, err = s.repo.MarkDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
||||
@@ -1,15 +1,73 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type desktopAccountRepoSpy struct {
|
||||
markDesktopID uuid.UUID
|
||||
markWorkspaceID int64
|
||||
markUserID int64
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) ListByClient(context.Context, int64, uuid.UUID) ([]repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) ListByUser(context.Context, int64, int64) ([]repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) GetByDesktopID(context.Context, uuid.UUID, int64) (*repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) GetByIdentity(context.Context, int64, uuid.UUID, string, string) (*repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) Upsert(context.Context, repository.UpsertDesktopAccountParams) (*repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) Patch(context.Context, repository.PatchDesktopAccountParams) (*repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) Tombstone(context.Context, uuid.UUID, int64, uuid.UUID, int64) (*repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) MarkDeleteRequested(_ context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*repository.DesktopAccount, error) {
|
||||
r.markDesktopID = desktopID
|
||||
r.markWorkspaceID = workspaceID
|
||||
r.markUserID = userID
|
||||
now := time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)
|
||||
return &repository.DesktopAccount{
|
||||
DesktopID: desktopID,
|
||||
TenantID: 10,
|
||||
WorkspaceID: workspaceID,
|
||||
UserID: userID,
|
||||
Platform: "toutiaohao",
|
||||
PlatformUID: "122707592",
|
||||
DisplayName: "Media Account",
|
||||
Health: "live",
|
||||
DeleteRequestedAt: &now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepoSpy) ClearDeleteRequested(context.Context, uuid.UUID, int64, int64) (*repository.DesktopAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
|
||||
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
|
||||
|
||||
@@ -21,6 +79,26 @@ func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
|
||||
assert.True(t, online)
|
||||
}
|
||||
|
||||
func TestRequestDeleteScopesToActorUser(t *testing.T) {
|
||||
repo := &desktopAccountRepoSpy{}
|
||||
service := &DesktopAccountService{repo: repo}
|
||||
accountID := uuid.New()
|
||||
|
||||
view, err := service.RequestDelete(
|
||||
context.Background(),
|
||||
auth.Actor{UserID: 42, TenantID: 10, PrimaryWorkspaceID: 7},
|
||||
accountID,
|
||||
false,
|
||||
)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, accountID, repo.markDesktopID)
|
||||
assert.Equal(t, int64(7), repo.markWorkspaceID)
|
||||
assert.Equal(t, int64(42), repo.markUserID)
|
||||
assert.NotNil(t, view)
|
||||
assert.NotNil(t, view.DeleteRequestedAt)
|
||||
}
|
||||
|
||||
func TestResolveDesktopClientOnline_PresenceMissWithKnownPresenceIsOffline(t *testing.T) {
|
||||
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
|
||||
lastSeen := now.Add(-desktopClientPresenceTTL / 2)
|
||||
|
||||
@@ -68,8 +68,8 @@ type DesktopAccountRepository interface {
|
||||
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
|
||||
Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error)
|
||||
Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error)
|
||||
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
||||
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
||||
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
|
||||
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
|
||||
}
|
||||
|
||||
type desktopAccountRepository struct {
|
||||
@@ -222,10 +222,11 @@ func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid
|
||||
return desktopAccountFromTombstoneRow(row)
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) {
|
||||
func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error) {
|
||||
row, err := r.q.MarkDesktopAccountDeleteRequested(ctx, generated.MarkDesktopAccountDeleteRequestedParams{
|
||||
DesktopID: pgUUID(desktopID),
|
||||
WorkspaceID: workspaceID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,10 +234,11 @@ func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desk
|
||||
return desktopAccountFromMarkDeleteRow(row)
|
||||
}
|
||||
|
||||
func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) {
|
||||
func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error) {
|
||||
row, err := r.q.ClearDesktopAccountDeleteRequested(ctx, generated.ClearDesktopAccountDeleteRequestedParams{
|
||||
DesktopID: pgUUID(desktopID),
|
||||
WorkspaceID: workspaceID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -18,6 +18,7 @@ SET delete_requested_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE desktop_id = $1
|
||||
AND workspace_id = $2
|
||||
AND user_id = $3
|
||||
AND deleted_at IS NULL
|
||||
RETURNING
|
||||
desktop_id,
|
||||
@@ -43,6 +44,7 @@ RETURNING
|
||||
type ClearDesktopAccountDeleteRequestedParams struct {
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
type ClearDesktopAccountDeleteRequestedRow struct {
|
||||
@@ -67,7 +69,7 @@ type ClearDesktopAccountDeleteRequestedRow struct {
|
||||
}
|
||||
|
||||
func (q *Queries) ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error) {
|
||||
row := q.db.QueryRow(ctx, clearDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID)
|
||||
row := q.db.QueryRow(ctx, clearDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID, arg.UserID)
|
||||
var i ClearDesktopAccountDeleteRequestedRow
|
||||
err := row.Scan(
|
||||
&i.DesktopID,
|
||||
@@ -449,6 +451,7 @@ SET delete_requested_at = now(),
|
||||
updated_at = now()
|
||||
WHERE desktop_id = $1
|
||||
AND workspace_id = $2
|
||||
AND user_id = $3
|
||||
AND deleted_at IS NULL
|
||||
RETURNING
|
||||
desktop_id,
|
||||
@@ -474,6 +477,7 @@ RETURNING
|
||||
type MarkDesktopAccountDeleteRequestedParams struct {
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
type MarkDesktopAccountDeleteRequestedRow struct {
|
||||
@@ -498,7 +502,7 @@ type MarkDesktopAccountDeleteRequestedRow struct {
|
||||
}
|
||||
|
||||
func (q *Queries) MarkDesktopAccountDeleteRequested(ctx context.Context, arg MarkDesktopAccountDeleteRequestedParams) (MarkDesktopAccountDeleteRequestedRow, error) {
|
||||
row := q.db.QueryRow(ctx, markDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID)
|
||||
row := q.db.QueryRow(ctx, markDesktopAccountDeleteRequested, arg.DesktopID, arg.WorkspaceID, arg.UserID)
|
||||
var i MarkDesktopAccountDeleteRequestedRow
|
||||
err := row.Scan(
|
||||
&i.DesktopID,
|
||||
|
||||
@@ -260,6 +260,7 @@ SET delete_requested_at = now(),
|
||||
updated_at = now()
|
||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||
AND workspace_id = sqlc.arg(workspace_id)
|
||||
AND user_id = sqlc.arg(user_id)
|
||||
AND deleted_at IS NULL
|
||||
RETURNING
|
||||
desktop_id,
|
||||
@@ -288,6 +289,7 @@ SET delete_requested_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||
AND workspace_id = sqlc.arg(workspace_id)
|
||||
AND user_id = sqlc.arg(user_id)
|
||||
AND deleted_at IS NULL
|
||||
RETURNING
|
||||
desktop_id,
|
||||
|
||||
Reference in New Issue
Block a user