feat: 强化打包配置与服务安全

This commit is contained in:
shen
2026-07-18 13:30:56 +08:00
parent d1f070b251
commit a48909f3bc
26 changed files with 779 additions and 203 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<nav v-if="isLoggedIn" class="nav">
<router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link>
<router-link v-if="isAdmin" to="/admin">管理</router-link>
<router-link to="/admin">管理</router-link>
</nav>
<div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button>
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import BuildView from '../views/BuildView.vue'
const createTestRouter = () => createRouter({
history: createMemoryHistory(),
routes: [{ path: '/build', component: BuildView }],
})
const mountBuildView = async () => {
const router = createTestRouter()
await router.push('/build')
await router.isReady()
global.fetch = vi.fn((url) => {
const responses = {
'/api/apps': {
'1': { name: '测试阅读', server: '测试环境' },
'2': { name: '正式阅读', server: '正式环境' },
'3': { name: '测试词典', server: '测试环境' },
},
'/api/schemes': { '1': { name: 'readoor31' } },
'/api/branches': ['main'],
'/api/tasks': [],
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(responses[url] || {}) })
})
const wrapper = mount(BuildView, {
global: {
plugins: [router],
provide: { getToken: () => '' },
},
})
await flushPromises()
return wrapper
}
describe('BuildView server environment filter', () => {
it('filters Apps by the selected server environment', async () => {
const wrapper = await mountBuildView()
const selects = wrapper.findAll('select')
const serverSelect = selects[0]
const appSelect = selects[1]
expect(appSelect.attributes('disabled')).toBeDefined()
await serverSelect.setValue('测试环境')
expect(appSelect.text()).toContain('测试阅读')
expect(appSelect.text()).toContain('测试词典')
expect(appSelect.text()).not.toContain('正式阅读')
})
it('clears the selected App when switching environments', async () => {
const wrapper = await mountBuildView()
const selects = wrapper.findAll('select')
const serverSelect = selects[0]
const appSelect = selects[1]
await serverSelect.setValue('测试环境')
await appSelect.setValue('1')
expect(wrapper.vm.form.app_id).toBe('1')
await serverSelect.setValue('正式环境')
expect(wrapper.vm.form.app_id).toBe('')
expect(appSelect.text()).toContain('正式阅读')
expect(appSelect.text()).not.toContain('测试阅读')
})
it('only offers configured build types and special App Schemes', async () => {
const wrapper = await mountBuildView()
await wrapper.setData({
apps: {
'1': {
name: '英汉大词典',
server: '测试环境',
certificates: { App_Store: { name: 'com.dictionary.app' } },
allowed_scheme_names: ['readoorDict'],
},
},
schemes: {
'1': { name: 'readoor31' },
'2': { name: 'readoorDict' },
},
})
const selects = wrapper.findAll('select')
await selects[0].setValue('测试环境')
await selects[1].setValue('1')
expect(selects[2].text()).toContain('App_Store')
expect(selects[2].text()).not.toContain('Ad_Hoc')
expect(selects[3].text()).toContain('readoorDict')
expect(selects[3].text()).not.toContain('readoor31')
})
})
+52 -11
View File
@@ -5,25 +5,37 @@
<div class="config-panel">
<h2>打包配置</h2>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id">
<label>选择服务器环境</label>
<select v-model="selectedServer">
<option value="">请选择...</option>
<option v-for="(app, id) in apps" :key="id" :value="id">
{{ app.server }} - {{ app.name }}
<option v-for="server in serverEnvironments" :key="server" :value="server">
{{ server }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id" :disabled="!selectedServer">
<option value="">{{ selectedServer ? '请选择...' : '请先选择服务器环境' }}</option>
<option v-for="[id, app] in filteredApps" :key="id" :value="id">
{{ app.name }}
</option>
</select>
</div>
<div class="form-group">
<label>打包类型</label>
<select v-model="form.build_type">
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
<option value="" disabled>请选择...</option>
<option v-for="buildType in availableBuildTypes" :key="buildType" :value="buildType">
{{ buildType }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 Scheme</label>
<select v-model="form.scheme_id">
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
<option value="" disabled>请选择...</option>
<option v-for="[id, scheme] in filteredSchemes" :key="id" :value="id">
{{ scheme.displayName || scheme.name }}
</option>
</select>
@@ -138,7 +150,7 @@
</template>
<script setup>
import { ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
import { computed, ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
import { useRoute } from 'vue-router'
const getToken = inject('getToken')
@@ -155,10 +167,28 @@ const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
const tasks = ref([])
const selectedServer = ref('')
const serverEnvironments = computed(() => {
return [...new Set(Object.values(apps.value).map(app => app.server).filter(Boolean))]
})
const filteredApps = computed(() => {
return Object.entries(apps.value).filter(([, app]) => app.server === selectedServer.value)
})
const selectedApp = computed(() => apps.value[form.value.app_id] || null)
const availableBuildTypes = computed(() => {
const certificates = selectedApp.value?.certificates || {}
return ['Ad_Hoc', 'App_Store'].filter(buildType => Boolean(certificates[buildType]))
})
const filteredSchemes = computed(() => {
const allowedNames = selectedApp.value?.allowed_scheme_names || []
return Object.entries(schemes.value).filter(([, scheme]) => {
return !allowedNames.length || allowedNames.includes(scheme.name)
})
})
const form = ref({
app_id: '',
build_type: 'Ad_Hoc',
scheme_id: '1',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: 'main',
})
@@ -224,7 +254,9 @@ const connectWs = (taskId) => {
completedTask.value = null
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
const token = getToken()
if (!token) return
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
activeWs = ws
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
@@ -258,6 +290,15 @@ watch(() => currentTaskId.value, (newId) => {
connectWs(newId)
})
watch(selectedServer, () => {
form.value.app_id = ''
})
watch(() => form.value.app_id, () => {
form.value.scheme_id = filteredSchemes.value[0]?.[0] || ''
form.value.build_type = availableBuildTypes.value[0] || ''
})
onUnmounted(() => {
if (activeWs) {
activeWs.close()
+21 -14
View File
@@ -1,22 +1,22 @@
<template>
<div class="container">
<div v-if="!isAdmin" class="access-denied">
<h3>需要管理员权限</h3>
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
<div v-if="!isLoggedIn" class="access-denied">
<h3>请先登录</h3>
<p>登录后可管理 Apps 配置</p>
<button class="btn-login" @click="showLogin = true">登录</button>
</div>
<div v-else class="admin-page">
<div class="sidebar">
<h3>配置管理</h3>
<ul class="sidebar-menu">
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li v-if="isAdmin" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul>
</div>
<div class="main-content">
@@ -405,12 +405,13 @@
</div>
<div class="form-group">
<label>服务器环境 *</label>
<select v-model="appForm.server" @change="onServerChange">
<select v-if="isAdmin" v-model="appForm.server" @change="onServerChange">
<option value="">请选择环境</option>
<option v-for="(server, name) in servers" :key="name" :value="name">
{{ name }} - {{ server.api }}
</option>
</select>
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
</div>
</div>
<div class="form-row">
@@ -479,7 +480,7 @@
</div>
<div class="form-row">
<div class="form-group">
<label>Provisioning Profile 路径</label>
<label>Provisioning Profile 名称</label>
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
</div>
<div class="form-group">
@@ -563,7 +564,7 @@ const showLogin = inject('showLogin')
const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin')
const tab = ref('users')
const tab = ref('apps')
// 用户管理
const users = ref([])
@@ -610,6 +611,12 @@ onMounted(async () => {
})
const loadData = async () => {
if (!isAdmin.value) {
const appsRes = await authFetch('/api/config/apps')
if (appsRes.ok) apps.value = await appsRes.json()
return
}
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/schemes'),
+3 -1
View File
@@ -262,7 +262,9 @@ const viewLogs = async (taskId) => {
const connectLogWs = (taskId) => {
if (logWs) { logWs.close(); logWs = null }
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
const token = getToken()
if (!token) return
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
logWs = ws
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)