feat: 支持按 Tag 打包,分支管理可添加 Tag

- 分支管理新增「分支 / Tag」类型选择,Tag 存于 config["tags"],与分支不可重名
- 每个 Tag 与分支一样拥有同名独立版本轨道,默认上架轨
- 打包页按「分支」「Tag」分组选择;任务新增 ref_type 字段记录引用类型
- Tag 打包时 fetch --tags --force 后以游离 HEAD 检出 refs/tags/<tag>
- 历史记录与下载页按类型显示「分支」或「Tag」

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-17 11:59:18 +09:00
co-authored by Claude Opus 5
parent 6867a3cb01
commit 5582aadc09
18 changed files with 347 additions and 50 deletions
+40
View File
@@ -73,6 +73,46 @@ describe('BuildView.vue', () => {
expect(branchSelect).toBeTruthy()
})
it('分支和 Tag 分组显示,可选择 Tag 提交', async () => {
fetch.mockImplementation((url, opts) => {
if (url === '/api/tasks' && opts?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'tag-task', status: 'pending' }) })
}
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tags': ['v3.2.0'],
'/api/tasks': [],
}
return Promise.resolve({ json: () => Promise.resolve(responses[url] || {}) })
})
const wrapper = mountBuildView()
await flushPromises()
const groups = wrapper.findAll('optgroup')
expect(groups.map(g => g.attributes('label'))).toEqual(['分支', 'Tag'])
expect(groups[1].findAll('option').map(o => o.text())).toEqual(['v3.2.0'])
wrapper.vm.selectedServer = '测试环境'
await wrapper.vm.$nextTick()
wrapper.vm.form.app_id = '1'
wrapper.vm.form.branch = 'v3.2.0'
await wrapper.vm.$nextTick()
await wrapper.find('.btn-primary').trigger('click')
await flushPromises()
const post = fetch.mock.calls.find(([url, opts]) => url === '/api/tasks' && opts?.method === 'POST')
expect(JSON.parse(post[1].body).branch).toBe('v3.2.0')
})
it('没有 Tag 时不显示 Tag 分组', async () => {
const wrapper = mountBuildView()
await flushPromises()
expect(wrapper.findAll('optgroup').map(g => g.attributes('label'))).toEqual(['分支'])
})
it('默认值正确', async () => {
const wrapper = mountBuildView()
await flushPromises()
+40 -1
View File
@@ -23,6 +23,7 @@ function mockFetch(url) {
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'],
'/api/config/tags': ['v3.2.0'],
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
'/api/config/versions': {
app_ver: '2.195.0',
@@ -31,8 +32,9 @@ function mockFetch(url) {
tracks: {
release: { kind: 'release', app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} },
dev: { kind: 'feature', app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
'v3.2.0': { kind: 'release', app_ver: '3.2.0', build_ver: '3.2.0.0', build_map: {} },
},
branch_track: { main: 'release', dev: 'dev' },
branch_track: { main: 'release', dev: 'dev', 'v3.2.0': 'v3.2.0' },
},
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
}
@@ -253,4 +255,41 @@ describe('ConfigView.vue', () => {
expect(calls).toEqual([{ branch: 'main', track: 'feature' }])
})
it('分支管理可添加和删除 Tag', async () => {
window.confirm = vi.fn(() => true)
const calls = []
fetch.mockImplementation((url, opts) => {
if (opts?.method === 'POST' || opts?.method === 'DELETE') {
calls.push([opts.method, url, opts.body && JSON.parse(opts.body)])
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
}
return mockFetch(url)
})
const wrapper = mountConfigView()
await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
await branchesMenu.trigger('click')
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('.config-table tbody tr')
expect(rows.map(r => r.findAll('td').slice(0, 2).map(td => td.text()))).toEqual([
['main', '分支'], ['dev', '分支'], ['v3.2.0', 'Tag'],
])
await wrapper.find('select').setValue('tag')
await wrapper.find('input[placeholder="输入 Tag 名称"]').setValue('v3.3.0')
await wrapper.findAll('.btn-primary').find(b => b.text() === '添加 Tag').trigger('click')
await flushPromises()
await rows[2].find('.action-btn.delete').trigger('click')
await flushPromises()
expect(calls).toEqual([
['POST', '/api/config/tags', { name: 'v3.3.0' }],
['DELETE', '/api/config/tags/v3.2.0', undefined],
])
})
})
+13 -4
View File
@@ -41,9 +41,14 @@
</select>
</div>
<div class="form-group">
<label>代码分支</label>
<label>代码分支 / Tag</label>
<select v-model="form.branch">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
<optgroup label="分支">
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
</optgroup>
<optgroup v-if="tags.length" label="Tag">
<option v-for="t in tags" :key="`tag:${t}`" :value="t">{{ t }}</option>
</optgroup>
</select>
</div>
<div class="form-group">
@@ -167,6 +172,7 @@ const authFetch = (url, options = {}) => {
const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
const tags = ref([])
const tasks = ref([])
const selectedServer = ref('')
const serverEnvironments = computed(() => {
@@ -314,15 +320,18 @@ onUnmounted(() => {
})
onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
const [appsRes, schemesRes, branchesRes, tagsRes, tasksRes] = await Promise.all([
authFetch('/api/apps'),
authFetch('/api/schemes'),
authFetch('/api/branches'),
authFetch('/api/tags'),
authFetch('/api/tasks'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
const allTasks = await tasksRes.json()
// 从历史页跳转过来时,读取 taskId 查询参数
@@ -436,7 +445,7 @@ const errorCategoryHint = (cat) => {
provisioning: '描述文件问题:描述文件过期、不匹配或未安装',
compilation: '代码编译错误:请检查代码语法和类型引用',
dependency: '依赖问题:Pod 依赖缺失或版本不兼容',
git: '代码拉取失败:请检查分支是否存在',
git: '代码拉取失败:请检查分支或 Tag 是否存在',
config: '配置替换失败:请检查打包配置',
build: '构建配置错误:请检查 Scheme 和 Workspace 配置',
unknown: '打包失败:请查看详细日志排查原因',
+39 -20
View File
@@ -152,36 +152,42 @@
<div v-if="tab === 'branches'">
<h2>分支管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">
管理可打包的代码分支打包时从对应分支目录获取源码
版本轨道决定该分支使用哪一套版本号上架master/develop 共用App_Store 打包构建号自增自测feature 构建号不自增
管理可打包的代码分支 Tag打包时从对应分支 Tag 获取源码
每个分支 / Tag 各有一套独立的版本号版本轨道决定构建号行为上架App_Store 打包构建号自增自测构建号不自增新增 Tag 默认为上架
</p>
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
<select v-model="newRefType" style="width: auto; padding: 10px 12px;">
<option value="branch">分支</option>
<option value="tag">Tag</option>
</select>
<input v-model="newBranch" type="text" :placeholder="newRefType === 'tag' ? '输入 Tag 名称' : '输入分支名称'" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">{{ newRefType === 'tag' ? '添加 Tag' : '添加分支' }}</button>
</div>
<table class="config-table">
<thead>
<tr>
<th>分支名称</th>
<th>名称</th>
<th>类型</th>
<th>版本轨道</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="b in branches" :key="b">
<td><strong>{{ b }}</strong></td>
<tr v-for="r in buildRefs" :key="`${r.type}:${r.name}`">
<td><strong>{{ r.name }}</strong></td>
<td>{{ r.type === 'tag' ? 'Tag' : '分支' }}</td>
<td>
<select :value="branchKind(b)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)">
<select :value="branchKind(r.name)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(r.name, $event.target.value)">
<option value="release">上架App_Store 打包构建号自增</option>
<option value="feature">自测构建号不自增</option>
</select>
</td>
<td class="action-btns">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
<button class="action-btn delete" @click="deleteBranch(r)">删除</button>
</td>
</tr>
<tr v-if="!branches.length">
<td colspan="3" style="text-align: center; color: #999;">暂无分支配置</td>
<tr v-if="!buildRefs.length">
<td colspan="4" style="text-align: center; color: #999;">暂无分支配置</td>
</tr>
</tbody>
</table>
@@ -297,7 +303,7 @@
<label>App_Ver应用版本</label>
<input type="text" v-model="versions[t.name].app_ver" placeholder="2.180.0" @input="onAppVerInput(t.name)">
<div style="font-size: 12px; color: #999; margin-top: 4px;">
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }} 分支轨道 {{ t.name }}与其它分支互不影响
格式主版本.次版本.修订号该版本号只用于 {{ t.branches.join(' / ') }}轨道 {{ t.name }}与其它分支 / Tag 互不影响
</div>
</div>
<div class="form-group">
@@ -647,7 +653,14 @@ const apps = ref({})
const schemes = ref({})
const servers = ref({})
const branches = ref([])
const tags = ref([])
const newBranch = ref('')
const newRefType = ref('branch') // 新增的是分支还是 Tag
// 分支管理表格:分支在前,Tag 在后
const buildRefs = computed(() => [
...branches.value.map(name => ({ name, type: 'branch' })),
...tags.value.map(name => ({ name, type: 'tag' })),
])
const buildSettings = ref({})
// 版本轨道:每个分支一条独立轨道,轨道名即分支名(master/main 沿用历史名 release)。
// 轨道类型 kind 决定构建号行为:release=上架、App_Store 打包自增;feature=自测、不自增。
@@ -678,13 +691,14 @@ const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'fe
// 打包设置里按分支分块展示版本号;多个分支共用一条轨道时只显示一块
const branchTracks = computed(() => {
const seen = new Set()
return branches.value
const refs = [...branches.value, ...tags.value]
return refs
.map(b => branchTrack.value[b])
.filter(name => name && versions.value[name] && !seen.has(name) && seen.add(name))
.map(name => ({
name,
kind: trackKind.value[name] || 'release',
branches: branches.value.filter(b => branchTrack.value[b] === name),
branches: refs.filter(b => branchTrack.value[b] === name),
}))
})
@@ -747,11 +761,12 @@ const loadData = async () => {
return
}
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
const [appsRes, schemesRes, serversRes, branchesRes, tagsRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/schemes'),
authFetch('/api/config/servers'),
authFetch('/api/config/branches'),
authFetch('/api/config/tags'),
authFetch('/api/config/build'),
authFetch('/api/config/upload'),
authFetch('/api/config'),
@@ -762,6 +777,8 @@ const loadData = async () => {
schemes.value = await schemesRes.json()
servers.value = await serversRes.json()
branches.value = await branchesRes.json()
const tagList = await tagsRes.json()
tags.value = Array.isArray(tagList) ? tagList : []
buildSettings.value = await buildRes.json()
uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
@@ -1178,12 +1195,12 @@ const deleteScheme = async (id) => {
await loadData()
}
// 分支管理
// 分支管理(分支与 Tag 共用一张表)
const addBranch = async () => {
const name = newBranch.value.trim()
if (!name) return
try {
const res = await authFetch('/api/config/branches', {
const res = await authFetch(newRefType.value === 'tag' ? '/api/config/tags' : '/api/config/branches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@@ -1218,10 +1235,12 @@ const setBranchTrack = async (name, track) => {
}
}
const deleteBranch = async (name) => {
if (!confirm(`确定删除分支「${name}」?`)) return
const deleteBranch = async ({ name, type }) => {
const isTag = type === 'tag'
if (!confirm(`确定删除${isTag ? ' Tag ' : '分支'}${name}」?`)) return
try {
const res = await authFetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
const base = isTag ? '/api/config/tags' : '/api/config/branches'
const res = await authFetch(`${base}/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败')
await loadData()
} catch (e) {
+1 -1
View File
@@ -110,7 +110,7 @@
<span :class="['build-type-badge', logTask?.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">{{ logTask?.build_type }}</span>
</div>
<div class="meta-item">
<span class="meta-label">分支</span>
<span class="meta-label">{{ logTask?.ref_type === 'tag' ? 'Tag' : '分支' }}</span>
<span class="meta-value">{{ logTask?.branch || '-' }}</span>
</div>
<div class="meta-item">