feat(admin): account governance and user sync pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
<!-- 用户同步管理页(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>
|
||||
Reference in New Issue
Block a user