update:文件地址

This commit is contained in:
ltx 2026-11-05 16:38:39 +08:00
parent 8c6c137f76
commit f37c98a55c

View File

@ -1,59 +1,287 @@
<template>
<div class="layout-padding release-page">
<el-card>
<template #header><div class="heading"><div><h2>裁判端版本管理</h2><small>仅超级管理员可发布。当前环境:{{ channelLabel }}</small></div><el-button @click="load">刷新列表</el-button></div></template>
<el-alert title="正式签名首次切换无法覆盖旧调试签名 APK;请先完成逐台迁移检查。发布前必须核对版本号、包名和签名指纹。" type="warning" :closable="false" show-icon />
<el-form label-width="130px" class="upload-form">
<el-form-item label="APK 文件"><input type="file" accept=".apk,application/vnd.android.package-archive" @change="selectFile" /><span v-if="file">{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span></el-form-item>
<el-form-item label="更新说明(中文)"><el-input v-model="notesZh" type="textarea" :rows="2" maxlength="2000" show-word-limit /></el-form-item>
<el-form-item label="Release notes"><el-input v-model="notesEn" type="textarea" :rows="2" maxlength="2000" show-word-limit /></el-form-item>
<el-form-item label="紧急强制更新"><el-switch v-model="mandatory" /><small class="hint">评分中不打断;断网仍可继续离线评分。</small></el-form-item>
<el-form-item><el-button type="primary" :loading="uploading" :disabled="!file" @click="upload">上传为草稿</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="releases" border stripe>
<el-table-column prop="versionName" label="版本" width="100" />
<el-table-column prop="versionCode" label="版本号" width="100" />
<el-table-column prop="releaseStatus" label="状态" width="100"><template #default="{row}">{{ ['草稿','已发布','已撤回'][row.releaseStatus] || '未知' }}</template></el-table-column>
<el-table-column prop="isMandatory" label="策略" width="100"><template #default="{row}">{{ row.isMandatory ? '强制' : '可选' }}</template></el-table-column>
<el-table-column prop="notesZh" label="更新说明" min-width="180" show-overflow-tooltip />
<el-table-column prop="sha256" label="文件 SHA-256" min-width="210" show-overflow-tooltip />
<el-table-column prop="certificateSha256" label="签名指纹" min-width="210" show-overflow-tooltip />
<el-table-column prop="publishedTime" label="发布时间" width="180" />
<el-table-column label="操作" width="165" fixed="right"><template #default="{row}"><el-button v-if="row.releaseStatus === 0" text type="primary" @click="publish(row)">发布</el-button><el-button v-if="row.releaseStatus === 1" text type="danger" @click="withdraw(row)">撤回</el-button></template></el-table-column>
<div class="release-page">
<section class="hero">
<div class="hero-pattern" aria-hidden="true"></div>
<div class="hero-copy">
<div class="eyebrow"><span class="eyebrow-dot"></span> APP RELEASE CENTER <span class="eyebrow-divider">/</span> 内部分发</div>
<h1>裁判端版本管理</h1>
<p>从安装包上传到版本发布,让每一次升级都清晰可追溯。</p>
<div class="hero-endpoint"><span>当前接口</span><strong>{{ channelLabel }}</strong></div>
</div>
<div class="hero-side">
<el-button class="refresh-button" :icon="Refresh" :loading="loading" @click="load">刷新记录</el-button>
<div class="hero-stats">
<div><strong>{{ publishedCount }}</strong><span>已发布</span></div>
<div><strong>{{ draftCount }}</strong><span>待发布</span></div>
<div><strong>{{ latestVersion }}</strong><span>最新版本</span></div>
</div>
</div>
</section>
<div class="safety-note">
<el-icon><WarningFilled /></el-icon>
<span>当前服务端暂不解析 APK 内的包名、版本和签名。请选用同一次打包生成的 APK 与 <code>build-info.json</code>,人工核对后发布;平板安装前仍会校验实际安装包。</span>
</div>
<div class="workspace">
<section class="panel upload-panel">
<div class="panel-heading">
<span class="step-number">01</span>
<div><h2>上传构建文件</h2><p>拖拽文件到对应区域,或点击区域浏览本地文件</p></div>
</div>
<div class="dropzones">
<div class="dropzone-wrap">
<div class="dropzone-label">Android 安装包 <span class="required">必选</span></div>
<el-upload class="dropzone" :class="{ selected: !!file }" drag :auto-upload="false" :show-file-list="false" accept=".apk,application/vnd.android.package-archive" :on-change="selectApk">
<span class="drop-icon"><el-icon><UploadFilled /></el-icon></span>
<strong>{{ file ? '安装包已就绪' : '拖拽 APK 到这里' }}</strong>
<span class="drop-hint">{{ file ? file.name : '或点击选择 .apk 文件' }}</span>
<span class="drop-meta">{{ file ? formatBytes(file.size) + ' · 点击可更换' : '支持最大 200 MB' }}</span>
</el-upload>
</div>
<div class="dropzone-wrap">
<div class="dropzone-label">构建信息 <span class="optional">推荐</span></div>
<el-upload class="dropzone metadata-dropzone" :class="{ selected: !!buildInfoName }" drag :auto-upload="false" :show-file-list="false" accept=".json,application/json" :on-change="selectBuildInfo">
<span class="drop-icon"><el-icon><Document /></el-icon></span>
<strong>{{ buildInfoName ? '构建信息已读取' : '拖拽构建信息到这里' }}</strong>
<span class="drop-hint">{{ buildInfoName || '或点击选择 build-info.json' }}</span>
<span class="drop-meta">只在浏览器本地读取,不上传 JSON</span>
</el-upload>
</div>
</div>
<div class="upload-tip"><el-icon><CircleCheck /></el-icon><span>建议从同一个打包输出目录选取两个文件;APK 将在服务端计算 SHA-256。</span></div>
</section>
<section class="panel details-panel">
<div class="panel-heading">
<span class="step-number">02</span>
<div><h2>填写发布信息</h2><p>构建信息会自动带入,发布前请再次核对</p></div>
</div>
<el-form label-position="top" class="release-form">
<div class="form-row">
<el-form-item label="版本名"><el-input v-model="versionName" placeholder="例如 1.0.0" maxlength="64" /></el-form-item>
<el-form-item label="版本号"><el-input-number v-model="versionCode" :min="1" :max="999999999" :precision="0" controls-position="right" /><span class="field-help">需高于设备已安装版本</span></el-form-item>
</div>
<el-form-item label="公开签名证书 SHA-256"><el-input v-model="certificateSha256" placeholder="64 位十六进制指纹,不是密钥密码" /><span class="field-help">仅用于 App 对照签名;不要填写或上传私钥密码</span></el-form-item>
<el-form-item label="更新说明"><el-input v-model="notesZh" type="textarea" :rows="3" maxlength="2000" show-word-limit placeholder="简要说明本次更新内容" /></el-form-item>
<el-form-item label="Release notes(选填)"><el-input v-model="notesEn" type="textarea" :rows="2" maxlength="2000" show-word-limit placeholder="English release notes" /></el-form-item>
<div class="policy-box">
<div><strong>紧急强制更新</strong><p>评分中不打断;待提交内容会先同步。普通更新可由裁判稍后处理。</p></div>
<el-switch v-model="mandatory" />
</div>
</el-form>
<div class="form-footer">
<span>先保存为草稿,核对无误后再发布</span>
<el-button type="primary" class="upload-button" :loading="uploading" :disabled="!file" @click="upload">上传为草稿</el-button>
</div>
</section>
</div>
<section class="panel history-panel">
<div class="history-heading">
<div class="panel-heading"><span class="step-number">03</span><div><h2>发布记录</h2><p>查看草稿、已发布及撤回的历史版本</p></div></div>
<span class="record-count">共 {{ releases.length }} 条记录</span>
</div>
<el-table v-loading="loading" :data="releases" class="release-table" empty-text="暂无版本记录" table-layout="fixed">
<el-table-column label="版本" min-width="150"><template #default="{ row }"><div class="version-cell"><strong>{{ row.versionName }}</strong><small>Build {{ row.versionCode }}</small></div></template></el-table-column>
<el-table-column label="状态" width="112"><template #default="{ row }"><el-tag round :type="statusType(row.releaseStatus)" effect="light">{{ statusLabel(row.releaseStatus) }}</el-tag></template></el-table-column>
<el-table-column label="策略" width="112"><template #default="{ row }"><span :class="['strategy', row.isMandatory ? 'mandatory' : 'optional']">{{ row.isMandatory ? '强制更新' : '可选更新' }}</span></template></el-table-column>
<el-table-column prop="notesZh" label="更新说明" min-width="190" show-overflow-tooltip />
<el-table-column label="文件大小" width="112"><template #default="{ row }">{{ formatBytes(row.fileSize) }}</template></el-table-column>
<el-table-column label="文件 SHA-256" min-width="145"><template #default="{ row }"><el-tooltip :content="row.sha256" placement="top"><span class="hash-text">{{ shortHash(row.sha256) }}</span></el-tooltip></template></el-table-column>
<el-table-column label="签名指纹" min-width="145"><template #default="{ row }"><el-tooltip :content="row.certificateSha256" placement="top"><span class="hash-text">{{ shortHash(row.certificateSha256) }}</span></el-tooltip></template></el-table-column>
<el-table-column label="发布时间" min-width="155"><template #default="{ row }">{{ formatTime(row.publishedTime) }}</template></el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button v-if="row.releaseStatus === 0" text type="primary" @click="publish(row)">发布</el-button><el-button v-else-if="row.releaseStatus === 1" text type="danger" @click="withdraw(row)">撤回</el-button><span v-else class="muted">—</span></template></el-table-column>
</el-table>
</el-card>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { UploadFile } from 'element-plus';
import { CircleCheck, Document, Refresh, UploadFilled, WarningFilled } from '@element-plus/icons-vue';
import http from '/@/utils/request';
type Release = { id: string; versionName: string; versionCode: number; releaseStatus: number; isMandatory: boolean; notesZh: string; sha256: string; certificateSha256: string; publishedTime?: string };
type Release = {
id: string;
versionName: string;
versionCode: number;
releaseStatus: number;
isMandatory: boolean;
notesZh: string;
fileSize: number;
sha256: string;
certificateSha256: string;
publishedTime?: string;
};
const releases = ref<Release[]>([]);
const file = ref<File | null>(null);
const buildInfoName = ref('');
const versionName = ref('');
const versionCode = ref<number | undefined>(undefined);
const certificateSha256 = ref('');
const notesZh = ref('');
const notesEn = ref('');
const mandatory = ref(false);
const loading = ref(false);
const uploading = ref(false);
const channelLabel = import.meta.env.VITE_API_URL;
function selectFile(event: Event) { file.value = (event.target as HTMLInputElement).files?.[0] || null; }
const channelLabel = import.meta.env.VITE_API_URL || '未配置';
const publishedCount = computed(() => releases.value.filter((item) => item.releaseStatus === 1).length);
const draftCount = computed(() => releases.value.filter((item) => item.releaseStatus === 0).length);
const latestVersion = computed(() => {
const latest = releases.value.filter((item) => item.releaseStatus === 1).sort((a, b) => b.versionCode - a.versionCode)[0];
return latest?.versionName || '—';
});
function formatBytes(size?: number) {
if (!size || size < 0) return '—';
return size >= 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(1)} MB` : `${(size / 1024).toFixed(1)} KB`;
}
function shortHash(value?: string) { return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : '—'; }
function formatTime(value?: string) {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false });
}
function statusLabel(status: number) { return ['草稿', '已发布', '已撤回'][status] || '未知'; }
function statusType(status: number): 'warning' | 'success' | 'info' { return status === 1 ? 'success' : status === 0 ? 'warning' : 'info'; }
function selectApk(uploadFile: UploadFile) {
const selected = uploadFile.raw;
if (!selected) return;
if (!selected.name.toLowerCase().endsWith('.apk')) return ElMessage.warning('请选择 .apk 安装包');
file.value = selected;
}
async function selectBuildInfo(uploadFile: UploadFile) {
const selected = uploadFile.raw;
if (!selected) return;
buildInfoName.value = '';
try {
const info = JSON.parse(await selected.text());
if (info.mode !== 'release' || info.packageName !== 'com.yd.event.yd_pattern_scoring_system' ||
!Number.isInteger(info.versionCode) || !/^[A-Fa-f0-9]{64}$/.test(info.certificateSha256 || '')) {
throw new Error('构建信息不是有效的正式版记录');
}
versionName.value = String(info.versionName || '');
versionCode.value = info.versionCode;
certificateSha256.value = info.certificateSha256.toUpperCase();
buildInfoName.value = selected.name;
ElMessage.success('已读取构建信息,请确认与所选 APK 来自同一次打包');
} catch { ElMessage.error('无法读取正式版 build-info.json,请手动填写并核对'); }
}
async function load() { loading.value = true; try { const response = await http.get('/app-updates/admin/releases'); if (response.data?.statusCode !== 200) throw new Error('加载失败'); releases.value = Array.isArray(response.data?.data) ? response.data.data : []; } catch { ElMessage.error('无法加载版本列表'); } finally { loading.value = false; } }
async function upload() {
if (!file.value || !notesZh.value.trim()) return ElMessage.warning('请选择 APK 并填写中文更新说明');
if (!file.value.name.toLowerCase().endsWith('.apk') || file.value.size > 200 * 1024 * 1024) return ElMessage.warning('仅支持不超过 200 MB 的 APK');
const form = new FormData(); form.append('file', file.value); form.append('notesZh', notesZh.value); form.append('notesEn', notesEn.value); form.append('isMandatory', String(mandatory.value));
const fingerprint = certificateSha256.value.replace(/:/g, '').trim().toUpperCase();
if (!versionName.value.trim() || !Number.isInteger(versionCode.value) || (versionCode.value ?? 0) <= 0 || !/^[A-F0-9]{64}$/.test(fingerprint)) {
return ElMessage.warning('请核对版本名、整数版本号和 64 位公开签名指纹');
}
const form = new FormData();
form.append('file', file.value);
form.append('versionName', versionName.value.trim());
form.append('versionCode', String(versionCode.value));
form.append('certificateSha256', fingerprint);
form.append('notesZh', notesZh.value);
form.append('notesEn', notesEn.value);
form.append('isMandatory', String(mandatory.value));
uploading.value = true;
try { const response = await http.post('/app-updates/admin/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 300000 }); if (response.data?.statusCode !== 200) throw new Error('上传失败'); ElMessage.success('已上传为草稿,请核对后发布'); file.value = null; await load(); }
catch { ElMessage.error('上传失败,请核对 APK、签名和服务器配置'); }
try { const response = await http.post('/app-updates/admin/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 300000 }); if (response.data?.statusCode !== 200) throw new Error('上传失败'); ElMessage.success('已上传为草稿,请核对后发布'); file.value = null; buildInfoName.value = ''; await load(); }
catch { ElMessage.error('上传失败,请查看服务器返回的具体错误'); }
finally { uploading.value = false; }
}
async function publish(row: Release) { await ElMessageBox.confirm(`确认发布 ${row.versionName}(${row.versionCode})?`, '发布确认'); try { const response = await http.post(`/app-updates/admin/${row.id}/publish`, {}); if (response.data?.statusCode !== 200) throw new Error('发布失败'); ElMessage.success('发布成功'); await load(); } catch { ElMessage.error('发布失败'); } }
async function withdraw(row: Release) { await ElMessageBox.confirm(`确认撤回 ${row.versionName}?`, '撤回确认'); try { const response = await http.post(`/app-updates/admin/${row.id}/withdraw`, {}); if (response.data?.statusCode !== 200) throw new Error('撤回失败'); ElMessage.success('已撤回'); await load(); } catch { ElMessage.error('撤回失败'); } }
async function publish(row: Release) {
const confirmed = await ElMessageBox.confirm(`请确认安装包与构建信息匹配。发布 ${row.versionName}(${row.versionCode})后,裁判端将可检查到此版本。`, '发布确认', { type: 'warning' }).then(() => true, () => false);
if (!confirmed) return;
try { const response = await http.post(`/app-updates/admin/${row.id}/publish`, {}); if (response.data?.statusCode !== 200) throw new Error('发布失败'); ElMessage.success('发布成功'); await load(); } catch { ElMessage.error('发布失败'); }
}
async function withdraw(row: Release) {
const confirmed = await ElMessageBox.confirm(`确认撤回 ${row.versionName}?`, '撤回确认', { type: 'warning' }).then(() => true, () => false);
if (!confirmed) return;
try { const response = await http.post(`/app-updates/admin/${row.id}/withdraw`, {}); if (response.data?.statusCode !== 200) throw new Error('撤回失败'); ElMessage.success('已撤回'); await load(); } catch { ElMessage.error('撤回失败'); }
}
onMounted(load);
</script>
<style scoped>
.release-page{padding:22px}.heading{display:flex;justify-content:space-between;align-items:center}.heading h2{margin:0 0 5px}.heading small,.hint{color:#8193a7}.upload-form{max-width:760px;margin:22px 0}.hint{margin-left:10px}
.release-page { min-height: 100%; padding: 24px clamp(16px, 2.6vw, 36px) 42px; color: #203553; background: radial-gradient(circle at 92% 2%, #e7f1ff 0, transparent 27%), #f5f8fd; }
.hero { position: relative; display: flex; justify-content: space-between; gap: 32px; min-height: 210px; overflow: hidden; padding: 30px 38px; border-radius: 24px; color: #fff; background: linear-gradient(112deg, #103b83 0%, #1668cf 57%, #438fe9 100%); box-shadow: 0 16px 36px rgba(17, 80, 159, .16); }
.hero::after { position: absolute; right: -90px; bottom: -166px; width: 480px; height: 480px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 50%; content: ''; }
.hero-pattern { position: absolute; right: 72px; top: -115px; width: 390px; height: 390px; border: 1px solid rgba(255, 255, 255, .14); border-radius: 50%; box-shadow: 0 0 0 45px rgba(255, 255, 255, .035), 0 0 0 92px rgba(255, 255, 255, .025); }
.hero-copy, .hero-side { position: relative; z-index: 1; }
.eyebrow { display: flex; align-items: center; gap: 9px; font-size: 11px; font-weight: 700; letter-spacing: .14em; color: #d5eaff; }
.eyebrow-dot { width: 7px; height: 7px; border-radius: 50%; background: #7de1ed; box-shadow: 0 0 0 4px rgba(125, 225, 237, .15); }
.eyebrow-divider { color: rgba(255, 255, 255, .45); }
.hero h1 { margin: 20px 0 8px; font-size: clamp(24px, 2.3vw, 34px); font-weight: 700; letter-spacing: .02em; }
.hero p { margin: 0; font-size: 14px; color: #d8eaff; }
.hero-endpoint { display: flex; align-items: center; gap: 10px; margin-top: 25px; font-size: 12px; color: #bad9ff; }
.hero-endpoint strong { max-width: 520px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #fff; }
.hero-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; min-width: 350px; }
.refresh-button { border-color: rgba(255, 255, 255, .36); color: #fff; background: rgba(255, 255, 255, .12); }
.refresh-button:hover { border-color: #fff; color: #1555a8; background: #fff; }
.hero-stats { display: flex; gap: 10px; }
.hero-stats > div { display: flex; flex-direction: column; gap: 6px; min-width: 98px; padding: 13px 16px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 13px; background: rgba(255, 255, 255, .1); backdrop-filter: blur(4px); }
.hero-stats strong { font-size: 20px; line-height: 1; }
.hero-stats span { font-size: 11px; color: #d3e7ff; }
.safety-note { display: flex; align-items: flex-start; gap: 10px; margin: 18px 0; padding: 13px 16px; border: 1px solid #f6dfad; border-radius: 13px; background: #fffaf0; color: #825e23; font-size: 12px; line-height: 1.7; }
.safety-note .el-icon { flex: none; margin-top: 2px; font-size: 17px; color: #d99a27; }
.safety-note code { padding: 2px 5px; border-radius: 4px; background: #fff0cd; font-size: 11px; }
.workspace { display: grid; grid-template-columns: minmax(0, 1.03fr) minmax(390px, .97fr); gap: 18px; align-items: stretch; }
.panel { min-width: 0; border: 1px solid #e5ecf6; border-radius: 19px; background: #fff; box-shadow: 0 8px 26px rgba(35, 77, 128, .045); }
.upload-panel, .details-panel { padding: 24px; }
.panel-heading { display: flex; align-items: flex-start; gap: 13px; }
.step-number { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 35px; height: 35px; border-radius: 11px; background: #eaf3ff; color: #1676d8; font-size: 13px; font-weight: 800; }
.panel-heading h2 { margin: 1px 0 5px; font-size: 17px; line-height: 1.2; color: #17345d; }
.panel-heading p { margin: 0; font-size: 12px; line-height: 1.5; color: #8293aa; }
.dropzones { display: grid; gap: 18px; margin-top: 25px; }
.dropzone-label { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; font-size: 12px; font-weight: 700; color: #334c6e; }
.required, .optional { padding: 2px 7px; border-radius: 5px; font-size: 10px; font-weight: 600; }
.required { color: #e56762; background: #fff0ef; }
.optional { color: #5482b8; background: #ecf4ff; }
.dropzone { display: block; width: 100%; }
.dropzone :deep(.el-upload), .dropzone :deep(.el-upload-dragger) { width: 100%; }
.dropzone :deep(.el-upload-dragger) { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 182px; padding: 18px; border: 1.5px dashed #b8cfec; border-radius: 15px; background: #f8fbff; transition: border-color .2s, background .2s, box-shadow .2s; }
.dropzone :deep(.el-upload-dragger:hover), .dropzone :deep(.el-upload-dragger.is-dragover) { border-color: #2888ed; background: #f0f7ff; box-shadow: inset 0 0 0 1px rgba(40, 136, 237, .13); }
.dropzone.selected :deep(.el-upload-dragger) { border-style: solid; border-color: #7bb7f4; background: #f1f8ff; }
.metadata-dropzone :deep(.el-upload-dragger) { height: 158px; }
.drop-icon { display: inline-flex; align-items: center; justify-content: center; width: 46px; height: 46px; margin-bottom: 11px; border-radius: 14px; background: #e6f1ff; color: #1882e8; font-size: 23px; }
.metadata-dropzone .drop-icon { background: #ecf7f9; color: #2d9cab; }
.dropzone strong { color: #28527e; font-size: 14px; }
.drop-hint { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 6px; color: #66819f; font-size: 12px; }
.drop-meta { margin-top: 7px; color: #9cabc0; font-size: 11px; }
.upload-tip { display: flex; align-items: flex-start; gap: 8px; margin-top: 21px; padding: 13px 15px; border-radius: 10px; background: #f2f8ff; color: #5d7b9f; font-size: 12px; line-height: 1.6; }
.upload-tip .el-icon { flex: none; margin-top: 2px; color: #2185e5; font-size: 15px; }
.release-form { margin-top: 24px; }
.release-form :deep(.el-form-item) { margin-bottom: 18px; }
.release-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344e70; font-size: 12px; font-weight: 700; }
.release-form :deep(.el-input__wrapper), .release-form :deep(.el-textarea__inner) { border-radius: 9px; }
.release-form :deep(.el-input-number) { width: 100%; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.field-help { display: block; margin-top: 5px; color: #91a0b4; font-size: 11px; line-height: 1.4; }
.policy-box { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 15px; border: 1px solid #e8eff8; border-radius: 11px; background: #f8fbff; }
.policy-box strong { font-size: 12px; color: #294663; }
.policy-box p { margin: 5px 0 0; font-size: 11px; line-height: 1.5; color: #899bb2; }
.form-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 20px; padding-top: 18px; border-top: 1px solid #eef2f7; }
.form-footer span { color: #8b9cb0; font-size: 11px; }
.upload-button { min-width: 130px; height: 38px; border: 0; border-radius: 9px; background: linear-gradient(110deg, #1686ee, #1265ce); box-shadow: 0 7px 15px rgba(27, 115, 214, .19); }
.upload-button:hover { background: linear-gradient(110deg, #3198f1, #1d74da); }
.history-panel { margin-top: 18px; padding: 23px 24px 12px; }
.history-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 17px; }
.record-count { padding: 7px 10px; border-radius: 7px; background: #f3f7fc; color: #7890aa; font-size: 11px; }
.release-table { width: 100%; }
.release-table :deep(th.el-table__cell) { height: 44px; background: #f6f9fe; color: #607794; font-size: 12px; font-weight: 700; }
.release-table :deep(td.el-table__cell) { height: 60px; color: #526b88; font-size: 12px; }
.release-table :deep(.el-table__inner-wrapper::before) { display: none; }
.version-cell { display: flex; flex-direction: column; gap: 4px; }
.version-cell strong { color: #1c416f; font-size: 13px; }
.version-cell small { color: #96a7b9; font-size: 11px; }
.strategy { font-size: 11px; font-weight: 600; }
.strategy.mandatory { color: #e17646; }
.strategy.optional { color: #6084ad; }
.hash-text { color: #4176af; cursor: help; font-family: Consolas, monospace; font-size: 11px; }
.muted { color: #adbac9; }
@media (max-width: 1180px) { .workspace { grid-template-columns: 1fr; } .dropzones { grid-template-columns: 1fr 1fr; } .metadata-dropzone :deep(.el-upload-dragger) { height: 182px; } }
@media (max-width: 740px) { .release-page { padding: 14px 12px 28px; } .hero { flex-direction: column; gap: 24px; min-height: 0; padding: 24px; } .hero-side { align-items: stretch; min-width: 0; gap: 15px; } .hero-stats > div { flex: 1; min-width: 0; } .hero-endpoint strong { max-width: 250px; } .dropzones, .form-row { grid-template-columns: 1fr; } .upload-panel, .details-panel, .history-panel { padding: 18px; } .form-footer { flex-direction: column; align-items: stretch; } }
</style>