1169 lines
46 KiB
Vue
1169 lines
46 KiB
Vue
<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">×</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">×</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">×</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">×</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="删除">×</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">×</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>
|