9471562f67
- 治理页通用层(ui06.scss):.ui06-page 工作区 / .ui06-page-heading 27px 页头 / .ui06-stats 三列统计卡 / .ui06-info-strip 提示条 / .ui06-card-title 17px 卡头 - 10 账号:页头(查看同步入口)+ 来源提示条;分页/同步档案列/G4 功能零改动 - 11 同步:页头(手动补拉上移);状态卡/历史/失败明细抽屉保留 - 12 用量:页头(阈值设置上移)+ 统计卡对齐示例(数字 27px + 注脚 10px) - 13 展示位:页头 + 卡片标题(配置对话框沿用 UI-04 真实组件) - 17-19 异常页:沿用 ArtException(大数字+文案+回退,语义等同示例;登记偏差) - i18n zh/en;typecheck/lint/vitest/build 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
327 lines
12 KiB
Vue
327 lines
12 KiB
Vue
<!-- 用户同步管理页(P4a Task 8):状态卡(引擎状态/水位/计数 + 手动补拉)、
|
||
运行历史(游标累积)、失败明细抽屉(点行打开)。页面存续期间 10s 自动
|
||
刷新状态(onUnmounted 清理);手动补拉 202 受理后轮询至批次离开 running。 -->
|
||
<template>
|
||
<div class="ui06-page">
|
||
<!-- 页头(示例 11 .page-heading:标题 + 副文 + 手动补拉入口上移) -->
|
||
<div class="ui06-page-heading">
|
||
<div>
|
||
<h1>{{ $t('userSync.page.title') }}</h1>
|
||
<p>{{ $t('userSync.page.subtitle') }}</p>
|
||
</div>
|
||
<div class="actions">
|
||
<ElButton
|
||
type="primary"
|
||
:loading="triggering"
|
||
:disabled="status?.state === 'running'"
|
||
data-test="sync-trigger-btn"
|
||
@click="triggerSync"
|
||
>
|
||
{{ $t('userSync.status.trigger') }}
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── 状态卡 ── -->
|
||
<ElCard data-test="sync-status-card" class="mb-4">
|
||
<template #header>
|
||
<div class="flex items-center justify-between">
|
||
<span class="ui06-card-title">{{ $t('userSync.status.title') }}</span>
|
||
</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>
|