Files
photo-live/apps/admin/src/views/system/user-sync/index.vue
T
浅念 9471562f67 feat(ui06): B3d governance + B3e closing — page headings, stat cards, info strip, embed slots
- 治理页通用层(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>
2026-09-23 07:04:18 +08:00

327 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 用户同步管理页(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>