1354 lines
41 KiB
Vue
1354 lines
41 KiB
Vue
<template>
|
|
<section class="page factory-page">
|
|
<div class="factory-shell">
|
|
<header class="factory-page-header">
|
|
<div class="factory-heading">
|
|
<div class="factory-title-row">
|
|
<h1>Factory 版本工作台</h1>
|
|
<el-tag v-if="versions.length" effect="plain">{{ versions.length }} 个版本</el-tag>
|
|
</div>
|
|
<p>{{ project?.projectName || `项目 ${projectId}` }}</p>
|
|
</div>
|
|
<div class="toolbar factory-header-actions">
|
|
<el-button :icon="ArrowLeft" @click="router.push('/projects')">返回项目</el-button>
|
|
<el-button :icon="Refresh" :loading="pageLoading" @click="refreshWorkspace">刷新</el-button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-loading="pageLoading" class="factory-workspace">
|
|
<aside class="version-pane" aria-label="ProjectSpec 版本历史">
|
|
<div class="pane-header">
|
|
<div>
|
|
<h2>版本历史</h2>
|
|
<span>不可变 ProjectSpec</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="versions.length" class="version-list" role="list">
|
|
<button
|
|
v-for="(version, index) in versions"
|
|
:key="version.specVersionId"
|
|
type="button"
|
|
class="version-row"
|
|
:class="{ 'version-row--selected': version.specVersionId === selectedVersionId }"
|
|
:aria-current="version.specVersionId === selectedVersionId ? 'true' : undefined"
|
|
@click="selectVersion(version.specVersionId)"
|
|
>
|
|
<span class="version-row-top">
|
|
<strong>v{{ version.versionNo }}</strong>
|
|
<span class="version-row-tags">
|
|
<el-tag v-if="index === 0" size="small" type="success">当前</el-tag>
|
|
<el-tag size="small" :type="sourceTagType(version.changeSource)" effect="plain">
|
|
{{ sourceLabel(version.changeSource) }}
|
|
</el-tag>
|
|
</span>
|
|
</span>
|
|
<span class="version-summary">{{ version.changeSummary || '无变更摘要' }}</span>
|
|
<span class="version-meta">
|
|
<code>{{ shortHash(version.contentHash) }}</code>
|
|
<time>{{ version.createTime || '-' }}</time>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<el-empty v-else :image-size="72" description="暂无 ProjectSpec 版本" />
|
|
</aside>
|
|
|
|
<main class="detail-pane">
|
|
<template v-if="selectedVersion">
|
|
<div class="selected-version-header">
|
|
<div class="selected-version-title">
|
|
<div class="selected-title-line">
|
|
<h2>v{{ selectedVersion.versionNo }}</h2>
|
|
<el-tag :type="selectedIsLatest ? 'success' : 'info'">
|
|
{{ selectedIsLatest ? '当前版本' : '历史版本' }}
|
|
</el-tag>
|
|
<el-tag :type="validationTag.type" effect="plain">{{ validationTag.label }}</el-tag>
|
|
</div>
|
|
<p>{{ selectedVersion.changeSummary || '无变更摘要' }}</p>
|
|
<div class="selected-hash">
|
|
<span>Spec SHA-256</span>
|
|
<code>{{ selectedVersion.contentHash }}</code>
|
|
<el-tooltip content="复制哈希" placement="top">
|
|
<el-button
|
|
:icon="CopyDocument"
|
|
circle
|
|
text
|
|
aria-label="复制 Spec 哈希"
|
|
@click="copyText(selectedVersion.contentHash, 'Spec 哈希已复制')"
|
|
/>
|
|
</el-tooltip>
|
|
</div>
|
|
</div>
|
|
<div class="selected-version-actions">
|
|
<el-button
|
|
type="primary"
|
|
:icon="VideoPlay"
|
|
:loading="generating"
|
|
@click="generateSelectedVersion"
|
|
>
|
|
生成并校验指纹
|
|
</el-button>
|
|
<el-button
|
|
:icon="RefreshLeft"
|
|
:disabled="selectedIsLatest"
|
|
:loading="rollingBack"
|
|
@click="rollbackSelectedVersion"
|
|
>
|
|
回滚为新版本
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<el-tabs v-model="activeTab" class="factory-tabs">
|
|
<el-tab-pane label="ProjectSpec" name="spec">
|
|
<div v-loading="detailLoading" class="tab-content spec-tab">
|
|
<div class="tab-toolbar">
|
|
<div class="tab-toolbar-title">
|
|
<strong>规范化 JSON</strong>
|
|
<span>Schema {{ selectedDetail?.schemaVersion || '-' }}</span>
|
|
</div>
|
|
<el-tooltip content="复制 ProjectSpec" placement="top">
|
|
<el-button
|
|
:icon="CopyDocument"
|
|
circle
|
|
plain
|
|
aria-label="复制 ProjectSpec"
|
|
@click="copyText(formattedSpec, 'ProjectSpec 已复制')"
|
|
/>
|
|
</el-tooltip>
|
|
</div>
|
|
<pre class="spec-code">{{ formattedSpec }}</pre>
|
|
|
|
<section class="validation-section" aria-label="ProjectSpec 校验结果">
|
|
<div class="section-heading">
|
|
<h3>校验结果</h3>
|
|
<div v-if="validationResult" class="validation-counts">
|
|
<el-tag :type="validationResult.valid ? 'success' : 'danger'">
|
|
{{ validationResult.valid ? '通过' : `${validationResult.errorCount || 0} 个错误` }}
|
|
</el-tag>
|
|
<el-tag v-if="validationResult.warningCount" type="warning" effect="plain">
|
|
{{ validationResult.warningCount }} 个警告
|
|
</el-tag>
|
|
</div>
|
|
</div>
|
|
<el-table
|
|
v-if="validationIssues.length"
|
|
:data="validationIssues"
|
|
size="small"
|
|
class="issue-table"
|
|
>
|
|
<el-table-column label="级别" width="86">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.severity === 'ERROR' ? 'danger' : 'warning'" size="small">
|
|
{{ row.severity }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="code" label="代码" min-width="180" />
|
|
<el-table-column prop="path" label="路径" min-width="240" show-overflow-tooltip />
|
|
<el-table-column prop="message" label="说明" min-width="280" />
|
|
</el-table>
|
|
<el-empty v-else :image-size="58" description="没有校验问题" />
|
|
</section>
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane label="语义 Diff" name="diff">
|
|
<div class="tab-content diff-tab">
|
|
<div class="diff-toolbar">
|
|
<div class="compare-control">
|
|
<span>基线版本</span>
|
|
<el-select
|
|
v-model="comparisonVersionId"
|
|
placeholder="选择版本"
|
|
:disabled="comparisonOptions.length === 0"
|
|
@change="loadDiff"
|
|
>
|
|
<el-option
|
|
v-for="version in comparisonOptions"
|
|
:key="version.specVersionId"
|
|
:label="`v${version.versionNo} · ${sourceLabel(version.changeSource)}`"
|
|
:value="version.specVersionId"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
<span v-if="comparisonVersion" class="diff-direction">
|
|
v{{ comparisonVersion.versionNo }} → v{{ selectedVersion.versionNo }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="diffResult" class="diff-summary" aria-label="Diff 汇总">
|
|
<div><strong>{{ diffResult.totalChanges || 0 }}</strong><span>全部变更</span></div>
|
|
<div><strong>{{ diffResult.addedCount || 0 }}</strong><span>新增</span></div>
|
|
<div><strong>{{ diffResult.changedCount || 0 }}</strong><span>修改</span></div>
|
|
<div><strong>{{ diffResult.removedCount || 0 }}</strong><span>删除</span></div>
|
|
</div>
|
|
|
|
<el-table
|
|
v-if="diffChanges.length"
|
|
v-loading="diffLoading"
|
|
:data="diffChanges"
|
|
size="small"
|
|
class="diff-table"
|
|
>
|
|
<el-table-column label="类型" width="92">
|
|
<template #default="{ row }">
|
|
<el-tag :type="diffTagType(row.type)" size="small">{{ diffTypeLabel(row.type) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="section" label="区域" width="130" />
|
|
<el-table-column prop="path" label="语义路径" min-width="260" show-overflow-tooltip />
|
|
<el-table-column label="变更前" min-width="220">
|
|
<template #default="{ row }"><code class="diff-value">{{ formatValue(row.beforeValue) }}</code></template>
|
|
</el-table-column>
|
|
<el-table-column label="变更后" min-width="220">
|
|
<template #default="{ row }"><code class="diff-value">{{ formatValue(row.afterValue) }}</code></template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-empty
|
|
v-else
|
|
v-loading="diffLoading"
|
|
:image-size="72"
|
|
:description="comparisonVersionId ? '两个版本没有语义差异' : '没有可比较的历史版本'"
|
|
/>
|
|
</div>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane label="生成运行" name="manifest">
|
|
<div v-loading="runLoading" class="tab-content manifest-tab">
|
|
<div class="run-history-toolbar">
|
|
<div class="compare-control">
|
|
<span>生成运行</span>
|
|
<el-select
|
|
v-model="selectedGenerationRunId"
|
|
placeholder="暂无运行"
|
|
:disabled="generationRuns.length === 0"
|
|
@change="selectGenerationRun"
|
|
>
|
|
<el-option
|
|
v-for="run in generationRuns"
|
|
:key="run.generationRunId"
|
|
:label="runOptionLabel(run)"
|
|
:value="run.generationRunId"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
<div class="compare-control">
|
|
<span>对比运行</span>
|
|
<el-select
|
|
v-model="comparisonRunId"
|
|
clearable
|
|
placeholder="选择对比运行"
|
|
:disabled="comparisonRunOptions.length === 0"
|
|
@change="loadRunDiff"
|
|
>
|
|
<el-option
|
|
v-for="run in comparisonRunOptions"
|
|
:key="run.generationRunId"
|
|
:label="runOptionLabel(run)"
|
|
:value="run.generationRunId"
|
|
/>
|
|
</el-select>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="generationResult">
|
|
<div class="run-identity-row">
|
|
<div>
|
|
<span>运行 ID</span>
|
|
<strong>#{{ generationResult.generationRunId }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>ProjectSpec</span>
|
|
<strong>v{{ generationResult.versionNo }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>完成时间</span>
|
|
<strong>{{ generationResult.createTime || '-' }}</strong>
|
|
</div>
|
|
<el-tag type="success">{{ generationResult.status || 'SUCCEEDED' }}</el-tag>
|
|
</div>
|
|
|
|
<div class="fingerprint-grid" aria-label="生成环境指纹">
|
|
<div>
|
|
<span>Adapter · {{ generationResult.adapterCode }} / {{ generationResult.adapterVersion }}</span>
|
|
<code>{{ generationResult.adapterFingerprint }}</code>
|
|
<small class="capability-meta">
|
|
Capability {{ shortHash(generationResult.adapterCapabilityFingerprint) }}
|
|
· {{ adapterCapabilitySummary(generationResult.adapterCapabilitiesJson) }}
|
|
</small>
|
|
</div>
|
|
<div>
|
|
<span>Plugin 集合</span>
|
|
<code>{{ generationResult.pluginFingerprint }}</code>
|
|
</div>
|
|
<div>
|
|
<span>Template · {{ generationResult.templateCode }} / {{ generationResult.templateVersion }}</span>
|
|
<code>{{ generationResult.templateFingerprint }}</code>
|
|
</div>
|
|
<div>
|
|
<span>完整生成环境</span>
|
|
<code>{{ generationResult.environmentFingerprint }}</code>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="manifest-summary">
|
|
<div><strong>{{ generationResult.targetCount || 0 }}</strong><span>生成目标</span></div>
|
|
<div><strong>{{ generationResult.fileCount || 0 }}</strong><span>文件</span></div>
|
|
<div><strong>{{ formatBytes(generationResult.totalBytes) }}</strong><span>内容体积</span></div>
|
|
<div class="manifest-hash-cell">
|
|
<span>聚合 SHA-256</span>
|
|
<code>{{ generationResult.aggregateContentHash }}</code>
|
|
</div>
|
|
</div>
|
|
|
|
<section v-if="runDiffResult" v-loading="runDiffLoading" class="run-diff-section" aria-label="生成产物 Diff">
|
|
<div class="section-heading">
|
|
<h3>产物 Diff</h3>
|
|
<div class="run-change-flags">
|
|
<el-tag :type="runDiffResult.specChanged ? 'warning' : 'info'" effect="plain">
|
|
Spec {{ runDiffResult.specChanged ? '已变化' : '一致' }}
|
|
</el-tag>
|
|
<el-tag :type="runDiffResult.environmentChanged ? 'warning' : 'success'" effect="plain">
|
|
环境 {{ runDiffResult.environmentChanged ? '已变化' : '一致' }}
|
|
</el-tag>
|
|
</div>
|
|
</div>
|
|
<div class="diff-summary">
|
|
<div><strong>{{ runDiffResult.totalChanges || 0 }}</strong><span>全部变化</span></div>
|
|
<div><strong>{{ runDiffResult.addedCount || 0 }}</strong><span>新增</span></div>
|
|
<div><strong>{{ runDiffResult.changedCount || 0 }}</strong><span>修改</span></div>
|
|
<div><strong>{{ runDiffResult.removedCount || 0 }}</strong><span>删除</span></div>
|
|
</div>
|
|
<el-table :data="runDiffResult.changes || []" size="small" class="run-diff-table">
|
|
<el-table-column label="类型" width="92">
|
|
<template #default="{ row }">
|
|
<el-tag :type="diffTagType(row.type)" size="small">{{ diffTypeLabel(row.type) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="目标" width="120">
|
|
<template #default="{ row }">{{ targetLabel(row.templateType) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="path" label="文件路径" min-width="340" show-overflow-tooltip />
|
|
<el-table-column label="变更前" min-width="180">
|
|
<template #default="{ row }">
|
|
<code>{{ shortHash(row.beforeContentHash) }}</code>
|
|
<span class="run-file-size">{{ formatOptionalBytes(row.beforeSize) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="变更后" min-width="180">
|
|
<template #default="{ row }">
|
|
<code>{{ shortHash(row.afterContentHash) }}</code>
|
|
<span class="run-file-size">{{ formatOptionalBytes(row.afterSize) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</section>
|
|
|
|
<el-collapse v-model="openManifestTargets" class="manifest-targets">
|
|
<el-collapse-item
|
|
v-for="target in generationResult.targets || []"
|
|
:key="target.templateType"
|
|
:name="target.templateType"
|
|
>
|
|
<template #title>
|
|
<div class="target-title">
|
|
<strong>{{ targetLabel(target.templateType) }}</strong>
|
|
<span>{{ target.fileCount || 0 }} 个文件 · {{ formatBytes(target.totalBytes) }}</span>
|
|
<code>{{ shortHash(target.contentHash) }}</code>
|
|
</div>
|
|
</template>
|
|
<el-table :data="target.files || []" size="small" class="manifest-file-table">
|
|
<el-table-column prop="path" label="文件路径" min-width="420" show-overflow-tooltip />
|
|
<el-table-column label="大小" width="110">
|
|
<template #default="{ row }">{{ formatBytes(row.size) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="SHA-256" min-width="250">
|
|
<template #default="{ row }"><code>{{ row.contentHash }}</code></template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</el-collapse-item>
|
|
</el-collapse>
|
|
</template>
|
|
<el-empty v-else :image-size="86" description="尚无持久化生成运行">
|
|
<el-button type="primary" :icon="VideoPlay" :loading="generating" @click="generateSelectedVersion">
|
|
生成并校验指纹
|
|
</el-button>
|
|
</el-empty>
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</template>
|
|
<el-empty v-else description="请选择一个 ProjectSpec 版本" />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { ArrowLeft, CopyDocument, Refresh, RefreshLeft, VideoPlay } from '@element-plus/icons-vue'
|
|
import {
|
|
diffProjectSpecGenerationRuns,
|
|
diffProjectSpecVersions,
|
|
generateProjectSpecVersion,
|
|
getProject,
|
|
getProjectSpecGenerationRun,
|
|
getProjectSpecVersion,
|
|
listProjectSpecGenerationRuns,
|
|
listProjectSpecVersions,
|
|
rollbackProjectSpecVersion,
|
|
validateProjectSpec
|
|
} from '@/api/project'
|
|
|
|
const SOURCE_LABELS = {
|
|
AI: 'AI',
|
|
AUTO_REPAIR: '自动修复',
|
|
IMPORT: '导入',
|
|
MANUAL_SNAPSHOT: '手动快照',
|
|
PATCH: 'Patch',
|
|
ROLLBACK: '回滚',
|
|
SYSTEM: '系统'
|
|
}
|
|
|
|
const TARGET_LABELS = {
|
|
backend: '后端',
|
|
frontend: '用户前端',
|
|
admin_frontend: '管理前端',
|
|
sql: 'SQL'
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const projectId = computed(() => Number(route.params.projectId))
|
|
const project = ref(null)
|
|
const versions = ref([])
|
|
const selectedVersionId = ref(null)
|
|
const selectedDetail = ref(null)
|
|
const validationResult = ref(null)
|
|
const comparisonVersionId = ref(null)
|
|
const diffResult = ref(null)
|
|
const generationRuns = ref([])
|
|
const selectedGenerationRunId = ref(null)
|
|
const selectedGenerationRun = ref(null)
|
|
const comparisonRunId = ref(null)
|
|
const runDiffResult = ref(null)
|
|
const activeTab = ref('spec')
|
|
const openManifestTargets = ref([])
|
|
const pageLoading = ref(false)
|
|
const detailLoading = ref(false)
|
|
const diffLoading = ref(false)
|
|
const runLoading = ref(false)
|
|
const runDiffLoading = ref(false)
|
|
const generating = ref(false)
|
|
const rollingBack = ref(false)
|
|
let detailRequestSequence = 0
|
|
let runRequestSequence = 0
|
|
let runDiffRequestSequence = 0
|
|
|
|
const selectedVersion = computed(() => versions.value.find(
|
|
(item) => item.specVersionId === selectedVersionId.value
|
|
) || null)
|
|
const selectedIsLatest = computed(() => Boolean(
|
|
versions.value.length && selectedVersionId.value === versions.value[0].specVersionId
|
|
))
|
|
const comparisonOptions = computed(() => versions.value.filter(
|
|
(item) => item.specVersionId !== selectedVersionId.value
|
|
))
|
|
const comparisonVersion = computed(() => versions.value.find(
|
|
(item) => item.specVersionId === comparisonVersionId.value
|
|
) || null)
|
|
const validationIssues = computed(() => validationResult.value?.issues || [])
|
|
const diffChanges = computed(() => diffResult.value?.changes || [])
|
|
const generationResult = computed(() => selectedGenerationRun.value)
|
|
const comparisonRunOptions = computed(() => generationRuns.value.filter(
|
|
(item) => item.generationRunId !== selectedGenerationRunId.value
|
|
))
|
|
const formattedSpec = computed(() => {
|
|
if (selectedDetail.value?.canonicalJson) {
|
|
try {
|
|
return JSON.stringify(JSON.parse(selectedDetail.value.canonicalJson), null, 2)
|
|
} catch (_error) {
|
|
return selectedDetail.value.canonicalJson
|
|
}
|
|
}
|
|
return selectedDetail.value?.spec ? JSON.stringify(selectedDetail.value.spec, null, 2) : ''
|
|
})
|
|
const validationTag = computed(() => {
|
|
if (!validationResult.value) return { label: '待校验', type: 'info' }
|
|
if (validationResult.value.valid) return { label: '校验通过', type: 'success' }
|
|
return { label: `${validationResult.value.errorCount || 0} 个错误`, type: 'danger' }
|
|
})
|
|
|
|
function sourceLabel(source) {
|
|
return SOURCE_LABELS[source] || source || '未知'
|
|
}
|
|
|
|
function sourceTagType(source) {
|
|
if (source === 'AI') return 'primary'
|
|
if (source === 'ROLLBACK') return 'warning'
|
|
if (source === 'PATCH') return 'success'
|
|
return 'info'
|
|
}
|
|
|
|
function shortHash(hash) {
|
|
return hash ? `${hash.slice(0, 12)}…` : '-'
|
|
}
|
|
|
|
function targetLabel(target) {
|
|
return TARGET_LABELS[target] || target
|
|
}
|
|
|
|
function adapterCapabilitySummary(capabilitiesJson) {
|
|
try {
|
|
const capabilities = typeof capabilitiesJson === 'string'
|
|
? JSON.parse(capabilitiesJson)
|
|
: capabilitiesJson
|
|
const targets = Array.isArray(capabilities?.outputTargets)
|
|
? capabilities.outputTargets
|
|
: []
|
|
return targets.length ? targets.map(targetLabel).join(' / ') : '未声明输出目标'
|
|
} catch (error) {
|
|
return '能力快照不可解析'
|
|
}
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
const bytes = Number(value || 0)
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
function formatOptionalBytes(value) {
|
|
return value === undefined || value === null ? '-' : formatBytes(value)
|
|
}
|
|
|
|
function runOptionLabel(run) {
|
|
return `#${run.generationRunId} · Spec v${run.versionNo} · ${run.createTime || '-'}`
|
|
}
|
|
|
|
function formatValue(value) {
|
|
if (value === undefined || value === null) return '-'
|
|
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
}
|
|
|
|
function diffTagType(type) {
|
|
if (type === 'ADDED') return 'success'
|
|
if (type === 'REMOVED') return 'danger'
|
|
return 'warning'
|
|
}
|
|
|
|
function diffTypeLabel(type) {
|
|
if (type === 'ADDED') return '新增'
|
|
if (type === 'REMOVED') return '删除'
|
|
return '修改'
|
|
}
|
|
|
|
function defaultComparisonVersionId(versionId) {
|
|
if (versions.value.length < 2) return null
|
|
const selectedIndex = versions.value.findIndex((item) => item.specVersionId === versionId)
|
|
if (selectedIndex === 0) return versions.value[1].specVersionId
|
|
return versions.value[0].specVersionId
|
|
}
|
|
|
|
function defaultComparisonRunId(runId) {
|
|
if (generationRuns.value.length < 2) return null
|
|
const selectedIndex = generationRuns.value.findIndex((item) => item.generationRunId === runId)
|
|
if (selectedIndex === 0) return generationRuns.value[1].generationRunId
|
|
return generationRuns.value[0].generationRunId
|
|
}
|
|
|
|
async function loadVersions(preferredVersionId) {
|
|
const result = await listProjectSpecVersions(projectId.value)
|
|
versions.value = Array.isArray(result) ? result : []
|
|
const preferred = versions.value.find((item) => item.specVersionId === preferredVersionId)
|
|
return preferred?.specVersionId || versions.value[0]?.specVersionId || null
|
|
}
|
|
|
|
async function loadGenerationRuns(preferredRunId) {
|
|
const result = await listProjectSpecGenerationRuns(projectId.value)
|
|
generationRuns.value = Array.isArray(result) ? result : []
|
|
const preferred = generationRuns.value.find((item) => item.generationRunId === preferredRunId)
|
|
return preferred?.generationRunId || generationRuns.value[0]?.generationRunId || null
|
|
}
|
|
|
|
async function loadWorkspace(preferredVersionId, preferredRunId) {
|
|
pageLoading.value = true
|
|
try {
|
|
const [projectResult, nextVersionId, nextRunId] = await Promise.all([
|
|
getProject(projectId.value),
|
|
loadVersions(preferredVersionId),
|
|
loadGenerationRuns(preferredRunId)
|
|
])
|
|
project.value = projectResult
|
|
if (nextVersionId) {
|
|
await selectVersion(nextVersionId)
|
|
} else {
|
|
selectedVersionId.value = null
|
|
selectedDetail.value = null
|
|
validationResult.value = null
|
|
}
|
|
if (nextRunId) {
|
|
await selectGenerationRun(nextRunId)
|
|
} else {
|
|
selectedGenerationRunId.value = null
|
|
selectedGenerationRun.value = null
|
|
comparisonRunId.value = null
|
|
runDiffResult.value = null
|
|
}
|
|
} catch (error) {
|
|
ElMessage.error(error.message || '加载 Factory 版本工作台失败')
|
|
} finally {
|
|
pageLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function refreshWorkspace() {
|
|
await loadWorkspace(selectedVersionId.value, selectedGenerationRunId.value)
|
|
}
|
|
|
|
async function selectVersion(versionId) {
|
|
if (!versionId) return
|
|
const requestSequence = ++detailRequestSequence
|
|
selectedVersionId.value = versionId
|
|
selectedDetail.value = null
|
|
validationResult.value = null
|
|
diffResult.value = null
|
|
comparisonVersionId.value = defaultComparisonVersionId(versionId)
|
|
detailLoading.value = true
|
|
try {
|
|
const detail = await getProjectSpecVersion(projectId.value, versionId)
|
|
const validation = await validateProjectSpec(projectId.value, detail.spec)
|
|
if (requestSequence !== detailRequestSequence) return
|
|
selectedDetail.value = detail
|
|
validationResult.value = validation
|
|
await loadDiff()
|
|
} catch (error) {
|
|
if (requestSequence === detailRequestSequence) {
|
|
ElMessage.error(error.message || '加载 ProjectSpec 版本失败')
|
|
}
|
|
} finally {
|
|
if (requestSequence === detailRequestSequence) detailLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadDiff() {
|
|
if (!comparisonVersionId.value || !selectedVersionId.value) {
|
|
diffResult.value = null
|
|
return
|
|
}
|
|
diffLoading.value = true
|
|
try {
|
|
diffResult.value = await diffProjectSpecVersions(
|
|
projectId.value,
|
|
comparisonVersionId.value,
|
|
selectedVersionId.value
|
|
)
|
|
} catch (error) {
|
|
diffResult.value = null
|
|
ElMessage.error(error.message || '加载版本 Diff 失败')
|
|
} finally {
|
|
diffLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function selectGenerationRun(generationRunId) {
|
|
if (!generationRunId) return
|
|
const requestSequence = ++runRequestSequence
|
|
selectedGenerationRunId.value = generationRunId
|
|
selectedGenerationRun.value = null
|
|
runDiffResult.value = null
|
|
comparisonRunId.value = defaultComparisonRunId(generationRunId)
|
|
runLoading.value = true
|
|
try {
|
|
const detail = await getProjectSpecGenerationRun(projectId.value, generationRunId)
|
|
if (requestSequence !== runRequestSequence) return
|
|
selectedGenerationRun.value = detail
|
|
openManifestTargets.value = (detail.targets || []).map((target) => target.templateType)
|
|
await loadRunDiff()
|
|
} catch (error) {
|
|
if (requestSequence === runRequestSequence) {
|
|
ElMessage.error(error.message || '加载生成运行失败')
|
|
}
|
|
} finally {
|
|
if (requestSequence === runRequestSequence) runLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadRunDiff() {
|
|
const requestSequence = ++runDiffRequestSequence
|
|
if (!comparisonRunId.value || !selectedGenerationRunId.value) {
|
|
runDiffResult.value = null
|
|
runDiffLoading.value = false
|
|
return
|
|
}
|
|
runDiffLoading.value = true
|
|
try {
|
|
const result = await diffProjectSpecGenerationRuns(
|
|
projectId.value,
|
|
comparisonRunId.value,
|
|
selectedGenerationRunId.value
|
|
)
|
|
if (requestSequence === runDiffRequestSequence) runDiffResult.value = result
|
|
} catch (error) {
|
|
if (requestSequence === runDiffRequestSequence) {
|
|
runDiffResult.value = null
|
|
ElMessage.error(error.message || '加载生成产物 Diff 失败')
|
|
}
|
|
} finally {
|
|
if (requestSequence === runDiffRequestSequence) runDiffLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function generateSelectedVersion() {
|
|
if (!selectedVersionId.value) return
|
|
generating.value = true
|
|
try {
|
|
const result = await generateProjectSpecVersion(projectId.value, selectedVersionId.value)
|
|
const nextRunId = await loadGenerationRuns(result.generationRunId)
|
|
await selectGenerationRun(nextRunId)
|
|
activeTab.value = 'manifest'
|
|
ElMessage.success(`生成运行 #${result.generationRunId} 已持久化`)
|
|
} catch (error) {
|
|
ElMessage.error(error.message || '按版本生成失败')
|
|
} finally {
|
|
generating.value = false
|
|
}
|
|
}
|
|
|
|
async function rollbackSelectedVersion() {
|
|
if (!selectedVersion.value || selectedIsLatest.value) return
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`将 v${selectedVersion.value.versionNo} 的内容创建为新的当前版本?`,
|
|
'回滚 ProjectSpec',
|
|
{
|
|
type: 'warning',
|
|
confirmButtonText: '创建回滚版本',
|
|
cancelButtonText: '取消'
|
|
}
|
|
)
|
|
rollingBack.value = true
|
|
const result = await rollbackProjectSpecVersion(projectId.value, selectedVersionId.value, {
|
|
changeSummary: `Rollback to version ${selectedVersion.value.versionNo} from Factory workspace`
|
|
})
|
|
const nextVersionId = await loadVersions(result.specVersionId)
|
|
await selectVersion(nextVersionId)
|
|
ElMessage.success(`已创建 v${result.versionNo} 回滚版本`)
|
|
} catch (error) {
|
|
if (error !== 'cancel' && error !== 'close') {
|
|
ElMessage.error(error.message || '回滚 ProjectSpec 失败')
|
|
}
|
|
} finally {
|
|
rollingBack.value = false
|
|
}
|
|
}
|
|
|
|
async function copyText(value, successMessage) {
|
|
if (!value) return
|
|
try {
|
|
await navigator.clipboard.writeText(value)
|
|
ElMessage.success(successMessage)
|
|
} catch (_error) {
|
|
ElMessage.warning('当前浏览器无法复制到剪贴板')
|
|
}
|
|
}
|
|
|
|
onMounted(() => loadWorkspace())
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.factory-page {
|
|
width: calc(100% - 40px);
|
|
max-width: none;
|
|
}
|
|
|
|
.factory-shell {
|
|
overflow: hidden;
|
|
border: 1px solid #d9e0ea;
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.factory-page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
min-height: 76px;
|
|
padding: 14px 20px;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.factory-heading,
|
|
.selected-version-title,
|
|
.detail-pane {
|
|
min-width: 0;
|
|
}
|
|
|
|
.factory-title-row,
|
|
.selected-title-line,
|
|
.version-row-top,
|
|
.version-row-tags,
|
|
.selected-hash,
|
|
.validation-counts,
|
|
.target-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.factory-title-row h1,
|
|
.pane-header h2,
|
|
.selected-title-line h2,
|
|
.section-heading h3 {
|
|
margin: 0;
|
|
color: #172033;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.factory-title-row h1 {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.factory-heading p,
|
|
.selected-version-title p {
|
|
margin: 5px 0 0;
|
|
color: #667085;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.factory-workspace {
|
|
display: grid;
|
|
grid-template-columns: minmax(290px, 340px) minmax(0, 1fr);
|
|
min-height: calc(100vh - 190px);
|
|
}
|
|
|
|
.version-pane {
|
|
min-width: 0;
|
|
border-right: 1px solid #e7ecf3;
|
|
background: #fbfcfe;
|
|
}
|
|
|
|
.pane-header {
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 64px;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.pane-header h2 {
|
|
font-size: 15px;
|
|
}
|
|
|
|
.pane-header span {
|
|
display: block;
|
|
margin-top: 3px;
|
|
color: #667085;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.version-list {
|
|
max-height: calc(100vh - 255px);
|
|
overflow: auto;
|
|
}
|
|
|
|
.version-row {
|
|
display: grid;
|
|
width: 100%;
|
|
min-height: 104px;
|
|
padding: 13px 16px;
|
|
border: 0;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
background: transparent;
|
|
color: inherit;
|
|
font: inherit;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.version-row:hover {
|
|
background: #f3f6fa;
|
|
}
|
|
|
|
.version-row--selected {
|
|
box-shadow: inset 3px 0 #2563eb;
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.version-row-top {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.version-row-top strong {
|
|
color: #172033;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.version-summary {
|
|
align-self: center;
|
|
overflow: hidden;
|
|
color: #475467;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.version-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
color: #8a94a6;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.version-meta code,
|
|
.selected-hash code,
|
|
.manifest-hash-cell code,
|
|
.target-title code,
|
|
.manifest-file-table code,
|
|
.fingerprint-grid code,
|
|
.run-diff-table code,
|
|
.diff-value {
|
|
font-family: Consolas, "SFMono-Regular", monospace;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.detail-pane {
|
|
background: #ffffff;
|
|
}
|
|
|
|
.selected-version-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
min-height: 112px;
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.selected-title-line h2 {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.selected-hash {
|
|
margin-top: 9px;
|
|
color: #667085;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.selected-hash code {
|
|
max-width: min(560px, 48vw);
|
|
overflow: hidden;
|
|
color: #344054;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.selected-version-actions {
|
|
display: flex;
|
|
flex: 0 0 auto;
|
|
gap: 10px;
|
|
}
|
|
|
|
.factory-tabs :deep(.el-tabs__header) {
|
|
margin: 0;
|
|
padding: 0 20px;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.factory-tabs :deep(.el-tabs__nav-wrap::after) {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content {
|
|
min-height: 460px;
|
|
padding: 18px 20px 24px;
|
|
}
|
|
|
|
.tab-toolbar,
|
|
.section-heading,
|
|
.diff-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.tab-toolbar-title {
|
|
display: grid;
|
|
gap: 3px;
|
|
}
|
|
|
|
.tab-toolbar-title span,
|
|
.diff-direction {
|
|
color: #667085;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.spec-code {
|
|
min-height: 280px;
|
|
max-height: 52vh;
|
|
margin: 14px 0 20px;
|
|
padding: 16px;
|
|
overflow: auto;
|
|
border: 1px solid #dfe5ee;
|
|
border-radius: 6px;
|
|
background: #101828;
|
|
color: #d1e9ff;
|
|
font: 12px/1.65 Consolas, "SFMono-Regular", monospace;
|
|
letter-spacing: 0;
|
|
tab-size: 2;
|
|
}
|
|
|
|
.validation-section {
|
|
border-top: 1px solid #e7ecf3;
|
|
padding-top: 18px;
|
|
}
|
|
|
|
.section-heading h3 {
|
|
font-size: 15px;
|
|
}
|
|
|
|
.issue-table,
|
|
.diff-table {
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.compare-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: #475467;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.compare-control :deep(.el-select) {
|
|
width: 220px;
|
|
}
|
|
|
|
.run-history-toolbar,
|
|
.run-identity-row,
|
|
.run-change-flags {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.run-history-toolbar {
|
|
justify-content: space-between;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.run-identity-row {
|
|
flex-wrap: wrap;
|
|
padding: 16px 0;
|
|
}
|
|
|
|
.run-identity-row > div {
|
|
display: grid;
|
|
gap: 3px;
|
|
min-width: 130px;
|
|
}
|
|
|
|
.run-identity-row span,
|
|
.fingerprint-grid span,
|
|
.run-file-size {
|
|
color: #667085;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.run-identity-row strong {
|
|
color: #172033;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.fingerprint-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
border-top: 1px solid #dfe5ee;
|
|
border-bottom: 1px solid #dfe5ee;
|
|
}
|
|
|
|
.fingerprint-grid > div {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
padding: 12px 14px;
|
|
border-right: 1px solid #e7ecf3;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.fingerprint-grid > div:nth-child(2n) {
|
|
border-right: 0;
|
|
}
|
|
|
|
.fingerprint-grid > div:nth-last-child(-n + 2) {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.fingerprint-grid code {
|
|
overflow: hidden;
|
|
color: #344054;
|
|
font-size: 11px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.fingerprint-grid .capability-meta {
|
|
overflow: hidden;
|
|
color: #667085;
|
|
font-size: 11px;
|
|
line-height: 1.45;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.diff-summary,
|
|
.manifest-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
margin-top: 16px;
|
|
border: 1px solid #dfe5ee;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.diff-summary > div,
|
|
.manifest-summary > div {
|
|
display: grid;
|
|
gap: 4px;
|
|
min-height: 72px;
|
|
align-content: center;
|
|
padding: 11px 14px;
|
|
border-right: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.diff-summary > div:last-child,
|
|
.manifest-summary > div:last-child {
|
|
border-right: 0;
|
|
}
|
|
|
|
.diff-summary strong,
|
|
.manifest-summary strong {
|
|
color: #172033;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.diff-summary span,
|
|
.manifest-summary span {
|
|
color: #667085;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.diff-value {
|
|
display: block;
|
|
max-height: 74px;
|
|
overflow: auto;
|
|
color: #344054;
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.run-diff-section {
|
|
margin-top: 18px;
|
|
padding-top: 18px;
|
|
border-top: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.run-diff-table {
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.run-diff-table code,
|
|
.run-file-size {
|
|
display: block;
|
|
}
|
|
|
|
.run-diff-table code {
|
|
color: #344054;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.run-file-size {
|
|
margin-top: 3px;
|
|
}
|
|
|
|
.manifest-hash-cell {
|
|
min-width: 0;
|
|
}
|
|
|
|
.manifest-hash-cell code {
|
|
overflow: hidden;
|
|
color: #344054;
|
|
font-size: 11px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.manifest-targets {
|
|
margin-top: 18px;
|
|
border-top: 1px solid #dfe5ee;
|
|
}
|
|
|
|
.target-title {
|
|
width: calc(100% - 24px);
|
|
min-width: 0;
|
|
}
|
|
|
|
.target-title strong {
|
|
min-width: 90px;
|
|
color: #172033;
|
|
}
|
|
|
|
.target-title span {
|
|
color: #667085;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.target-title code {
|
|
margin-left: auto;
|
|
color: #667085;
|
|
font-size: 11px;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.selected-version-header {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.selected-version-actions {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.selected-hash code {
|
|
max-width: 62vw;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.factory-page {
|
|
width: min(100% - 24px, 1180px);
|
|
}
|
|
|
|
.factory-workspace {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.version-pane {
|
|
border-right: 0;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.version-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
max-height: 280px;
|
|
}
|
|
|
|
.version-row:nth-child(odd) {
|
|
border-right: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.factory-workspace {
|
|
min-height: auto;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.factory-page-header,
|
|
.selected-version-header,
|
|
.tab-toolbar,
|
|
.section-heading,
|
|
.diff-toolbar,
|
|
.run-history-toolbar {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.factory-header-actions,
|
|
.selected-version-actions {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.factory-header-actions :deep(.el-button),
|
|
.selected-version-actions :deep(.el-button) {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.version-list {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.version-row:nth-child(odd) {
|
|
border-right: 0;
|
|
}
|
|
|
|
.selected-hash {
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.selected-hash code {
|
|
max-width: calc(100vw - 92px);
|
|
white-space: normal;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.compare-control {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.compare-control :deep(.el-select) {
|
|
width: 100%;
|
|
}
|
|
|
|
.run-identity-row {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.fingerprint-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.fingerprint-grid > div,
|
|
.fingerprint-grid > div:nth-child(2n),
|
|
.fingerprint-grid > div:nth-last-child(-n + 2) {
|
|
border-right: 0;
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
|
|
.fingerprint-grid > div:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.diff-summary,
|
|
.manifest-summary {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.diff-summary > div:nth-child(2),
|
|
.manifest-summary > div:nth-child(2) {
|
|
border-right: 0;
|
|
}
|
|
|
|
.diff-summary > div:nth-child(-n + 2),
|
|
.manifest-summary > div:nth-child(-n + 2) {
|
|
border-bottom: 1px solid #e7ecf3;
|
|
}
|
|
}
|
|
</style>
|