Initial commit: iOS Build Server
- FastAPI backend with build queue, WebSocket logs, task management - Vue 3 frontend with build/config/history views - Xcode project build automation with IPA export - Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
@@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="!isLoggedIn" class="access-denied">
|
||||
<h3>需要管理员权限</h3>
|
||||
<p>请先登录管理员账号以访问配置管理页面</p>
|
||||
<button class="btn-login" @click="$root.showLogin = true">登录管理员账号</button>
|
||||
</div>
|
||||
<div v-else class="admin-page">
|
||||
<div class="sidebar">
|
||||
<h3>配置管理</h3>
|
||||
<ul class="sidebar-menu">
|
||||
<li :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>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<!-- 服务器环境配置 -->
|
||||
<div v-if="tab === 'servers'">
|
||||
<h2>服务器环境配置</h2>
|
||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理服务器环境和对应的 API 地址、关联域名等配置</p>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openServerModal()">+ 新增环境</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>环境名称</th>
|
||||
<th>API 地址</th>
|
||||
<th>Associated Domains</th>
|
||||
<th>Universal Link</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(server, name) in servers" :key="name">
|
||||
<td><strong>{{ name }}</strong></td>
|
||||
<td>{{ server.api }}</td>
|
||||
<td>{{ server.assDom }}</td>
|
||||
<td>{{ server.universalLink }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openServerModal(name, server)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteServer(name)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Apps 配置 -->
|
||||
<div v-if="tab === 'apps'">
|
||||
<h2>Apps 配置</h2>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openAppModal()">+ 新增 App</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>环境</th>
|
||||
<th>AppGuid</th>
|
||||
<th>证书</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(app, id) in apps" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ app.name }}</td>
|
||||
<td>{{ app.server }}</td>
|
||||
<td>{{ app.AppGuid || app.AppId }}</td>
|
||||
<td>{{ Object.keys(app.certificates || {}).join(', ') || '-' }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openAppModal(id, app)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteApp(id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Schemes 配置 -->
|
||||
<div v-if="tab === 'schemes'">
|
||||
<h2>Schemes 配置</h2>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openSchemeModal()">+ 新增 Scheme</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>OSS 目录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(scheme, id) in schemes" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ scheme.name }}</td>
|
||||
<td>{{ scheme.ossFloder }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openSchemeModal(id, scheme)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteScheme(id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分支管理 -->
|
||||
<div v-if="tab === 'branches'">
|
||||
<h2>分支管理</h2>
|
||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理可打包的代码分支,打包时从对应分支目录获取源码</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>
|
||||
</div>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分支名称</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in branches" :key="b">
|
||||
<td><strong>{{ b }}</strong></td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!branches.length">
|
||||
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 上传配置 -->
|
||||
<div v-if="tab === 'upload'">
|
||||
<h2>上传配置</h2>
|
||||
<div style="max-width: 600px;">
|
||||
<div class="form-group">
|
||||
<label>上传方式</label>
|
||||
<select v-model="uploadConfig.mode">
|
||||
<option value="oss">阿里云 OSS</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- OSS 配置 -->
|
||||
<div v-if="uploadConfig.mode === 'oss'" class="config-section">
|
||||
<h4 class="section-title">阿里云 OSS</h4>
|
||||
<div class="form-group">
|
||||
<label>AccessKey ID</label>
|
||||
<input v-model="uploadConfig.oss.access_key_id" type="text" placeholder="LTAI...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>AccessKey Secret</label>
|
||||
<input v-model="uploadConfig.oss.access_key_secret" type="password" placeholder="密钥">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint</label>
|
||||
<input v-model="uploadConfig.oss.endpoint" type="text" placeholder="oss-cn-beijing.aliyuncs.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bucket 名称</label>
|
||||
<input v-model="uploadConfig.oss.bucket_name" type="text" placeholder="my-bucket">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>访问地址(Base URL)</label>
|
||||
<input v-model="uploadConfig.oss.base_url" type="text" placeholder="https://my-bucket.oss-cn-beijing.aliyuncs.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV 配置 -->
|
||||
<div v-if="uploadConfig.mode === 'webdav'" class="config-section">
|
||||
<h4 class="section-title">WebDAV</h4>
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="uploadConfig.webdav.server_url" type="text" placeholder="https://dav.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="uploadConfig.webdav.username" type="text" placeholder="留空则无需认证">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="uploadConfig.webdav.password" type="password" placeholder="密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>远程路径</label>
|
||||
<input v-model="uploadConfig.webdav.base_path" type="text" placeholder="/ios-builds">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>公开访问地址</label>
|
||||
<input v-model="uploadConfig.webdav.public_url" type="text" placeholder="https://download.example.com/ios-builds">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">iOS 安装需要 HTTPS 公开链接,需配置 Nginx 反代或公开目录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 钉钉通知 -->
|
||||
<div class="config-section">
|
||||
<h4 class="section-title">钉钉通知</h4>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="uploadConfig.dingtalk.enabled" style="width: 16px; height: 16px;">
|
||||
启用钉钉通知
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="uploadConfig.dingtalk.enabled">
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input v-model="uploadConfig.dingtalk.webhook_url" type="text" placeholder="https://oapi.dingtalk.com/robot/send?access_token=...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>加签密钥(可选)</label>
|
||||
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveUploadConfig">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打包设置 -->
|
||||
<div v-if="tab === 'build'">
|
||||
<h2>打包设置</h2>
|
||||
<div style="max-width: 500px;">
|
||||
<div class="form-group">
|
||||
<label>最大并行打包数</label>
|
||||
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包目录保留时间(小时)</label>
|
||||
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包基础目录</label>
|
||||
<input type="text" v-model="buildSettings.build_base_dir">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON 编辑 -->
|
||||
<div v-if="tab === 'json'">
|
||||
<h2>JSON 编辑</h2>
|
||||
<div style="background: #1a1a2e; border-radius: 8px; padding: 16px;">
|
||||
<textarea v-model="jsonContent" style="width: 100%; height: 500px; background: transparent; border: none; color: #00ff00; font-family: 'Monaco', monospace; font-size: 12px; resize: vertical;"></textarea>
|
||||
</div>
|
||||
<div style="margin-top: 16px; display: flex; gap: 12px;">
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveJson">保存 JSON</button>
|
||||
<button class="action-btn" style="padding: 10px 24px;" @click="formatJson">格式化</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务器环境编辑弹窗 -->
|
||||
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingServerName ? '编辑' : '新增' }} 服务器环境</h3>
|
||||
<button class="modal-close" @click="showServerModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>环境名称 *</label>
|
||||
<input v-model="serverForm.name" type="text" placeholder="例如:测试环境" :disabled="!!editingServerName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API 地址 *</label>
|
||||
<input v-model="serverForm.api" type="text" placeholder="https://api3-dev.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Associated Domains</label>
|
||||
<input v-model="serverForm.assDom" type="text" placeholder="applinks:dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Universal Link</label>
|
||||
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App 编辑弹窗 -->
|
||||
<div v-if="showAppModal" class="modal-overlay" @click.self="showAppModal = false">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingAppId ? '编辑' : '新增' }} App</h3>
|
||||
<button class="modal-close" @click="showAppModal = false">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<h4 class="section-title">基本信息</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>App 名称 *</label>
|
||||
<input v-model="appForm.name" type="text" placeholder="例如:上财云津">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器环境 *</label>
|
||||
<select 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>AppGuid *</label>
|
||||
<input v-model="appForm.AppGuid" type="text" placeholder="应用唯一标识">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API 地址 *</label>
|
||||
<input v-model="appForm.API" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联配置 -->
|
||||
<h4 class="section-title">关联配置</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Associated Domains</label>
|
||||
<input v-model="appForm.AssDom" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Universal Link</label>
|
||||
<input v-model="appForm.UniversalLink" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>微信登录 AppID</label>
|
||||
<input v-model="appForm.weixinlogin" type="text" placeholder="wx...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>微信支付 AppID</label>
|
||||
<input v-model="appForm.weixinpay" type="text" placeholder="wx...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>腾讯 AppID</label>
|
||||
<input v-model="appForm.tencent" type="text" placeholder="tencent...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>阿里云 License Key</label>
|
||||
<input v-model="appForm.AlivcLicenseKey" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 证书配置 -->
|
||||
<h4 class="section-title">证书配置</h4>
|
||||
<div v-for="certType in ['Ad_Hoc', 'App_Store']" :key="certType" class="cert-section">
|
||||
<div class="cert-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" :checked="appForm.certificates && appForm.certificates[certType]" @change="toggleCert(certType)">
|
||||
{{ certType }} 证书
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="appForm.certificates && appForm.certificates[certType]" class="cert-fields">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Bundle ID</label>
|
||||
<input v-model="appForm.certificates[certType].name" type="text" placeholder="cn.example.app">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>证书名称</label>
|
||||
<input v-model="appForm.certificates[certType].cer" type="text" placeholder="iPhone Distribution: ...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Provisioning Profile 路径</label>
|
||||
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题目录</label>
|
||||
<input v-model="appForm.certificates[certType].theme" type="text" placeholder="AutoPacking/ymh">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showAppModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveApp">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheme 编辑弹窗 -->
|
||||
<div v-if="showSchemeModal" class="modal-overlay" @click.self="showSchemeModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingSchemeId ? '编辑' : '新增' }} Scheme</h3>
|
||||
<button class="modal-close" @click="showSchemeModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scheme 名称 *</label>
|
||||
<input v-model="schemeForm.name" type="text" placeholder="例如:readoor31">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>OSS 目录 *</label>
|
||||
<input v-model="schemeForm.ossFloder" type="text" placeholder="例如:readoor">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showSchemeModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveScheme">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isLoggedIn = ref(localStorage.getItem('isAdmin') === 'true')
|
||||
const tab = ref('servers')
|
||||
const apps = ref({})
|
||||
const schemes = ref({})
|
||||
const servers = ref({})
|
||||
const branches = ref([])
|
||||
const newBranch = ref('')
|
||||
const buildSettings = ref({})
|
||||
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
||||
const jsonContent = ref('{}')
|
||||
|
||||
const showServerModal = ref(false)
|
||||
const editingServerName = ref(null)
|
||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '' })
|
||||
|
||||
const showAppModal = ref(false)
|
||||
const editingAppId = ref(null)
|
||||
const appForm = ref({})
|
||||
|
||||
const showSchemeModal = ref(false)
|
||||
const editingSchemeId = ref(null)
|
||||
const schemeForm = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes] = await Promise.all([
|
||||
fetch('/api/config/apps'),
|
||||
fetch('/api/config/schemes'),
|
||||
fetch('/api/config/servers'),
|
||||
fetch('/api/config/branches'),
|
||||
fetch('/api/config/build'),
|
||||
fetch('/api/config/upload'),
|
||||
fetch('/api/config'),
|
||||
])
|
||||
apps.value = await appsRes.json()
|
||||
schemes.value = await schemesRes.json()
|
||||
servers.value = await serversRes.json()
|
||||
branches.value = await branchesRes.json()
|
||||
buildSettings.value = await buildRes.json()
|
||||
uploadConfig.value = await uploadRes.json()
|
||||
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
||||
}
|
||||
|
||||
// 服务器环境管理
|
||||
const openServerModal = (name = null, server = null) => {
|
||||
editingServerName.value = name
|
||||
serverForm.value = server ? { ...server, name } : { name: '', api: '', assDom: '', universalLink: '' }
|
||||
showServerModal.value = true
|
||||
}
|
||||
|
||||
const saveServer = async () => {
|
||||
if (!serverForm.value.name || !serverForm.value.api) {
|
||||
alert('请填写必填字段:环境名称、API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingServerName.value) {
|
||||
const res = await fetch(`/api/config/servers/${encodeURIComponent(editingServerName.value)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverForm.value),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '保存失败')
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/config/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverForm.value),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '创建失败')
|
||||
}
|
||||
}
|
||||
showServerModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteServer = async (name) => {
|
||||
if (!confirm(`确定删除环境「${name}」?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/config/servers/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '删除失败')
|
||||
}
|
||||
await loadData()
|
||||
alert('删除成功')
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// App 相关
|
||||
const openAppModal = (id = null, app = null) => {
|
||||
editingAppId.value = id
|
||||
if (app) {
|
||||
appForm.value = JSON.parse(JSON.stringify(app))
|
||||
if (!appForm.value.certificates) appForm.value.certificates = {}
|
||||
} else {
|
||||
appForm.value = {
|
||||
name: '',
|
||||
server: '',
|
||||
AppGuid: '',
|
||||
API: '',
|
||||
AssDom: '',
|
||||
UniversalLink: '',
|
||||
weixinlogin: '',
|
||||
weixinpay: '',
|
||||
tencent: '',
|
||||
AlivcLicenseKey: '',
|
||||
certificates: {},
|
||||
}
|
||||
}
|
||||
showAppModal.value = true
|
||||
}
|
||||
|
||||
const onServerChange = () => {
|
||||
const serverName = appForm.value.server
|
||||
if (serverName && servers.value[serverName]) {
|
||||
const server = servers.value[serverName]
|
||||
appForm.value.API = server.api || ''
|
||||
appForm.value.AssDom = server.assDom || ''
|
||||
appForm.value.UniversalLink = server.universalLink || ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCert = (certType) => {
|
||||
if (!appForm.value.certificates) appForm.value.certificates = {}
|
||||
if (appForm.value.certificates[certType]) {
|
||||
delete appForm.value.certificates[certType]
|
||||
} else {
|
||||
appForm.value.certificates[certType] = {
|
||||
name: '',
|
||||
pro: '',
|
||||
cer: '',
|
||||
theme: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveApp = async () => {
|
||||
if (!appForm.value.name || !appForm.value.AppGuid) {
|
||||
alert('请填写必填字段:App 名称、AppGuid')
|
||||
return
|
||||
}
|
||||
|
||||
// 清理空证书
|
||||
if (appForm.value.certificates) {
|
||||
Object.keys(appForm.value.certificates).forEach(key => {
|
||||
if (!appForm.value.certificates[key].name) {
|
||||
delete appForm.value.certificates[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清理空值
|
||||
const cleanData = { ...appForm.value }
|
||||
Object.keys(cleanData).forEach(key => {
|
||||
if (cleanData[key] === '' || cleanData[key] === null || cleanData[key] === undefined) {
|
||||
delete cleanData[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
let res
|
||||
if (editingAppId.value) {
|
||||
res = await fetch(`/api/config/apps/${editingAppId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cleanData),
|
||||
})
|
||||
} else {
|
||||
res = await fetch('/api/config/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cleanData),
|
||||
})
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '保存失败' }))
|
||||
throw new Error(err.detail || '保存失败')
|
||||
}
|
||||
showAppModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApp = async (id) => {
|
||||
if (!confirm('确定删除此 App?')) return
|
||||
await fetch(`/api/config/apps/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// Scheme 相关
|
||||
const openSchemeModal = (id = null, scheme = null) => {
|
||||
editingSchemeId.value = id
|
||||
schemeForm.value = scheme ? { ...scheme } : { name: '', ossFloder: '' }
|
||||
showSchemeModal.value = true
|
||||
}
|
||||
|
||||
const saveScheme = async () => {
|
||||
if (!schemeForm.value.name || !schemeForm.value.ossFloder) {
|
||||
alert('请填写必填字段:名称、OSS 目录')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingSchemeId.value) {
|
||||
const res = await fetch(`/api/config/schemes/${editingSchemeId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schemeForm.value),
|
||||
})
|
||||
if (!res.ok) throw new Error('保存失败')
|
||||
} else {
|
||||
const res = await fetch('/api/config/schemes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schemeForm.value),
|
||||
})
|
||||
if (!res.ok) throw new Error('创建失败')
|
||||
}
|
||||
showSchemeModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteScheme = async (id) => {
|
||||
if (!confirm('确定删除此 Scheme?')) return
|
||||
await fetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// 分支管理
|
||||
const addBranch = async () => {
|
||||
const name = newBranch.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
const res = await fetch('/api/config/branches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '添加失败')
|
||||
}
|
||||
newBranch.value = ''
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
alert('添加失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBranch = async (name) => {
|
||||
if (!confirm(`确定删除分支「${name}」?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('删除失败')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他
|
||||
const saveBuildSettings = async () => {
|
||||
await fetch('/api/config/build', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildSettings.value),
|
||||
})
|
||||
alert('设置已保存')
|
||||
}
|
||||
|
||||
const saveUploadConfig = async () => {
|
||||
try {
|
||||
await fetch('/api/config/upload', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(uploadConfig.value),
|
||||
})
|
||||
alert('上传配置已保存')
|
||||
} catch (e) {
|
||||
alert('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveJson = async () => {
|
||||
try {
|
||||
const config = JSON.parse(jsonContent.value)
|
||||
await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
await loadData()
|
||||
alert('配置已保存')
|
||||
} catch (e) {
|
||||
alert('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const config = JSON.parse(jsonContent.value)
|
||||
jsonContent.value = JSON.stringify(config, null, 2)
|
||||
} catch (e) {
|
||||
alert('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.access-denied { text-align: center; padding: 60px 20px; background: white; border-radius: 12px; }
|
||||
.access-denied h3 { font-size: 18px; color: #666; margin-bottom: 8px; }
|
||||
.access-denied p { color: #999; margin-bottom: 20px; }
|
||||
.btn-login { background: #1890ff; color: white; border: none; padding: 10px 32px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.admin-page { display: grid; grid-template-columns: 200px 1fr; gap: 24px; min-height: calc(100vh - 120px); }
|
||||
.sidebar { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.sidebar h3 { font-size: 13px; color: #999; margin-bottom: 12px; text-transform: uppercase; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { padding: 10px 12px; border-radius: 6px; cursor: pointer; color: #333; margin-bottom: 4px; }
|
||||
.sidebar-menu li:hover { background: #f5f5f5; }
|
||||
.sidebar-menu li.active { background: #e6f7ff; color: #1890ff; }
|
||||
.main-content { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.main-content h2 { font-size: 18px; margin-bottom: 20px; color: #1a1a2e; }
|
||||
.config-table { width: 100%; border-collapse: collapse; }
|
||||
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table tr:hover { background: #fafafa; }
|
||||
.action-btns { display: flex; gap: 8px; }
|
||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||
.action-btn.delete:hover { border-color: #ff4d4f; color: #ff4d4f; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group input:focus, .form-group select:focus { outline: none; border-color: #1890ff; }
|
||||
.form-group input:disabled { background: #f5f5f5; color: #999; cursor: not-allowed; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; width: 100%; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.modal-content { background: white; border-radius: 12px; padding: 24px; width: 500px; max-height: 80vh; overflow-y: auto; }
|
||||
.modal-large { width: 700px; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { font-size: 16px; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
|
||||
.section-title { font-size: 14px; color: #1890ff; margin: 20px 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
|
||||
.cert-section { margin-bottom: 16px; padding: 12px; background: #fafafa; border-radius: 8px; }
|
||||
.cert-header { margin-bottom: 12px; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 500; }
|
||||
.checkbox-label input { width: 16px; height: 16px; }
|
||||
.cert-fields { padding-top: 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user