Files
photo-live/apps/admin/src/views/system/user-sync/index.vue
T

317 lines
12 KiB
Vue
Raw Normal View History

<!-- 用户同步管理页(P4a Task 8):状态卡(引擎状态/水位/计数 + 手动补拉)、
运行历史(游标累积)、失败明细抽屉(点行打开)。页面存续期间 10s 自动
刷新状态(onUnmounted 清理);手动补拉 202 受理后轮询至批次离开 running。 -->
<template>
<div class="p-5">
<!-- ── 状态卡 ── -->
<ElCard data-test="sync-status-card" class="mb-4">
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('userSync.status.title') }}</span>
<ElButton
type="primary"
:loading="triggering"
:disabled="status?.state === 'running'"
data-test="sync-trigger-btn"
@click="triggerSync"
>
{{ $t('userSync.status.trigger') }}
</ElButton>
</div>
</template>
<div v-if="statusError && !status" data-test="sync-status-error">
<ElAlert type="error" :closable="false">
<div class="flex items-center gap-2">
<span>{{ $t('userSync.status.loadFailed') }}:{{ statusError.message }}</span>
<ElButton size="small" data-test="sync-status-retry" @click="refreshStatus">
{{ $t('userSync.status.retry') }}
</ElButton>
</div>
</ElAlert>
</div>
<div v-else v-loading="statusLoading" data-test="sync-status-body">
<div class="flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-gray-500">{{ $t('userSync.status.state') }}</span>
<ElTag
:type="status ? SYNC_STATE_TAG_TYPE[status.state] : 'info'"
:data-state="status?.state ?? 'idle'"
data-test="sync-state-tag"
>
{{ $t(`userSync.state.${status?.state ?? 'idle'}`) }}
</ElTag>
</div>
<div>
<span class="text-gray-500">{{ $t('userSync.status.lastRunAt') }}</span>
<span class="ml-2">{{ formatDateTime(status?.lastRunAt) }}</span>
</div>
<div>
<span class="text-gray-500">{{ $t('userSync.status.lastSuccessAt') }}</span>
<span class="ml-2">{{ formatDateTime(status?.lastSuccessAt) }}</span>
</div>
<div>
<span class="text-gray-500">{{ $t('userSync.status.sourceVersion') }}</span>
<span class="ml-2" data-test="sync-source-version">
{{ status?.sourceVersion ?? '-' }}
</span>
</div>
</div>
<!-- 最近批次计数 chips(从未运行为空展示) -->
<div v-if="status?.counts" class="flex flex-wrap gap-2 mt-3" data-test="sync-counts">
<ElTag
v-for="key in COUNT_KEYS"
:key="key"
:type="key === 'failed' && status.counts[key] > 0 ? 'danger' : 'info'"
:data-test="`sync-count-${key}`"
>
{{ $t(`userSync.counts.${key}`) }} {{ status.counts[key] }}
</ElTag>
</div>
<div v-else class="text-gray-400 text-sm mt-3" data-test="sync-counts-empty">
{{ $t('userSync.status.noRuns') }}
</div>
</div>
</ElCard>
<!-- ── 运行历史 ── -->
<ElCard data-test="sync-runs-card">
<template #header>
<span>{{ $t('userSync.runs.title') }}</span>
</template>
<ElAlert
v-if="runsError && !runs.length"
type="error"
:closable="false"
data-test="sync-runs-error"
>
<div class="flex items-center gap-2">
<span>{{ $t('userSync.runs.loadFailed') }}:{{ runsError.message }}</span>
<ElButton size="small" data-test="sync-runs-retry" @click="loadFirstRuns()">
{{ $t('userSync.runs.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable
v-else
v-loading="runsLoading"
:data="runs"
row-class-name="sync-run-row"
data-test="sync-runs-table"
@row-click="onRunClick"
>
<ElTableColumn :label="$t('userSync.runs.colTrigger')" width="110">
<template #default="{ row }">
<ElTag :type="row.trigger === 'manual' ? 'warning' : 'info'" effect="plain">
{{ $t(`userSync.trigger.${row.trigger}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.runs.colState')" width="110">
<template #default="{ row }">
<ElTag :type="runStateTagOf(row.state)">
{{ $t(`userSync.state.${row.state}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.runs.colCounts')" min-width="240">
<template #default="{ row }">
<span class="text-sm" data-test="sync-run-counts">
{{ $t('userSync.counts.added') }} {{ row.counts.added }} ·
{{ $t('userSync.counts.updated') }} {{ row.counts.updated }} ·
{{ $t('userSync.counts.disabled') }} {{ row.counts.disabled }} ·
{{ $t('userSync.counts.failed') }} {{ row.counts.failed }}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.runs.colStartedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.startedAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.runs.colFinishedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.finishedAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.runs.colError')" min-width="180">
<template #default="{ row }">
<span class="text-gray-500 text-sm break-all">{{ row.error ?? '-' }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!runsLoading" :description="$t('userSync.runs.empty')" />
</template>
</ElTable>
<div v-if="runs.length" class="mt-4 text-center">
<ElButton
v-if="runsHasMore"
:loading="runsLoading"
data-test="sync-runs-load-more"
@click="loadMoreRuns()"
>
{{ $t('userSync.runs.loadMore') }}
</ElButton>
<span v-else class="text-gray-400 text-sm">{{ $t('userSync.runs.noMore') }}</span>
</div>
</ElCard>
<!-- ── 失败明细抽屉(点运行历史行打开) ── -->
<ElDrawer
v-model="errors.drawerVisible.value"
:title="$t('userSync.errors.title', { runId: errors.drawerRunId.value ?? '' })"
size="560px"
data-test="sync-errors-drawer"
>
<div v-if="errors.error.value" data-test="sync-errors-error">
<ElAlert type="error" :closable="false">
<div class="flex items-center gap-2">
<span>{{ $t('userSync.errors.loadFailed') }}:{{ errors.error.value.message }}</span>
<ElButton size="small" data-test="sync-errors-retry" @click="errors.load()">
{{ $t('userSync.errors.retry') }}
</ElButton>
</div>
</ElAlert>
</div>
<ElTable
v-else
v-loading="errors.loading.value"
:data="errors.items.value"
data-test="sync-errors-table"
>
<ElTableColumn :label="$t('userSync.errors.colExternalId')" min-width="130">
<template #default="{ row }">
<span class="font-mono text-sm">{{ row.externalUserId }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.errors.colCode')" width="170">
<template #default="{ row }">
<ElTag type="danger" effect="plain" size="small">{{ row.code }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.errors.colDetail')" min-width="180">
<template #default="{ row }">
<span class="text-sm break-all">{{ row.detail }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('userSync.errors.colCreatedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.createdAt) }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty
v-if="!errors.loading.value"
:description="$t('userSync.errors.empty')"
data-test="sync-errors-empty"
/>
</template>
</ElTable>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { fetchTriggerUserSyncRun, fetchUserSyncRuns, fetchUserSyncStatus } from '@/api/userSync'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatDateTime } from '@/utils/albums/format'
import { createAutoRefresh, waitSyncTerminal } from '@/utils/system/syncPolling'
import { SYNC_STATE_TAG_TYPE } from '@/utils/system/userAdmin'
import { useSyncErrors } from './composables/useSyncErrors'
defineOptions({ name: 'SystemUserSync' })
const PAGE_SIZE = 20
const COUNT_KEYS = ['added', 'updated', 'disabled', 'failed'] as const
const { t } = useI18n()
/** 模板取值桥(ElTable 行槽位为 any,模板表达式不可写 as 断言) */
function runStateTagOf(state: Api.UserSync.RunState): 'success' | 'warning' | 'danger' | 'info' {
return SYNC_STATE_TAG_TYPE[state]
}
// ── 状态卡:手动加载 + 10s 自动刷新(页面存续期间) ──
const status = ref<Api.UserSync.Status | null>(null)
const statusLoading = ref(false)
const statusError = ref<Error | null>(null)
async function refreshStatus(): Promise<void> {
statusLoading.value = true
statusError.value = null
try {
status.value = await fetchUserSyncStatus()
} catch (e) {
statusError.value = e as Error
} finally {
statusLoading.value = false
}
}
const autoRefresh = createAutoRefresh(() => refreshStatus(), 10_000)
// ── 手动补拉:202 受理 → 轮询至离开 running → 刷新状态与历史 ──
const triggering = ref(false)
async function triggerSync(): Promise<void> {
triggering.value = true
try {
await fetchTriggerUserSyncRun()
ElMessage.info(t('userSync.status.triggerAccepted'))
const final = await waitSyncTerminal(fetchUserSyncStatus, { intervalMs: 1500 })
ElMessage.success(
final.state === 'failed'
? t('userSync.status.runFailed')
: t('userSync.status.runFinished', { state: t(`userSync.state.${final.state}`) })
)
status.value = final
await loadFirstRuns()
} catch {
// 受理/轮询失败已由 http 层弹出(waitSyncTerminal 超时为 Error('timeout')
// ——此处补一条本地化提示)
ElMessage.error(t('userSync.status.waitFailed'))
} finally {
triggering.value = false
}
}
// ── 运行历史(游标累积 + 加载更多) ──
const {
items: runs,
hasMore: runsHasMore,
loading: runsLoading,
error: runsError,
loadFirst: loadFirstRuns,
loadMore: loadMoreRuns
} = useCursorList<Api.UserSync.RunItem>(
(cursor?: string) => fetchUserSyncRuns({ cursor, limit: PAGE_SIZE }),
(item) => item.id
)
// ── 失败明细抽屉 ──
const errors = useSyncErrors()
function onRunClick(row: Api.UserSync.RunItem): void {
void errors.open(row.id)
}
onMounted(() => {
void refreshStatus()
void loadFirstRuns()
autoRefresh.start()
})
onUnmounted(() => {
autoRefresh.stop()
})
</script>