feat(delivery): refine base service integrations

This commit is contained in:
mac
2026-07-29 16:36:27 +08:00
parent 2ca08f9360
commit 35875218c9
22 changed files with 1042 additions and 377 deletions
+27
View File
@@ -14,6 +14,11 @@ export interface WayneNamespace {
kubeNamespace: string
}
export interface SinaOrganization {
id: string
name: string
}
export const businessLineApi = {
async listMine(): Promise<BusinessLine[]> {
const token = getToken()
@@ -87,6 +92,28 @@ export const businessLineApi = {
body: JSON.stringify({ namespaces }),
})
},
async listSinaOrganizations(businessLineId: number, keyword = ''): Promise<SinaOrganization[]> {
const params = new URLSearchParams()
if (keyword.trim()) {
params.set('keyword', keyword.trim())
}
const suffix = params.toString() ? `?${params.toString()}` : ''
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`)
return Array.isArray(data.items) ? data.items : []
},
async listMappedSinaOrganizations(businessLineId: number): Promise<SinaOrganization[]> {
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`)
return Array.isArray(data.items) ? data.items : []
},
async replaceMappedSinaOrganizations(businessLineId: number, organizations: SinaOrganization[]): Promise<void> {
await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, {
method: 'PUT',
body: JSON.stringify({ organizations }),
})
},
}
async function request(path: string, init: RequestInit = {}) {
+13 -3
View File
@@ -66,6 +66,7 @@ export interface DeliveryTask {
updated_at: string
started_at?: string
finished_at?: string
credential_available?: boolean
}
export interface TaskEvent {
@@ -85,8 +86,12 @@ export interface DeliveryTaskSnapshot {
}
export interface DeploymentCredential {
username: string
service: string
instance_name: string
host: string
port: number
username: string
account_host: string
password: string
}
@@ -109,8 +114,13 @@ export const deliveryApi = {
return Array.isArray(data.items) ? data.items : []
},
async listTargetMountPaths(targetId: number, host: string): Promise<DeliveryMountPath[]> {
const data = await authRequest(`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths`)
async listTargetMountPaths(targetId: number, host: string, prefix = ''): Promise<DeliveryMountPath[]> {
const search = new URLSearchParams()
if (prefix) search.set('prefix', prefix)
const query = search.toString()
const data = await authRequest(
`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths${query ? `?${query}` : ''}`,
)
return Array.isArray(data.items) ? data.items : []
},
+7 -4
View File
@@ -46,13 +46,13 @@ export interface MachineResourceList {
export interface MachineResourceQuery {
page: number
size: number
businessLineId?: number
hostname?: string
assetNumber?: string
type?: string
location?: string
ip?: string
spec?: string
businessLine?: string
source?: string
status?: string
}
@@ -83,21 +83,24 @@ export const emptyMachineOverview: MachineOverview = {
}
export const machineApi = {
async getOverview(): Promise<MachineOverview> {
return authRequest('/auth/api/v1/machines/overview')
async getOverview(businessLineId?: number): Promise<MachineOverview> {
const params = new URLSearchParams()
if (businessLineId) params.set('business_line_id', String(businessLineId))
const suffix = params.toString() ? `?${params.toString()}` : ''
return authRequest(`/auth/api/v1/machines/overview${suffix}`)
},
async listResources(query: MachineResourceQuery): Promise<MachineResourceList> {
const params = new URLSearchParams()
params.set('page', String(query.page))
params.set('size', String(query.size))
if (query.businessLineId) params.set('business_line_id', String(query.businessLineId))
if (query.hostname) params.set('hostname', query.hostname)
if (query.assetNumber) params.set('assetNumber', query.assetNumber)
if (query.type) params.set('type', query.type)
if (query.location) params.set('location', query.location)
if (query.ip) params.set('ip', query.ip)
if (query.spec) params.set('spec', query.spec)
if (query.businessLine) params.set('businessLine', query.businessLine)
if (query.source) params.set('source', query.source)
if (query.status) params.set('status', query.status)
const data = await authRequest(`/auth/api/v1/machines/resources?${params.toString()}`)
+105 -1
View File
@@ -45,6 +45,34 @@
</el-form-item>
<el-button type="primary" :loading="savingWayneNamespaces" @click="saveWayneNamespaceMapping">保存 Wayne namespace 映射</el-button>
</el-form>
<div class="section-divider"></div>
<el-form label-position="top">
<el-form-item label="SINA 映射">
<el-select
v-model="selectedSinaOrganizationIds"
multiple
filterable
remote
reserve-keyword
collapse-tags
collapse-tags-tooltip
:loading="loadingSinaOrganizations"
:remote-method="searchSinaOrganizations"
placeholder="选择 SINA 业务线"
@visible-change="handleSinaSelectVisible"
>
<el-option
v-for="item in sinaOrganizations"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-button type="primary" :loading="savingSinaOrganizations" @click="saveSinaOrganizationMapping">保存 SINA 映射</el-button>
</el-form>
</div>
<div v-else class="empty-state">需要当前业务线管理员权限</div>
</section>
@@ -53,7 +81,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { businessLineApi, type WayneNamespace } from '@/api/businessLine'
import { businessLineApi, type SinaOrganization, type WayneNamespace } from '@/api/businessLine'
import { userApi, type UserOption } from '@/api/user'
import { useBusinessLineStore } from '@/stores/businessLine'
@@ -63,9 +91,13 @@ const currentName = computed(() => businessLineStore.current?.name || '未选择
const users = ref<UserOption[]>([])
const wayneNamespaces = ref<WayneNamespace[]>([])
const selectedWayneNamespaceIds = ref<number[]>([])
const sinaOrganizations = ref<SinaOrganization[]>([])
const selectedSinaOrganizationIds = ref<string[]>([])
const granting = ref(false)
const loadingWayneNamespaces = ref(false)
const savingWayneNamespaces = ref(false)
const loadingSinaOrganizations = ref(false)
const savingSinaOrganizations = ref(false)
const grantForm = reactive<{
target_user_id: number | null
target_business_line_id: number | null
@@ -82,6 +114,7 @@ watch(
}
if (id && isCurrentBusinessLineAdmin.value) {
loadWayneNamespaceMapping(id)
loadSinaOrganizationMapping(id)
}
},
{ immediate: true },
@@ -98,6 +131,7 @@ watch(
const businessLineID = businessLineStore.current?.id
if (businessLineID) {
await loadWayneNamespaceMapping(businessLineID)
await loadSinaOrganizationMapping(businessLineID)
}
}
},
@@ -113,6 +147,7 @@ onMounted(async () => {
const businessLineID = businessLineStore.current?.id
if (businessLineID) {
await loadWayneNamespaceMapping(businessLineID)
await loadSinaOrganizationMapping(businessLineID)
}
}
})
@@ -182,6 +217,75 @@ async function saveWayneNamespaceMapping() {
savingWayneNamespaces.value = false
}
}
async function handleSinaSelectVisible(visible: boolean) {
if (!visible || sinaOrganizations.value.length) {
return
}
await searchSinaOrganizations('')
}
async function searchSinaOrganizations(keyword: string) {
const businessLineID = businessLineStore.current?.id
if (!businessLineID) {
return
}
loadingSinaOrganizations.value = true
try {
const rows = await businessLineApi.listSinaOrganizations(businessLineID, keyword)
sinaOrganizations.value = mergeSinaOrganizations(rows, selectedSinaOrganizations.value)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '查询 SINA 业务线失败')
} finally {
loadingSinaOrganizations.value = false
}
}
const selectedSinaOrganizations = computed(() =>
sinaOrganizations.value.filter((item) => selectedSinaOrganizationIds.value.includes(item.id)),
)
async function loadSinaOrganizationMapping(businessLineID: number) {
loadingSinaOrganizations.value = true
try {
const mapped = await businessLineApi.listMappedSinaOrganizations(businessLineID)
sinaOrganizations.value = mergeSinaOrganizations(sinaOrganizations.value, mapped)
selectedSinaOrganizationIds.value = mapped.map((item) => item.id)
} catch (error) {
selectedSinaOrganizationIds.value = []
ElMessage.error(error instanceof Error ? error.message : '查询 SINA 映射失败')
} finally {
loadingSinaOrganizations.value = false
}
}
async function saveSinaOrganizationMapping() {
const businessLineID = businessLineStore.current?.id
if (!businessLineID) {
ElMessage.warning('请选择当前业务线')
return
}
const selected = sinaOrganizations.value.filter((item) => selectedSinaOrganizationIds.value.includes(item.id))
savingSinaOrganizations.value = true
try {
await businessLineApi.replaceMappedSinaOrganizations(businessLineID, selected)
ElMessage.success('已保存 SINA 映射')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存 SINA 映射失败')
} finally {
savingSinaOrganizations.value = false
}
}
function mergeSinaOrganizations(...groups: SinaOrganization[][]) {
const rows = new Map<string, SinaOrganization>()
for (const group of groups) {
for (const item of group) {
rows.set(item.id, item)
}
}
return Array.from(rows.values())
}
</script>
<style scoped>
+25 -17
View File
@@ -51,7 +51,6 @@
<th>机房 / 区域</th>
<th>内网 IP</th>
<th>规格</th>
<th>业务线</th>
<th>数据来源</th>
<th>状态</th>
</tr>
@@ -112,16 +111,6 @@
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-input
v-model="filters.businessLine"
clearable
size="small"
placeholder="筛选业务线"
@clear="reloadFirstPage"
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-select v-model="filters.source" clearable size="small" placeholder="全部" @change="reloadFirstPage">
<el-option label="SINA CMDB" value="cmdb" />
@@ -149,12 +138,11 @@
<td><span class="tag zone-a">{{ item.location }}</span></td>
<td class="mono">{{ item.ip }}</td>
<td class="mono text-xs">{{ item.spec }}</td>
<td class="mono">{{ item.businessLine }}</td>
<td><span class="tag src-cmdb">{{ item.source }}</span></td>
<td class="status-text ok">● {{ item.status }}</td>
</tr>
<tr v-if="!loading && resources.length === 0">
<td colspan="9" class="empty-cell">暂无机器数据</td>
<td colspan="8" class="empty-cell">当前业务线暂无机器数据</td>
</tr>
</tbody>
</table>
@@ -177,10 +165,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { emptyMachineOverview, machineApi, type MachineResource } from '@/api/machine'
import { useBusinessLineStore } from '@/stores/businessLine'
const businessLineStore = useBusinessLineStore()
const overview = ref(emptyMachineOverview)
const resources = ref<MachineResource[]>([])
const total = ref(0)
@@ -195,7 +185,6 @@ const filters = reactive({
location: '',
ip: '',
spec: '',
businessLine: '',
source: '',
status: '',
})
@@ -207,22 +196,33 @@ const syncStatusText = computed(() => {
})
async function loadOverview() {
overview.value = await machineApi.getOverview()
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
overview.value = emptyMachineOverview
return
}
overview.value = await machineApi.getOverview(businessLineId)
}
async function loadResources() {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
resources.value = []
total.value = 0
return
}
loading.value = true
try {
const data = await machineApi.listResources({
page: page.value,
size: size.value,
businessLineId,
hostname: filters.hostname.trim(),
assetNumber: filters.assetNumber.trim(),
type: filters.type,
location: filters.location.trim(),
ip: filters.ip.trim(),
spec: filters.spec.trim(),
businessLine: filters.businessLine.trim(),
source: filters.source,
status: filters.status,
})
@@ -252,6 +252,14 @@ async function handleSync() {
onMounted(async () => {
await Promise.all([loadOverview(), loadResources()])
})
watch(
() => businessLineStore.current?.id,
() => {
page.value = 1
Promise.all([loadOverview(), loadResources()])
},
)
</script>
<style scoped>
+152 -23
View File
@@ -176,7 +176,20 @@
<div class="form-grid resource-fields">
<label class="form-field" :class="{ 'is-invalid': dataDiskError }">
数据盘挂载点
<el-input v-model="deliveryForm.dataDisk" placeholder="/data" />
<el-autocomplete
v-model="deliveryForm.dataDisk"
:fetch-suggestions="queryMountPathSuggestions"
placeholder="/data"
:loading="mountPathsLoading"
clearable
>
<template #default="{ item }">
<div class="mount-path-option">
<span>{{ item.path }}</span>
<small>{{ mountPathMeta(item) }}</small>
</div>
</template>
</el-autocomplete>
<small v-if="dataDiskError" class="field-error">{{ dataDiskError }}</small>
<small v-else class="field-help">填写目标主机上的绝对挂载点,例如 /data 或 /data/ax;实例数据会写入其下的标准目录。</small>
</label>
@@ -415,10 +428,20 @@
<p>请立即复制或下载凭证文件;关闭后页面不再显示明文密码。</p>
<el-button v-if="!credentialRevealed" type="warning" :loading="credentialRevealing" @click="revealCredential">查看一次性密码</el-button>
<div v-else class="credential-secret">
<code>{{ revealedRootCredential?.password }}</code>
<div v-for="credential in revealedCredentials" :key="`${credential.username}@${credential.account_host || credential.host}`" class="credential-account">
<div class="credential-field">
<span>用户名</span>
<code>{{ formatCredentialUsername(credential) }}</code>
<el-button text :icon="CopyDocument" aria-label="复制用户名" title="复制用户名" @click="copyText(formatCredentialUsername(credential), '用户名已复制')" />
</div>
<div class="credential-field">
<span>密码</span>
<code>{{ credential.password }}</code>
<el-button text :icon="CopyDocument" aria-label="复制密码" title="复制密码" @click="copyText(credential.password, '密码已复制')" />
</div>
</div>
<div class="credential-actions">
<el-button :icon="CopyDocument" @click="copyText(revealedRootCredential?.password || '', 'root 密码已复制')">复制密码</el-button>
<el-button :icon="Download" @click="downloadCredentialFile">下载凭证文件</el-button>
<el-button :icon="Download" @click="downloadCredentialFile">下载 Excel 凭证</el-button>
<el-button type="success" plain :icon="CircleCheck" @click="dismissCredential">已保存,关闭</el-button>
</div>
</div>
@@ -495,7 +518,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown, Back, CircleCheck, Connection, CopyDocument, DocumentCopy, Download, Monitor, Promotion, Refresh, Setting, Tickets } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { deliveryApi, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
import { useAuthStore } from '@/stores/auth'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -780,6 +803,8 @@ const targetsLoading = ref(false)
const pollTimer = ref<number | undefined>()
const seenEventIds = ref(new Set<number>())
const deliveryLog = ref('[ready] 等待创建交付任务...')
const mountPathsLoading = ref(false)
let mountPathSuggestSeq = 0
const lastDeliveryStatus = ref('')
const deliveryRolledBack = computed(() => lastDeliveryStatus.value === 'rolled_back')
const deliveryAcknowledged = computed(() => lastDeliveryStatus.value === 'rollback_acknowledged')
@@ -1536,6 +1561,48 @@ function mysqlVersionValue(version: string) {
return matched.split('.').slice(0, 2).join('.')
}
function mountPathMeta(item: DeliveryMountPath) {
const parts = []
if (item.available_gi > 0) parts.push(`可用 ${item.available_gi}GiB`)
if (item.fstype) parts.push(item.fstype)
return parts.join(' · ')
}
function queryMountPathSuggestions(query: string, callback: (items: Array<DeliveryMountPath & { value: string }>) => void) {
const prefix = query.trim()
if (!prefix) {
callback(defaultMountPathOptions().map((item) => ({ ...item, value: item.path })))
return
}
const host = deliveryForm.targetHost || targetHosts.value[0]?.name || ''
if (!selectedTargetId.value || !host || !prefix.startsWith('/')) {
callback([])
return
}
const seq = ++mountPathSuggestSeq
mountPathsLoading.value = true
deliveryApi
.listTargetMountPaths(selectedTargetId.value, host, prefix)
.then((items) => {
if (seq !== mountPathSuggestSeq) return
callback(items.map((item) => ({ ...item, value: item.path })))
})
.catch(() => {
if (seq !== mountPathSuggestSeq) return
callback([])
})
.finally(() => {
if (seq === mountPathSuggestSeq) {
mountPathsLoading.value = false
}
})
}
function defaultMountPathOptions(): DeliveryMountPath[] {
return ['/data', '/disk1', '/mnt', '/opt/mysql-delivery'].map((path) => ({ path, available_gi: 0 }))
}
function normalizeDNSLabel(value: string) {
const normalized = value
.toLowerCase()
@@ -1552,7 +1619,6 @@ function generateRootPassword() {
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'abcdefghijkmnopqrstuvwxyz',
'23456789',
'!@#$%^&*_-+=',
]
const alphabet = groups.join('')
const pick = (characters: string) => {
@@ -1576,8 +1642,9 @@ function generateRootPassword() {
function rootPasswordValidationError(value: string) {
if (!value) return '请输入 root 密码'
if (value.length < 16 || value.length > 64) return '密码长度必须为 16–64 位'
if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value) || !/[^A-Za-z0-9]/.test(value)) {
return '密码需同时包含大小写字母、数字和特殊字符'
if (!/^[A-Za-z0-9]+$/.test(value)) return '密码只能包含大小写字母和数字'
if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value)) {
return '密码需同时包含大小写字母和数字'
}
return ''
}
@@ -1606,7 +1673,15 @@ async function revealCredential() {
credentialRevealing.value = true
try {
if (deploymentId.value === 'PREVIEW-CREDENTIAL') {
revealedCredentials.value = [{ username: 'root', host: 'localhost', password: deliveryForm.rootPassword }]
revealedCredentials.value = [{
service: 'mysql',
instance_name: deliveryForm.instanceName,
host: deliveredHost.value || '10.24.18.21',
port: deliveredPort.value || 13306,
username: 'root',
account_host: 'localhost',
password: deliveryForm.rootPassword,
}]
} else {
revealedCredentials.value = await deliveryApi.revealCredentials(deploymentId.value)
}
@@ -1628,24 +1703,55 @@ function dismissCredential() {
revealedCredentials.value = []
}
function formatCredentialUsername(credential: DeploymentCredential) {
return credential.account_host ? `${credential.username}@${credential.account_host}` : credential.username
}
function escapeExcelCell(value: unknown) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function downloadCredentialFile() {
const password = revealedRootCredential.value?.password
if (!credentialRevealed.value || !password) return
const content = [
'# XInfra MySQL delivery credential',
`instance=${deliveryForm.instanceName}`,
`host=${deliveredHost.value}`,
`port=${deliveredPort.value || ''}`,
'username=root',
`password=${password}`,
].join('\n') + '\n'
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }))
if (!credentialRevealed.value || !revealedCredentials.value.length) return
const primary = revealedRootCredential.value || revealedCredentials.value[0]
const headers = ['服务', '实例名称', '主机', '端口', '用户名', '账号 Host', '密码']
const rows = revealedCredentials.value.map((credential) => [
credential.service || activeService.value?.key || 'mysql',
credential.instance_name || deliveryForm.instanceName,
credential.host || deliveredHost.value,
credential.port || deliveredPort.value || '',
credential.username,
credential.account_host || '',
credential.password,
])
const tableRows = [headers, ...rows]
.map((row) => `<tr>${row.map((cell) => `<td>${escapeExcelCell(cell)}</td>`).join('')}</tr>`)
.join('')
const content = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
table { border-collapse: collapse; }
td { border: 1px solid #d9e2ef; padding: 8px; mso-number-format: "\\@"; }
</style>
</head>
<body>
<table>${tableRows}</table>
</body>
</html>`
const url = URL.createObjectURL(new Blob([content], { type: 'application/vnd.ms-excel;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `${deliveryForm.instanceName}-root-credential.txt`
link.download = `${primary.instance_name || deliveryForm.instanceName}-credential.xls`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('凭证文件已下载,请妥善保管')
ElMessage.success('Excel 凭证已下载,请妥善保管')
}
function loadDeliveryHistory(): DeliveryHistoryItem[] {
@@ -2795,7 +2901,25 @@ h4 {
gap: 10px;
}
.credential-secret > code {
.credential-account {
display: grid;
gap: 8px;
}
.credential-field {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 32px;
align-items: center;
gap: 8px;
}
.credential-field > span {
color: var(--text-dim);
font-size: 11.5px;
font-weight: 700;
}
.credential-field > code {
overflow-wrap: anywhere;
padding: 9px 10px;
border: 1px solid var(--line-soft);
@@ -2805,6 +2929,11 @@ h4 {
font-size: 12px;
}
.credential-field .el-button {
width: 32px;
min-width: 32px;
}
.credential-actions {
display: flex;
flex-wrap: wrap;
+16 -9
View File
@@ -223,7 +223,7 @@
<p>凭证仅可领取一次,领取后服务端立即销毁明文。</p>
<el-button type="warning" :loading="credentialRevealing" @click="revealDeliveryCredential(selectedDeliveryTask)">领取一次性凭证</el-button>
</template>
<p v-else-if="consumedCredentialTasks.has(selectedDeliveryTask.id)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
<p v-else-if="['finished', 'register_failed'].includes(selectedDeliveryTask.status)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
<p v-else>仅交付成功的任务提供一次性凭证。</p>
</div>
</template>
@@ -266,7 +266,6 @@ const deliveryDetailVisible = ref(false)
const selectedDeliveryTask = ref<DeliveryTask>()
const revealedCredentials = ref<DeploymentCredential[]>([])
const credentialRevealing = ref(false)
const consumedCredentialTasks = ref<Set<string>>(new Set())
const containerServices = ref<ContainerWorkload[]>([])
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
@@ -354,32 +353,40 @@ function deliveryTasksForService(serviceName: string) {
}
function credentialEligible(task: DeliveryTask) {
return ['finished', 'register_failed'].includes(task.status) && !consumedCredentialTasks.value.has(task.id)
return ['finished', 'register_failed'].includes(task.status) && Boolean(task.credential_available)
}
function credentialStatusText(task: DeliveryTask) {
if (consumedCredentialTasks.value.has(task.id)) return '已领取'
return credentialEligible(task) ? '可领取' : '不提供'
if (credentialEligible(task)) return '可领取'
if (['finished', 'register_failed'].includes(task.status)) return '已领取'
return '不提供'
}
function credentialStatusClass(task: DeliveryTask) {
if (consumedCredentialTasks.value.has(task.id)) return 'tag-green'
return credentialEligible(task) ? 'tag-amber' : ''
}
function markCredentialUnavailable(taskID: string) {
const task = deliveryRecords.value.find((item) => item.id === taskID)
if (task) task.credential_available = false
if (selectedDeliveryTask.value?.id === taskID) {
selectedDeliveryTask.value.credential_available = false
}
}
async function revealDeliveryCredential(task: DeliveryTask) {
credentialRevealing.value = true
try {
const items = await deliveryApi.revealCredentials(task.id)
if (items.length === 0) {
consumedCredentialTasks.value.add(task.id)
markCredentialUnavailable(task.id)
ElMessage.warning('该任务没有可领取的凭证')
return
}
revealedCredentials.value = items
consumedCredentialTasks.value.add(task.id)
markCredentialUnavailable(task.id)
} catch (error) {
consumedCredentialTasks.value.add(task.id)
markCredentialUnavailable(task.id)
ElMessage.error(error instanceof Error ? error.message : '凭证领取失败')
} finally {
credentialRevealing.value = false
+8 -199
View File
@@ -42,11 +42,7 @@
暂无任务记录
</div>
<div class="pagination">
<span>共 {{ totalTasks }} 条 · 第 {{ currentPage }}/{{ totalPages }} 页 · 当前业务线:{{ currentName }}</span>
<div class="pagination-actions">
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上一页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下一页</el-button>
</div>
<span>共 {{ tasks.length }} 条 · 当前业务线:{{ currentName }}</span>
</div>
</div>
</div>
@@ -56,29 +52,11 @@
<h3>
<span>任务日志</span>
<span class="selected-name">{{ selectedTaskName }}</span>
<span v-if="isStreaming" class="streaming-indicator">● 实时更新中</span>
<span v-if="streamRetryCount > 0" class="retry-indicator">重试中 ({{ streamRetryCount }}/3)</span>
</h3>
<div class="panel-actions">
<el-switch
v-model="autoScroll"
active-text="自动滚动"
class="auto-scroll-switch"
/>
<span class="meta">{{ selectedTaskMeta }}</span>
</div>
<span class="meta">{{ selectedTaskMeta }}</span>
</div>
<div
ref="logContainerRef"
class="panel-body log-stream"
@scroll="handleScroll"
>
<!-- 简单滚动容器 -->
<div
v-for="(log, index) in logs"
:key="index"
:class="['task-log-line', log.class]"
>
<div class="panel-body log-stream">
<div v-for="(log, index) in logs" :key="index" :class="['task-log-line', log.class]">
<span class="t">{{ log.time }}</span>{{ log.message }}
</div>
<div v-if="loadingLogs" class="task-log-line">
@@ -94,8 +72,8 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { taskLogApi, createTaskLogStream, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
import { computed, onMounted, ref, watch } from 'vue'
import { taskLogApi, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -109,21 +87,6 @@ const logs = ref<TaskLogLine[]>([])
const loadingTasks = ref(false)
const loadingLogs = ref(false)
const lastLoadedAt = ref<Date | null>(null)
const isStreaming = ref(false)
let eventSource: EventSource | null = null
const autoScroll = ref(true)
const logContainerRef = ref<HTMLDivElement | null>(null)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(20)
const totalTasks = ref(0)
const totalPages = computed(() => Math.ceil(totalTasks.value / pageSize.value))
// SSE 重试相关
const streamRetryCount = ref(0)
const maxRetryCount = 3
let retryTimeout: ReturnType<typeof setTimeout> | null = null
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
const selectedTaskName = computed(() => selectedTask.value?.name || '未选择')
@@ -133,14 +96,11 @@ const lastLoadedText = computed(() => lastLoadedAt.value ? `更新于 ${formatTi
async function loadTasks() {
loadingTasks.value = true
try {
const result = await taskLogApi.list({
const data = await taskLogApi.list({
source: sourceFilter.value,
businessLineId: businessLineStore.current?.id,
page: currentPage.value,
pageSize: pageSize.value,
})
tasks.value = result.items
totalTasks.value = result.total
tasks.value = data.items
lastLoadedAt.value = new Date()
if (!tasks.value.some((task) => task.id === selectedTaskId.value)) {
selectedTaskId.value = tasks.value[0]?.id || ''
@@ -163,9 +123,6 @@ async function loadLogs(taskId: string) {
const data = await taskLogApi.get(taskId)
logs.value = data.lines
lastLoadedAt.value = new Date()
scrollToBottom()
// 启动 SSE 流式更新
startStream(taskId)
} catch (error) {
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
} finally {
@@ -173,93 +130,10 @@ async function loadLogs(taskId: string) {
}
}
function startStream(taskId: string) {
stopStream() // 关闭之前的连接
streamRetryCount.value = 0
eventSource = createTaskLogStream(taskId, {
onInit: (lines) => {
logs.value = lines
lastLoadedAt.value = new Date()
scrollToBottom()
},
onUpdate: (newLines) => {
logs.value = newLines
lastLoadedAt.value = new Date()
scrollToBottom()
},
onFinished: () => {
isStreaming.value = false
streamRetryCount.value = 0
},
onError: (error) => {
console.error('SSE error:', error)
isStreaming.value = false
// 尝试重连
retryStream(taskId)
},
})
isStreaming.value = true
}
function retryStream(taskId: string) {
if (streamRetryCount.value >= maxRetryCount) {
console.error('Max retry count reached')
return
}
streamRetryCount.value++
// 指数退避:1s, 2s, 4s
const delay = Math.pow(2, streamRetryCount.value - 1) * 1000
retryTimeout = setTimeout(() => {
startStream(taskId)
}, delay)
}
function stopStream() {
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
isStreaming.value = false
}
}
function scrollToBottom() {
if (!autoScroll.value) return
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
}
})
}
function handleScroll() {
if (!logContainerRef.value) return
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.value
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 距离底部小于 50px 时认为在底部,恢复自动滚动
if (distanceFromBottom < 50) {
autoScroll.value = true
} else if (distanceFromBottom > 100) {
// 用户向上滚动超过 100px 时暂停自动滚动
autoScroll.value = false
}
}
function selectTask(taskId: string) {
if (selectedTaskId.value === taskId) {
return
}
stopStream() // 停止之前的流
selectedTaskId.value = taskId
void loadLogs(taskId)
}
@@ -268,20 +142,6 @@ function refreshCurrent() {
void loadTasks()
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
void loadTasks()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
void loadTasks()
}
}
function formatTime(date: Date) {
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
@@ -289,7 +149,6 @@ function formatTime(date: Date) {
watch(
() => businessLineStore.current?.id,
() => {
currentPage.value = 1
void loadTasks()
},
)
@@ -297,10 +156,6 @@ watch(
onMounted(() => {
void loadTasks()
})
onUnmounted(() => {
stopStream()
})
</script>
<style scoped>
@@ -394,7 +249,6 @@ onUnmounted(() => {
.task-log-panel .panel-head {
gap: 14px;
flex-wrap: wrap;
}
.task-log-panel .panel-head h3 {
@@ -404,36 +258,11 @@ onUnmounted(() => {
gap: 10px;
}
.task-log-panel .panel-head .panel-actions {
margin-left: auto;
}
.selected-name {
color: var(--text-dim);
font-weight: 500;
}
.streaming-indicator {
font-size: 11px;
color: var(--accent);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.panel-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auto-scroll-switch {
--el-switch-on-color: var(--accent);
}
.log-stream {
max-height: calc(100vh - 210px);
min-height: 520px;
@@ -458,26 +287,6 @@ onUnmounted(() => {
font-size: 13px;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
font-size: 12px;
color: var(--text-dim);
}
.pagination-actions {
display: flex;
gap: 8px;
}
.retry-indicator {
font-size: 11px;
color: var(--warn);
animation: pulse 1s ease-in-out infinite;
}
@media (max-width: 1180px) {
.task-layout {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);