iOSBuildServer/frontend/src/views/ConfigView.vue
2026-07-20 16:24:13 +09:00

1169 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="container">
<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 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 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 :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">
<!-- 用户管理 -->
<div v-if="tab === 'users'">
<h2>用户管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理员可以创建和管理普通用户账号</p>
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openUserModal()">+ 新增用户</button>
<table class="config-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><strong>{{ u.username }}</strong></td>
<td>
<span :class="['build-type-badge', u.is_admin ? 'badge-appstore' : 'badge-adhoc']">
{{ u.is_admin ? '管理员' : '普通用户' }}
</span>
</td>
<td>{{ u.created_at || '-' }}</td>
<td class="action-btns">
<button class="action-btn" @click="openPasswordModal(u)">改密码</button>
<button class="action-btn" @click="toggleUserRole(u)">{{ u.is_admin ? '取消管理员' : '设为管理员' }}</button>
<button class="action-btn delete" @click="deleteUser(u)">删除</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="4" style="text-align: center; color: #999;">暂无用户</td>
</tr>
</tbody>
</table>
</div>
<!-- 服务器环境配置 -->
<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>App ID 前缀</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.app_id_prefix }}</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>Scheme 名称</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.displayName || '-' }}</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;">
<h4 class="section-title">应用版本号</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号</div>
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
<template v-if="isAdmin">
<h4 class="section-title">打包参数</h4>
<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>
</template>
</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="showUserModal" class="modal-overlay" @click.self="showUserModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>新增用户</h3>
<button class="modal-close" @click="showUserModal = false">&times;</button>
</div>
<div class="form-group">
<label>用户名 *</label>
<input v-model="userForm.username" type="text" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 11 位">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.is_admin" style="width: 16px; height: 16px;">
管理员权限
</label>
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showUserModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="createUser">创建</button>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
<div class="modal-content" style="width: 400px;">
<div class="modal-header">
<h3>修改密码 - {{ passwordTarget?.username }}</h3>
<button class="modal-close" @click="showPasswordModal = false">&times;</button>
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 11 位">
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="changePassword">确认</button>
</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">&times;</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 class="form-group">
<label>App ID 前缀</label>
<input :value="editingServerName ? serverForm.app_id_prefix : nextServerPrefix" type="text" disabled>
<div style="font-size: 12px; color: #999; margin-top: 4px;">新增环境自动分配,保存后不可修改。</div>
</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">&times;</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-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 v-if="isAdmin" class="form-group">
<label>App ID 前缀覆盖</label>
<input v-model.number="appForm.app_id_prefix_override" type="number" min="1" placeholder="仅特殊 App 使用">
</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>
<select v-if="appSkins.length"
:value="appSkins.some(s => s.name.replace('.zip', '') === appForm.certificates[certType].theme) ? appForm.certificates[certType].theme : ''"
@change="appForm.certificates[certType].theme = $event.target.value"
class="theme-select">
<option value="">无</option>
<option v-for="skin in appSkins" :key="skin.name" :value="skin.name.replace('.zip', '')">
{{ skin.name.replace('.zip', '') }}
</option>
</select>
<div v-if="!appSkins.length || !appSkins.some(s => s.name.replace('.zip', '') === appForm.certificates[certType].theme)" style="margin-top: 4px;">
<input v-model="appForm.certificates[certType].theme" type="text" placeholder="AutoPacking/ymh">
</div>
</div>
</div>
</div>
</div>
<!-- 皮肤包管理 -->
<h4 class="section-title" style="margin-top: 20px;">皮肤包管理</h4>
<div class="skins-section">
<div v-if="!appForm.upload_key" class="skins-hint">请先保存 APP 后再上传皮肤包</div>
<div v-else>
<div class="skins-list" v-if="appSkins.length">
<div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div>
</div>
<div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }">
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label>
</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">&times;</button>
</div>
<div class="form-group">
<label>Scheme 名称 *</label>
<input v-model="schemeForm.name" type="text" placeholder="例如readoor31">
</div>
<div class="form-group">
<label>显示名称</label>
<input v-model="schemeForm.displayName" type="text" placeholder="例如:标准功能">
</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 { computed, ref, onMounted, inject } from 'vue'
const showLogin = inject('showLogin')
const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin')
const tab = ref('apps')
// 用户管理
const users = ref([])
const showUserModal = ref(false)
const userForm = ref({ username: '', password: '', is_admin: false })
const showPasswordModal = ref(false)
const passwordTarget = ref(null)
const newPassword = ref('')
// 带认证的 fetch 封装
const authFetch = (url, options = {}) => {
const token = getToken()
if (token) {
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` }
}
return fetch(url, options)
}
const apps = ref({})
const schemes = ref({})
const servers = ref({})
const branches = ref([])
const newBranch = ref('')
const buildSettings = ref({})
const versions = ref({ app_ver: '', build_ver: '' })
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: '', app_id_prefix: null })
const nextServerPrefix = computed(() => {
const prefixes = Object.values(servers.value)
.map(server => Number(server.app_id_prefix))
.filter(prefix => Number.isInteger(prefix) && prefix > 0)
return Math.max(0, ...prefixes) + 1
})
const showAppModal = ref(false)
const editingAppId = ref(null)
const appForm = ref({})
const appSkins = ref([])
const skinUploading = ref(false)
const showSchemeModal = ref(false)
const editingSchemeId = ref(null)
const schemeForm = ref({})
onMounted(async () => {
await loadData()
})
const loadData = async () => {
if (!isAdmin.value) {
const [appsRes, versionsRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/versions'),
])
if (appsRes.ok) apps.value = await appsRes.json()
if (versionsRes.ok) versions.value = await versionsRes.json()
return
}
const [appsRes, schemesRes, serversRes, branchesRes, 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/build'),
authFetch('/api/config/upload'),
authFetch('/api/config'),
authFetch('/api/config/versions'),
authFetch('/api/users'),
])
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)
versions.value = await versionsRes.json()
if (usersRes.ok) users.value = await usersRes.json()
}
// 用户管理
const openUserModal = () => {
userForm.value = { username: '', password: '', is_admin: false }
showUserModal.value = true
}
const createUser = async () => {
if (!userForm.value.username || !userForm.value.password) {
alert('请填写用户名和密码')
return
}
if (userForm.value.password.length < 11) {
alert('密码至少 11 位')
return
}
try {
const res = await authFetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userForm.value),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '创建失败')
}
showUserModal.value = false
await loadData()
} catch (e) {
alert('创建失败: ' + e.message)
}
}
const openPasswordModal = (u) => {
passwordTarget.value = u
newPassword.value = ''
showPasswordModal.value = true
}
const changePassword = async () => {
if (!newPassword.value || newPassword.value.length < 11) {
alert('密码至少 11 位')
return
}
try {
const res = await authFetch(`/api/users/${passwordTarget.value.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword.value }),
})
if (!res.ok) throw new Error('修改失败')
showPasswordModal.value = false
alert('密码已更新')
} catch (e) {
alert('修改失败: ' + e.message)
}
}
const toggleUserRole = async (u) => {
try {
const res = await authFetch(`/api/users/${u.id}/role`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: !u.is_admin }),
})
if (!res.ok) throw new Error('操作失败')
await loadData()
} catch (e) {
alert('操作失败: ' + e.message)
}
}
const deleteUser = async (u) => {
if (!confirm(`确定删除用户「${u.username}」?`)) return
try {
const res = await authFetch(`/api/users/${u.id}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '删除失败')
}
await loadData()
} catch (e) {
alert('删除失败: ' + e.message)
}
}
// 服务器环境管理
const openServerModal = (name = null, server = null) => {
editingServerName.value = name
serverForm.value = server
? { ...server, name }
: { name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null }
showServerModal.value = true
}
const saveServer = async () => {
if (!serverForm.value.name || !serverForm.value.api) {
alert('请填写必填字段环境名称、API 地址')
return
}
try {
if (editingServerName.value) {
const res = await authFetch(`/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 authFetch('/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 authFetch(`/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: '',
app_id_prefix_override: '',
certificates: {},
}
}
showAppModal.value = true
loadSkins()
}
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 loadSkins = async () => {
const uploadKey = appForm.value.upload_key
if (!uploadKey) { appSkins.value = []; return }
try {
const res = await authFetch(`/api/config/skins/${uploadKey}`)
if (res.ok) appSkins.value = await res.json()
} catch { appSkins.value = [] }
}
const uploadSkin = async (event) => {
const file = event.target.files[0]
if (!file) return
const uploadKey = appForm.value.upload_key
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
skinUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const res = await authFetch(`/api/config/skins/${uploadKey}`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '上传失败')
}
await loadSkins()
event.target.value = ''
} catch (e) {
alert('上传失败: ' + e.message)
} finally {
skinUploading.value = false
}
}
const deleteSkin = async (skinName) => {
if (!confirm(`确定删除皮肤包 ${skinName}`)) return
const uploadKey = appForm.value.upload_key
try {
const res = await authFetch(`/api/config/skins/${uploadKey}/${skinName}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败')
await loadSkins()
} catch (e) {
alert('删除失败: ' + e.message)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
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 authFetch(`/api/config/apps/${editingAppId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cleanData),
})
} else {
res = await authFetch('/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 authFetch(`/api/config/apps/${id}`, { method: 'DELETE' })
await loadData()
}
// Scheme 相关
const openSchemeModal = (id = null, scheme = null) => {
editingSchemeId.value = id
schemeForm.value = scheme ? { ...scheme } : { name: '', displayName: '', ossFloder: '' }
showSchemeModal.value = true
}
const saveScheme = async () => {
if (!schemeForm.value.name || !schemeForm.value.ossFloder) {
alert('请填写必填字段名称、OSS 目录')
return
}
try {
if (editingSchemeId.value) {
const res = await authFetch(`/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 authFetch('/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 authFetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
await loadData()
}
// 分支管理
const addBranch = async () => {
const name = newBranch.value.trim()
if (!name) return
try {
const res = await authFetch('/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 authFetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败')
await loadData()
} catch (e) {
alert('删除失败: ' + e.message)
}
}
// 其他
const saveBuildSettings = async () => {
await authFetch('/api/config/build', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildSettings.value),
})
alert('设置已保存')
}
const saveVersions = async () => {
try {
const res = await authFetch('/api/config/versions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_ver: versions.value.app_ver }),
})
if (res.ok) {
const data = await res.json()
versions.value = { app_ver: data.app_ver, build_ver: data.build_ver }
alert('版本号已保存')
} else {
const err = await res.json()
alert(err.detail || '保存失败')
}
} catch (e) {
alert('保存失败')
}
}
const saveUploadConfig = async () => {
try {
await authFetch('/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 authFetch('/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; }
.skins-section { padding: 12px; background: #fafafa; border-radius: 8px; }
.skins-hint { color: #999; font-size: 13px; margin-bottom: 8px; }
.skins-list { margin-bottom: 12px; }
.skin-item { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.skin-item:last-child { border-bottom: none; }
.skin-name { font-size: 14px; color: #333; flex: 1; }
.skin-size { font-size: 12px; color: #999; }
.btn-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.btn-danger-icon { background: #fff1f0; color: #ff4d4f; }
.btn-danger-icon:hover { background: #ff4d4f; color: white; }
.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; }
.btn-outline:hover { background: #e6f7ff; }
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
.skin-upload-btn { margin-top: 8px; }
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
</style>