Files
yidaima/RuoYi-Vue/easycode-web/src/views/FactoryProjectView.vue

1354 lines
41 KiB
Vue
Raw Normal View History

<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>