Files
iOSBuildServer/frontend/src/views/ConfigView.vue
T
shenleiandClaude Opus 5 bf7a5a0449 feat: 上架版本号按分支拆分,master / develop 各自独立
原来 master 与 develop 共用一条 release 轨,版本号和构建号绑在一起。
现改为每个上架分支各有一条轨道(轨道名即分支名),自测分支仍共用 feature 轨。

- 轨道记录新增 kind(release/feature),branch_track 存轨道名
- 旧的共用 release 轨及更早的单轨结构,加载时自动按上架分支拆开,
  已用构建号原样复制,拆分后各分支从原来的号继续递增
- 构建号递增时跨上架轨取同一 App_Ver 的最大值 +1:App Store 的构建号
  唯一性按 MARKETING_VERSION 全局判定,避免两分支同版本号时重号被拒
- 分支管理页轨道选择改为「上架(本分支独立版本号)/ 自测(共用)」,
  打包设置页按上架分支分块填写版本号

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJaZ913U5GVdTkYHiRpvDU
2026-09-02 10:32:43 +09:00

1382 lines
56 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 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">
<!-- 用户管理 -->
<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;">
管理可打包的代码分支打包时从对应分支目录获取源码
版本轨道决定该分支使用哪一套版本号上架master/develop 共用App_Store 打包构建号自增)、自测feature 构建号不自增)。
</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>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="b in branches" :key="b">
<td><strong>{{ b }}</strong></td>
<td>
<select :value="branchKind(b)" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)">
<option value="release">上架本分支独立版本号</option>
<option value="feature">自测共用 feature 版本号</option>
</select>
</td>
<td class="action-btns">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
</td>
</tr>
<tr v-if="!branches.length">
<td colspan="3" 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 class="form-group">
<label>Ad_Hoc @ 开发人员手机号可选</label>
<textarea v-model="dingtalkAtMobiles" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 4px;">须是钉钉账号绑定的手机号Ad_Hoc 打包完成的通知会 @ 这些人</div>
</div>
<div class="form-group">
<label>App_Store @ 开发人员手机号可选</label>
<textarea v-model="dingtalkAtMobilesAppStore" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
<div style="font-size: 12px; color: #999; margin-top: 4px;">App_Store 打包完成的通知会 @ 这些人</div>
</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;">
<template v-for="t in releaseTracks" :key="t.name">
<h4 class="section-title">上架版本号{{ t.branches.join(' / ') }}</h4>
<div class="form-group">
<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(' / ') }} 分支与其它分支互不影响</div>
</div>
<div class="form-group">
<label>Build_Ver构建号</label>
<input type="text" v-model="versions[t.name].build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">
四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号
每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改
</div>
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions(t.name)">保存 {{ t.name }} 版本号</button>
</template>
<template v-if="versions.feature">
<h4 class="section-title">自测版本号feature 分支</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<input type="text" v-model="versions.feature.app_ver" placeholder="2.180.0" @input="onAppVerInput('feature')">
<div style="font-size: 12px; color: #999; margin-top: 4px;">所有自测分支共用与上架版本号各自独立互不影响</div>
</div>
<div class="form-group">
<label>Build_Ver构建号</label>
<input type="text" v-model="versions.feature.build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅供自测打包时<strong>不会自动递增</strong>始终使用这里填写的值默认 .0)。需要区分时请手动修改
</div>
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('feature')">保存自测版本号</button>
</template>
<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>企业微信 Schema</label>
<input v-model="appForm.wxworkSchema" type="text" placeholder="不填则不写入 Info.plist(与微信登录 AppID 相同)">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>腾讯 AppID</label>
<input v-model="appForm.tencent" type="text" placeholder="tencent...(可只填数字,自动补前缀)" @blur="normalizeTencentAppId">
</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>
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
更新
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
</label>
<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, dragover: skinDragover }"
@dragover.prevent="skinDragover = true"
@dragenter.prevent="skinDragover = true"
@dragleave.prevent="skinDragover = false"
@drop.prevent="onSkinDrop">
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.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({})
// 版本轨道:每个上架分支(master、develop 等)一条独立轨道,轨道名即分支名;
// 自测分支共用固定的 feature 轨。各轨道的版本号互不影响。
const versions = ref({}) // 轨道名 -> { app_ver, build_ver }
const buildMap = ref({}) // 轨道名 -> { App_Ver: 已用构建号 }
const trackKind = ref({}) // 轨道名 -> release | feature
const branchTrack = ref({}) // 分支名 -> 轨道名
const applyVersions = (data) => {
const tracks = data.tracks || {}
const nextVersions = {}
const nextMap = {}
const nextKind = {}
for (const [name, item] of Object.entries(tracks)) {
nextVersions[name] = { app_ver: item.app_ver || '', build_ver: item.build_ver || '' }
nextMap[name] = item.build_map || {}
nextKind[name] = item.kind || 'release'
}
versions.value = nextVersions
buildMap.value = nextMap
trackKind.value = nextKind
branchTrack.value = data.branch_track || {}
}
// 分支所属轨道的类型(供分支管理里的下拉框显示)
const branchKind = (branch) => trackKind.value[branchTrack.value[branch]] || 'feature'
// 打包设置里按上架轨道分块展示;多个分支共用一条轨道时只显示一块
const releaseTracks = computed(() => {
const seen = new Set()
return branches.value
.map(b => branchTrack.value[b])
.filter(name => name && trackKind.value[name] === 'release' && !seen.has(name) && seen.add(name))
.map(name => ({
name,
branches: branches.value.filter(b => branchTrack.value[b] === name),
}))
})
// 修改 App_Ver 时,自动把 Build_Ver 切换为该版本号已记录的构建号(新版本号则为 .0)
const onAppVerInput = (track) => {
const appVer = (versions.value[track]?.app_ver || '').trim()
if (/^\d+\.\d+\.\d+$/.test(appVer)) {
const n = Number((buildMap.value[track] || {})[appVer] || 0)
versions.value[track].build_ver = `${appVer}.${n}`
}
}
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
const jsonContent = ref('{}')
// 钉钉 @ 手机号:配置里存字符串数组,输入框按逗号/换行分隔编辑。
// Ad_Hoc 与 App_Store 各一份名单。
const dingtalkAtMobilesModel = (key) => computed({
get: () => (uploadConfig.value.dingtalk?.[key] || []).join('\n'),
set: (val) => {
if (!uploadConfig.value.dingtalk) uploadConfig.value.dingtalk = {}
uploadConfig.value.dingtalk[key] = val
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
},
})
const dingtalkAtMobiles = dingtalkAtMobilesModel('at_mobiles')
const dingtalkAtMobilesAppStore = dingtalkAtMobilesModel('at_mobiles_app_store')
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 skinDragover = ref(false)
const showSchemeModal = ref(false)
const editingSchemeId = ref(null)
const schemeForm = ref({})
onMounted(async () => {
await loadData()
})
const loadData = async () => {
if (!isAdmin.value) {
// 普通用户只能管理 Apps,版本号等配置均为管理员专属
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'),
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)
applyVersions(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: '',
wxworkSchema: '',
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 uploadSkinFile = async (file) => {
if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); 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()
// 若某证书的主题目录还是空的,自动选中刚上传的皮肤
const skinTheme = file.name.replace(/\.zip$/i, '')
const certs = appForm.value.certificates || {}
Object.keys(certs).forEach(certType => {
if (!certs[certType].theme) certs[certType].theme = skinTheme
})
} catch (e) {
alert('上传失败: ' + e.message)
} finally {
skinUploading.value = false
}
}
const uploadSkin = async (event) => {
await uploadSkinFile(event.target.files[0])
event.target.value = ''
}
const onSkinDrop = (event) => {
skinDragover.value = false
if (skinUploading.value) return
const file = event.dataTransfer.files[0]
uploadSkinFile(file)
}
const updateSkin = async (skinName, event) => {
const file = event.target.files[0]
if (!file) return
const uploadKey = appForm.value.upload_key
if (!uploadKey) { event.target.value = ''; return }
skinUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const res = await authFetch(`/api/config/skins/${uploadKey}/${skinName}`, {
method: 'PUT',
body: formData,
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '更新失败')
}
await loadSkins()
} catch (e) {
alert('更新失败: ' + e.message)
} finally {
event.target.value = ''
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'
}
// 腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)
const normalizeTencentAppId = () => {
const value = (appForm.value.tencent || '').trim()
appForm.value.tencent = value && !value.startsWith('tencent') ? `tencent${value}` : value
}
const saveApp = async () => {
if (!appForm.value.name || !appForm.value.AppGuid) {
alert('请填写必填字段:App 名称、AppGuid')
return
}
normalizeTencentAppId()
// 清理空证书
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 || '保存失败')
}
await loadData()
if (!editingAppId.value) {
// 新建成功:不关弹窗,转为编辑态并回填 upload_key,让皮肤包上传区立即可用
const data = await res.json().catch(() => ({}))
editingAppId.value = data.id || editingAppId.value
if (data.upload_key) appForm.value.upload_key = data.upload_key
await loadSkins()
alert('保存成功,现在可以上传皮肤包了')
} else {
showAppModal.value = false
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 setBranchTrack = async (name, track) => {
try {
const res = await authFetch('/api/config/branches/track', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: name, track }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '修改失败')
}
// 切到上架轨可能新建了轨道,重新拉取版本号
applyVersions(await (await authFetch('/api/config/versions')).json())
} catch (e) {
alert('修改失败: ' + e.message)
await loadData()
}
}
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 (track) => {
try {
const res = await authFetch('/api/config/versions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track,
app_ver: versions.value[track].app_ver,
build_ver: versions.value[track].build_ver,
}),
})
if (res.ok) {
const data = await res.json()
// 回写该版本号最新的构建号,保持本地 build_map 与服务端一致
buildMap.value[track] = {
...(buildMap.value[track] || {}),
[data.app_ver]: Number((data.build_ver.split('.')[3]) || 0),
}
versions.value[track] = { 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; }
.skin-update-btn { font-size: 12px; color: #1890ff; cursor: pointer; padding: 2px 8px; border: 1px solid #91d5ff; border-radius: 4px; background: #e6f7ff; line-height: 1.4; }
.skin-update-btn:hover { background: #1890ff; color: white; }
.skin-update-btn.disabled { opacity: 0.5; pointer-events: none; }
.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; transition: background 0.15s, border-color 0.15s; }
.skin-upload-btn.dragover { border-color: #1890ff; border-style: dashed; background: #e6f7ff; color: #1890ff; }
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
</style>