feat: support media account unbind requests
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
|
LockOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
@@ -107,6 +108,19 @@ const activeMediaScope = ref<MediaScope>('ugc')
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedHealth = ref<AccountHealthFilter>('all')
|
const selectedHealth = ref<AccountHealthFilter>('all')
|
||||||
const downloadingReleaseKey = ref<string | null>(null)
|
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 enterpriseSiteModalOpen = ref(false)
|
||||||
const editingEnterpriseSiteId = ref<number | null>(null)
|
const editingEnterpriseSiteId = ref<number | null>(null)
|
||||||
const deletingEnterpriseSiteId = 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 loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
|
||||||
const refreshing = computed(
|
const refreshing = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -555,7 +582,7 @@ function handleEnterpriseSiteCMSTypeChange(value: CmsType): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEnterpriseSiteSelectPopupContainer(triggerNode: HTMLElement): HTMLElement {
|
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 {
|
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 {
|
function enterpriseSiteFaviconURL(site: EnterpriseSiteConnection): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(site.site_url)
|
const parsed = new URL(site.site_url)
|
||||||
@@ -939,7 +999,10 @@ function releaseSize(value?: number | null): string {
|
|||||||
<PlusOutlined class="empty-plus" />
|
<PlusOutlined class="empty-plus" />
|
||||||
</div>
|
</div>
|
||||||
<h3>暂无企业自建站点</h3>
|
<h3>暂无企业自建站点</h3>
|
||||||
<p>对接您的 CMS 站点(如 PBootCMS、WordPress 等)后,系统将能够通过 API 自动将文章推送到您的自建网站,实现多渠道一键发布与同步。</p>
|
<p>
|
||||||
|
对接您的 CMS 站点(如 PBootCMS、WordPress 等)后,系统将能够通过 API
|
||||||
|
自动将文章推送到您的自建网站,实现多渠道一键发布与同步。
|
||||||
|
</p>
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
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">
|
<article v-for="site in enterpriseSites" :key="site.id" class="site-connection-card">
|
||||||
<!-- Top accent based on status -->
|
<!-- Top accent based on status -->
|
||||||
<div class="site-card-accent" :class="`accent-${site.status || 'pending'}`"></div>
|
<div class="site-card-accent" :class="`accent-${site.status || 'pending'}`"></div>
|
||||||
|
|
||||||
<div class="site-connection-card__header">
|
<div class="site-connection-card__header">
|
||||||
<span class="site-connection-card__icon">
|
<span class="site-connection-card__icon">
|
||||||
<img
|
<img
|
||||||
@@ -967,7 +1030,7 @@ function releaseSize(value?: number | null): string {
|
|||||||
/>
|
/>
|
||||||
<GlobalOutlined v-else />
|
<GlobalOutlined v-else />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="site-connection-card__header-main">
|
<div class="site-connection-card__header-main">
|
||||||
<div class="site-name-row">
|
<div class="site-name-row">
|
||||||
<h4>{{ site.name }}</h4>
|
<h4>{{ site.name }}</h4>
|
||||||
@@ -975,7 +1038,7 @@ function releaseSize(value?: number | null): string {
|
|||||||
{{ cmsTypeLabel(site.cms_type as CmsType) }}
|
{{ cmsTypeLabel(site.cms_type as CmsType) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="site-badge site-badge--status"
|
class="site-badge site-badge--status"
|
||||||
:class="`site-badge--status-${site.status || 'pending'}`"
|
:class="`site-badge--status-${site.status || 'pending'}`"
|
||||||
@@ -993,7 +1056,7 @@ function releaseSize(value?: number | null): string {
|
|||||||
<span>{{ site.site_url }}</span>
|
<span>{{ site.site_url }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="site-meta-grid">
|
<div class="site-meta-grid">
|
||||||
<div class="meta-cell">
|
<div class="meta-cell">
|
||||||
<span class="meta-label">{{ enterpriseSiteCredentialLabel(site) }}</span>
|
<span class="meta-label">{{ enterpriseSiteCredentialLabel(site) }}</span>
|
||||||
@@ -1039,7 +1102,7 @@ function releaseSize(value?: number | null): string {
|
|||||||
<template #icon><SyncOutlined /></template>
|
<template #icon><SyncOutlined /></template>
|
||||||
同步栏目
|
同步栏目
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||||
<a-button size="middle" class="action-more-btn" type="text" @click.stop>
|
<a-button size="middle" class="action-more-btn" type="text" @click.stop>
|
||||||
<template #icon><MoreOutlined /></template>
|
<template #icon><MoreOutlined /></template>
|
||||||
@@ -1290,6 +1353,38 @@ function releaseSize(value?: number | null): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="media-card__tags">
|
<div class="media-card__tags">
|
||||||
@@ -1365,6 +1460,120 @@ function releaseSize(value?: number | null): string {
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</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
|
<a-modal
|
||||||
v-model:open="enterpriseSiteModalOpen"
|
v-model:open="enterpriseSiteModalOpen"
|
||||||
:title="enterpriseSiteModalTitle"
|
:title="enterpriseSiteModalTitle"
|
||||||
@@ -2203,12 +2412,16 @@ function releaseSize(value?: number | null): string {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
overflow: hidden;
|
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 {
|
.site-connection-card:hover {
|
||||||
border-color: #cbd5e1;
|
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);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2795,6 +3008,8 @@ function releaseSize(value?: number | null): string {
|
|||||||
.media-card__head {
|
.media-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card__identity {
|
.media-card__identity {
|
||||||
@@ -2802,7 +3017,24 @@ function releaseSize(value?: number | null): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-width: 0;
|
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 */
|
/* Circular Avatar glow container */
|
||||||
@@ -3236,32 +3468,32 @@ function releaseSize(value?: number | null): string {
|
|||||||
.site-connection-card {
|
.site-connection-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-meta-grid {
|
.site-meta-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-cell.full-width {
|
.meta-cell.full-width {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-connection-card__footer-actions {
|
.site-connection-card__footer-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action-ping.ant-btn {
|
.btn-action-ping.ant-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-actions-right {
|
.footer-actions-right {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action-sync.ant-btn {
|
.btn-action-sync.ant-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -3304,4 +3536,305 @@ function releaseSize(value?: number | null): string {
|
|||||||
:deep(.ant-dropdown-menu-item-danger:hover .anticon) {
|
:deep(.ant-dropdown-menu-item-danger:hover .anticon) {
|
||||||
color: #ffffff;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ type MonitorExecutionPhase =
|
|||||||
const heartbeatIntervalMs = 15_000
|
const heartbeatIntervalMs = 15_000
|
||||||
const pullIntervalMs = 60_000
|
const pullIntervalMs = 60_000
|
||||||
const publishPullIntervalMs = 30_000
|
const publishPullIntervalMs = 30_000
|
||||||
|
const accountSyncIntervalMs = 30_000
|
||||||
const pumpWatchdogIntervalMs = 30_000
|
const pumpWatchdogIntervalMs = 30_000
|
||||||
const leaseExtendIntervalMs = 60_000
|
const leaseExtendIntervalMs = 60_000
|
||||||
const defaultPublishTaskTimeoutMs = 5 * 60_000
|
const defaultPublishTaskTimeoutMs = 5 * 60_000
|
||||||
@@ -258,11 +259,13 @@ interface RuntimeState {
|
|||||||
publishFallbackBackoffUntil: number
|
publishFallbackBackoffUntil: number
|
||||||
heartbeatTimer: ReturnType<typeof setInterval> | null
|
heartbeatTimer: ReturnType<typeof setInterval> | null
|
||||||
pullTimer: ReturnType<typeof setInterval> | null
|
pullTimer: ReturnType<typeof setInterval> | null
|
||||||
|
accountSyncTimer: ReturnType<typeof setInterval> | null
|
||||||
pumpWatchdogTimer: ReturnType<typeof setInterval> | null
|
pumpWatchdogTimer: ReturnType<typeof setInterval> | null
|
||||||
accountHealthReportTimer: ReturnType<typeof setTimeout> | null
|
accountHealthReportTimer: ReturnType<typeof setTimeout> | null
|
||||||
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
|
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
|
||||||
accountHealthReportBadAccounts: Set<string>
|
accountHealthReportBadAccounts: Set<string>
|
||||||
accountRemoteHealth: Map<string, DesktopAccountInfo['health']>
|
accountRemoteHealth: Map<string, DesktopAccountInfo['health']>
|
||||||
|
accountSyncRunning: boolean
|
||||||
dispatchWsClient: DispatchWsClient | null
|
dispatchWsClient: DispatchWsClient | null
|
||||||
dispatchWsConnected: boolean
|
dispatchWsConnected: boolean
|
||||||
lastHeartbeatAt: number
|
lastHeartbeatAt: number
|
||||||
@@ -289,11 +292,13 @@ const state: RuntimeState = {
|
|||||||
publishFallbackBackoffUntil: 0,
|
publishFallbackBackoffUntil: 0,
|
||||||
heartbeatTimer: null,
|
heartbeatTimer: null,
|
||||||
pullTimer: null,
|
pullTimer: null,
|
||||||
|
accountSyncTimer: null,
|
||||||
pumpWatchdogTimer: null,
|
pumpWatchdogTimer: null,
|
||||||
accountHealthReportTimer: null,
|
accountHealthReportTimer: null,
|
||||||
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||||||
accountHealthReportBadAccounts: new Set<string>(),
|
accountHealthReportBadAccounts: new Set<string>(),
|
||||||
accountRemoteHealth: new Map<string, DesktopAccountInfo['health']>(),
|
accountRemoteHealth: new Map<string, DesktopAccountInfo['health']>(),
|
||||||
|
accountSyncRunning: false,
|
||||||
dispatchWsClient: null,
|
dispatchWsClient: null,
|
||||||
dispatchWsConnected: false,
|
dispatchWsConnected: false,
|
||||||
lastHeartbeatAt: 0,
|
lastHeartbeatAt: 0,
|
||||||
@@ -712,6 +717,7 @@ function startRuntime(): void {
|
|||||||
connectDispatchWs()
|
connectDispatchWs()
|
||||||
scheduleHeartbeatLoop()
|
scheduleHeartbeatLoop()
|
||||||
schedulePullLoop()
|
schedulePullLoop()
|
||||||
|
scheduleAccountSyncLoop()
|
||||||
schedulePumpWatchdogLoop()
|
schedulePumpWatchdogLoop()
|
||||||
|
|
||||||
void sendHeartbeat('startup')
|
void sendHeartbeat('startup')
|
||||||
@@ -753,6 +759,10 @@ function stopRuntime(): void {
|
|||||||
clearInterval(state.pullTimer)
|
clearInterval(state.pullTimer)
|
||||||
state.pullTimer = null
|
state.pullTimer = null
|
||||||
}
|
}
|
||||||
|
if (state.accountSyncTimer) {
|
||||||
|
clearInterval(state.accountSyncTimer)
|
||||||
|
state.accountSyncTimer = null
|
||||||
|
}
|
||||||
if (state.pumpWatchdogTimer) {
|
if (state.pumpWatchdogTimer) {
|
||||||
clearInterval(state.pumpWatchdogTimer)
|
clearInterval(state.pumpWatchdogTimer)
|
||||||
state.pumpWatchdogTimer = null
|
state.pumpWatchdogTimer = null
|
||||||
@@ -824,6 +834,16 @@ function schedulePullLoop(): void {
|
|||||||
}, publishPullIntervalMs)
|
}, publishPullIntervalMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleAccountSyncLoop(): void {
|
||||||
|
if (state.accountSyncTimer) {
|
||||||
|
clearInterval(state.accountSyncTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.accountSyncTimer = setInterval(() => {
|
||||||
|
void syncAccounts('timer')
|
||||||
|
}, accountSyncIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
function schedulePumpWatchdogLoop(): void {
|
function schedulePumpWatchdogLoop(): void {
|
||||||
if (state.pumpWatchdogTimer) {
|
if (state.pumpWatchdogTimer) {
|
||||||
clearInterval(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)) {
|
if (!canRunLive(state.session)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (state.accountSyncRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.accountSyncRunning = true
|
||||||
try {
|
try {
|
||||||
const accounts = await listDesktopAccounts()
|
const accounts = await consumeDeleteRequestedAccounts(await listDesktopAccounts())
|
||||||
state.lastAccountsSyncAt = Date.now()
|
state.lastAccountsSyncAt = Date.now()
|
||||||
state.lastError = null
|
state.lastError = null
|
||||||
|
|
||||||
@@ -1298,9 +1322,47 @@ async function syncAccounts(source: 'startup' | 'manual'): Promise<void> {
|
|||||||
setSchedulerError(message)
|
setSchedulerError(message)
|
||||||
|
|
||||||
recordActivity('warn', '账号同步失败', 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> {
|
async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): Promise<void> {
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
try {
|
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) {
|
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")
|
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 {
|
if undo {
|
||||||
account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID)
|
account, err = s.repo.ClearDeleteRequested(ctx, desktopID, actor.PrimaryWorkspaceID, actor.UserID)
|
||||||
} else {
|
} 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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
|||||||
@@ -1,15 +1,73 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
"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) {
|
func TestResolveDesktopClientOnline_PresenceHitWins(t *testing.T) {
|
||||||
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
|
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)
|
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) {
|
func TestResolveDesktopClientOnline_PresenceMissWithKnownPresenceIsOffline(t *testing.T) {
|
||||||
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
|
now := time.Date(2026, 4, 19, 21, 30, 0, 0, time.UTC)
|
||||||
lastSeen := now.Add(-desktopClientPresenceTTL / 2)
|
lastSeen := now.Add(-desktopClientPresenceTTL / 2)
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ type DesktopAccountRepository interface {
|
|||||||
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
|
Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error)
|
||||||
Patch(ctx context.Context, params PatchDesktopAccountParams) (*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)
|
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)
|
MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
|
||||||
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error)
|
ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID, userID int64) (*DesktopAccount, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type desktopAccountRepository struct {
|
type desktopAccountRepository struct {
|
||||||
@@ -222,10 +222,11 @@ func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid
|
|||||||
return desktopAccountFromTombstoneRow(row)
|
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{
|
row, err := r.q.MarkDesktopAccountDeleteRequested(ctx, generated.MarkDesktopAccountDeleteRequestedParams{
|
||||||
DesktopID: pgUUID(desktopID),
|
DesktopID: pgUUID(desktopID),
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -233,10 +234,11 @@ func (r *desktopAccountRepository) MarkDeleteRequested(ctx context.Context, desk
|
|||||||
return desktopAccountFromMarkDeleteRow(row)
|
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{
|
row, err := r.q.ClearDesktopAccountDeleteRequested(ctx, generated.ClearDesktopAccountDeleteRequestedParams{
|
||||||
DesktopID: pgUUID(desktopID),
|
DesktopID: pgUUID(desktopID),
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ SET delete_requested_at = NULL,
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = $1
|
WHERE desktop_id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
|
AND user_id = $3
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -43,6 +44,7 @@ RETURNING
|
|||||||
type ClearDesktopAccountDeleteRequestedParams struct {
|
type ClearDesktopAccountDeleteRequestedParams struct {
|
||||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||||
WorkspaceID int64 `json:"workspace_id"`
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClearDesktopAccountDeleteRequestedRow struct {
|
type ClearDesktopAccountDeleteRequestedRow struct {
|
||||||
@@ -67,7 +69,7 @@ type ClearDesktopAccountDeleteRequestedRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ClearDesktopAccountDeleteRequested(ctx context.Context, arg ClearDesktopAccountDeleteRequestedParams) (ClearDesktopAccountDeleteRequestedRow, error) {
|
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
|
var i ClearDesktopAccountDeleteRequestedRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.DesktopID,
|
&i.DesktopID,
|
||||||
@@ -449,6 +451,7 @@ SET delete_requested_at = now(),
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = $1
|
WHERE desktop_id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
|
AND user_id = $3
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -474,6 +477,7 @@ RETURNING
|
|||||||
type MarkDesktopAccountDeleteRequestedParams struct {
|
type MarkDesktopAccountDeleteRequestedParams struct {
|
||||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||||
WorkspaceID int64 `json:"workspace_id"`
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MarkDesktopAccountDeleteRequestedRow struct {
|
type MarkDesktopAccountDeleteRequestedRow struct {
|
||||||
@@ -498,7 +502,7 @@ type MarkDesktopAccountDeleteRequestedRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) MarkDesktopAccountDeleteRequested(ctx context.Context, arg MarkDesktopAccountDeleteRequestedParams) (MarkDesktopAccountDeleteRequestedRow, error) {
|
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
|
var i MarkDesktopAccountDeleteRequestedRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.DesktopID,
|
&i.DesktopID,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ SET delete_requested_at = now(),
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||||
AND workspace_id = sqlc.arg(workspace_id)
|
AND workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND user_id = sqlc.arg(user_id)
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
desktop_id,
|
desktop_id,
|
||||||
@@ -288,6 +289,7 @@ SET delete_requested_at = NULL,
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE desktop_id = sqlc.arg(desktop_id)
|
WHERE desktop_id = sqlc.arg(desktop_id)
|
||||||
AND workspace_id = sqlc.arg(workspace_id)
|
AND workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND user_id = sqlc.arg(user_id)
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
desktop_id,
|
desktop_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user