fix(T+): 补齐指导成本与读取服务保护

补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。
同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。

定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
This commit is contained in:
liuwenxin committed 2026-11-05 19:32:20 +08:00
1 parent 68e70fd6e2
commit b8920c0d28
17 files changed
+375 -47

No files matched your search

+18
View File
@@ -90,6 +90,18 @@ export interface AutomationStatus {
next_retry_at: string | null
alerts: {code: string; message: string; batch_no?: string | null}[]
}
export interface RuntimeStatus {
desired_state: 'RUNNING' | 'STOPPED'
actual_state: 'RUNNING' | 'STOPPED' | 'STOPPING' | 'DEGRADED' | 'OFFLINE'
worker_phase: string
heartbeat_at: string | null
last_error: string | null
host: string | null
platform: string | null
version: number
updated_by: number | null
editable: boolean
}
export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number }
export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number }
export interface CreateTask { task_type: TaskType; entity_types: EntityType[]; request_key: string }
@@ -105,6 +117,12 @@ export function listBatches(params: BatchQuery, options: ReadOptions = {}): Prom
export function updateAutomation(data: { enabled: boolean; expected_version: number }): Promise<AutomationStatus> {
return request({ url: `${base}/automation`, method: 'put', data })
}
export function getRuntime(options: ReadOptions = {}): Promise<RuntimeStatus> {
return request({ url: `${base}/runtime`, ...options })
}
export function updateRuntime(data: { desired_state: 'RUNNING' | 'STOPPED'; expected_version: number }): Promise<RuntimeStatus> {
return request({ url: `${base}/runtime`, method: 'put', data })
}
/** 按需查询报告。 */
export function getBatch(number: string, options: ReadOptions = {}): Promise<TPlusDetail> {
return request({ url: `${base}/batches/${encodeURIComponent(number)}`, ...options })
+19 -3
View File
@@ -20,8 +20,8 @@ import { useSyncPage } from './useSyncPage'
const user = useUserStore()
const route = useRoute()
const {overview, records, total, pageNum, pageSize, status, taskType, loading, failed,
saving, refreshedAt, refresh, changeAutomation, search, onPageChange, onSizeChange} = useSyncPage()
const {overview, runtime, records, total, pageNum, pageSize, status, taskType, loading, failed,
saving, runtimeSaving, refreshedAt, refresh, changeAutomation, changeRuntime, search, onPageChange, onSizeChange} = useSyncPage()
const activeTab = ref('records'), creating = ref(false), retrying = ref<string | null>(null)
const submittedNumber = ref<string | null>(null)
const detailNumber = ref<string | null>(typeof route.query.batch === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(route.query.batch) ? route.query.batch : null)
@@ -32,6 +32,8 @@ const phases: Record<string, string> = {UNKNOWN:'暂未收到心跳', IDLE:'等
const connectionLabel = computed(() => !overview.value ? '正在检查连接配置' : overview.value.is_configured ? 'T+ 读取配置就绪' : '读取配置尚未就绪')
const canCreate = computed(() => user.can('tplus:collect') && overview.value?.is_configured && !failed.value)
const canConfigure = computed(() => user.can('tplus:collect') && automation.value?.editable && !failed.value)
const canControl = computed(() => user.can('tplus:collect') && runtime.value?.editable && !failed.value && !saving.value)
const runtimeLabels: Record<string, string> = { RUNNING: '运行中', STOPPED: '已停止', STOPPING: '正在停止', DEGRADED: '等待依赖', OFFLINE: '服务离线' }
const switchReason = computed(() => !user.can('tplus:collect') ? user.permissionReason('tplus:collect') : !automation.value?.editable ? '连接配置或设置服务尚未就绪,请联系维护人员' : '开关保存后自动生效,当前读取任务不会中断')
const alerts: Record<string, string> = {
MIGRATION_REQUIRED:'采集设置尚未就绪,请联系维护人员检查服务。',
@@ -77,11 +79,18 @@ async function retry(row: api.TPlusBatch): Promise<void> {
<section class="console-panel automation-panel" aria-labelledby="automation-title">
<div class="automation-title"><h2 id="automation-title">每周自动采集</h2><el-switch :model-value="!!automation?.enabled" :loading="saving" :disabled="!canConfigure" :title="switchReason" aria-label="自动采集开关" @change="changeAutomation" /></div>
<p class="automation-state" role="status">{{ saving ? '正在保存设置…' : automation?.enabled ? '自动采集已开启' : '自动采集已关闭' }}</p>
<dl class="schedule-details"><div><dt>采集时间</dt><dd>每周{{ weekdays[automation?.weekday ?? 0] }} {{ automation?.time ?? '02:00' }}<small>北京时间</small></dd></div><div><dt>读取服务</dt><dd>{{ phases[automation?.worker_phase ?? 'UNKNOWN'] }}</dd></div><div><dt>最近成功</dt><dd>{{ formatDateTime(automation?.last_success_at) }}</dd></div><div v-if="automation?.next_retry_at"><dt>下次重试</dt><dd>{{ formatDateTime(automation.next_retry_at) }}</dd></div></dl>
<dl class="schedule-details"><div><dt>采集时间</dt><dd>每周{{ weekdays[automation?.weekday ?? 0] }} {{ automation?.time ?? '02:00' }}<small>北京时间</small></dd></div><div><dt>读取阶段</dt><dd>{{ phases[runtime?.worker_phase ?? 'UNKNOWN'] }}</dd></div><div><dt>最近成功</dt><dd>{{ formatDateTime(automation?.last_success_at) }}</dd></div><div v-if="automation?.next_retry_at"><dt>下次重试</dt><dd>{{ formatDateTime(automation.next_retry_at) }}</dd></div></dl>
<p class="settings-help">开启后按周读取;关闭只停止后续自动排队,当前任务继续完成。手动同步仍可使用。</p>
<p v-if="!canConfigure" class="settings-help">{{ switchReason }}</p>
</section>
</div>
<section class="runtime-section" aria-labelledby="runtime-title">
<div class="runtime-heading"><h2 id="runtime-title">读取服务</h2><el-tag :type="runtime?.actual_state === 'RUNNING' ? 'success' : runtime?.actual_state === 'STOPPED' ? 'info' : 'warning'">{{ runtimeLabels[runtime?.actual_state ?? 'OFFLINE'] }}</el-tag></div>
<div class="runtime-details"><span>期望状态:{{ runtime?.desired_state === 'RUNNING' ? '运行' : '停止' }}</span><span>最近心跳:{{ formatDateTime(runtime?.heartbeat_at) }}</span><span v-if="runtime?.host">运行主机:{{ runtime.host }}({{ runtime.platform }})</span></div>
<p v-if="runtime?.last_error" class="runtime-error">{{ runtime.last_error }}</p>
<p v-if="!runtime?.editable" class="settings-help">服务管理尚未就绪,请联系维护人员。</p>
<div class="runtime-actions"><el-button type="primary" :disabled="!canControl || runtime?.desired_state === 'RUNNING'" :loading="runtimeSaving" @click="changeRuntime('RUNNING')">启动读取服务</el-button><el-button :disabled="!canControl || runtime?.desired_state === 'STOPPED'" :loading="runtimeSaving" @click="changeRuntime('STOPPED')">停止读取服务</el-button></div>
</section>
<el-alert v-for="alert in automation?.alerts ?? []" :key="alert.code" class="connection-alert" :title="alerts[alert.code] ?? '采集状态需要检查,请查看相关任务。'" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
<BusinessSyncPanel />
@@ -134,6 +143,13 @@ async function retry(row: api.TPlusBatch): Promise<void> {
.schedule-details dd { margin: 0; line-height: 1.65; font-variant-numeric: tabular-nums; }
.schedule-details small { margin-left: 6px; color: var(--ys-ink-2); font-size: 13px; }
.settings-help { font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); margin: 18px 0 0; }
.runtime-section { margin-top: 20px; padding: 18px 0; border-bottom: 1px solid var(--ys-line); }
.runtime-heading { display: flex; align-items: center; gap: 12px; }
.runtime-heading h2 { margin: 0; font-size: 16px; }
.runtime-details { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 12px; font-size: 13px; color: var(--ys-ink-2); }
.runtime-error { color: var(--el-color-danger); font-size: 13px; }
.runtime-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.runtime-actions .el-button + .el-button { margin-left: 0; }
.sync-boundary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px 20px; padding: 18px 0; font-size: 13px; line-height: 1.7; }
.sync-boundary b { font-weight: 600; }
.sync-boundary p { color: var(--ys-ink-2); margin: 4px 0 0; font-size: 12px; }
+25 -8
View File
@@ -6,10 +6,11 @@ import { handledError } from '@/utils/error'
/** 页面与筛选使用同一请求序号,后台刷新保留已显示数据和交互位置。 */
export function useSyncPage() {
const overview = ref<api.TPlusOverview | null>(null)
const runtime = ref<api.RuntimeStatus | null>(null)
const records = ref<api.TPlusBatch[]>([])
const total = ref(0), pageNum = ref(1), pageSize = ref(10)
const status = ref<api.TaskStatus>(), taskType = ref<api.TaskType>()
const loading = ref(false), failed = ref(false), saving = ref(false)
const loading = ref(false), failed = ref(false), saving = ref(false), runtimeSaving = ref(false)
const refreshedAt = ref<Date | null>(null)
let shownPage = 1, shownSize = 10, sequence = 0, alive = false
let timer: ReturnType<typeof setTimeout> | undefined
@@ -18,16 +19,17 @@ export function useSyncPage() {
function schedule(): void {
clearTimeout(timer)
if (!alive) return
const active = ['RUNNING', 'QUEUED'].includes(overview.value?.current_batch?.status ?? '')
const active = ['RUNNING', 'QUEUED'].includes(overview.value?.current_batch?.status ?? '') ||
(runtime.value?.actual_state !== 'OFFLINE' && runtime.value?.desired_state !== runtime.value?.actual_state)
timer = setTimeout(() => {
if (document.hidden || saving.value) schedule()
if (document.hidden || saving.value || runtimeSaving.value) schedule()
else void refresh(true)
}, active ? 5000 : 30000)
}
async function refresh(background = false): Promise<void> {
if (!alive) return
if (saving.value) { schedule(); return }
if (saving.value || runtimeSaving.value) { schedule(); return }
const current = ++sequence
controller?.abort()
controller = new AbortController()
@@ -35,9 +37,10 @@ export function useSyncPage() {
const query = {pageNum: pageNum.value, pageSize: pageSize.value, status: status.value, task_type: taskType.value}
const options = {silentError: background, signal: controller.signal}
try {
const [summary, page] = await Promise.all([api.getOverview(options), api.listBatches(query, options)])
const [summary, page, service] = await Promise.all([api.getOverview(options), api.listBatches(query, options), api.getRuntime(options)])
if (!alive || current !== sequence) return
overview.value = summary
runtime.value = service
records.value = page.list
total.value = page.total
shownPage = query.pageNum
@@ -57,6 +60,20 @@ export function useSyncPage() {
}
}
async function changeRuntime(desired_state: 'RUNNING' | 'STOPPED'): Promise<void> {
const current = runtime.value
if (!current || runtimeSaving.value || saving.value) return
runtimeSaving.value = true
++sequence
controller?.abort()
loading.value = false
try {
runtime.value = await api.updateRuntime({desired_state, expected_version: current.version})
ElMessage.success(desired_state === 'RUNNING' ? '已请求启动读取服务,等待状态更新' : '已请求停止读取服务,当前任务完成后停止')
} catch (error) { handledError(error) }
finally { runtimeSaving.value = false; if (alive) await refresh(true) }
}
async function changeAutomation(value: boolean | string | number): Promise<void> {
const config = overview.value?.automation
if (typeof value !== 'boolean' || !config || saving.value) return
@@ -80,9 +97,9 @@ export function useSyncPage() {
function search(): void { pageNum.value = 1; void refresh() }
function onPageChange(value: number): void { pageNum.value = value; void refresh() }
function onSizeChange(value: number): void { pageSize.value = value; pageNum.value = 1; void refresh() }
function visible(): void { if (!document.hidden && !saving.value) void refresh(true) }
function visible(): void { if (!document.hidden && !saving.value && !runtimeSaving.value) void refresh(true) }
onMounted(() => { alive = true; document.addEventListener('visibilitychange', visible); void refresh() })
onUnmounted(() => { alive = false; ++sequence; clearTimeout(timer); controller?.abort(); document.removeEventListener('visibilitychange', visible) })
return {overview, records, total, pageNum, pageSize, status, taskType, loading, failed,
saving, refreshedAt, refresh, changeAutomation, search, onPageChange, onSizeChange}
return {overview, runtime, records, total, pageNum, pageSize, status, taskType, loading, failed,
saving, runtimeSaving, refreshedAt, refresh, changeAutomation, changeRuntime, search, onPageChange, onSizeChange}
}