230 lines
8.9 KiB
Vue
230 lines
8.9 KiB
Vue
<script setup>
|
||
// 后台:桌面端版本管理 — 上传安装包(从文件名解析版本)、改更新说明、只保留最近 3 个包
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import http, { toast, serverBase } from '../../api/http'
|
||
import NavIcon from '../../components/NavIcon.vue'
|
||
|
||
const data = ref(null)
|
||
const notes = ref('')
|
||
const force = ref(false)
|
||
const saving = ref(false)
|
||
const uploading = ref(false)
|
||
const fileInput = ref(null)
|
||
const pickedName = ref('')
|
||
const progress = ref(0)
|
||
|
||
const releases = computed(() => data.value?.releases || [])
|
||
|
||
function fmtSize(n) {
|
||
if (!n && n !== 0) return '-'
|
||
if (n < 1024) return n + ' B'
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||
return (n / 1024 / 1024).toFixed(1) + ' MB'
|
||
}
|
||
|
||
function fmtTime(ts) {
|
||
if (!ts) return '-'
|
||
const d = new Date(ts * 1000)
|
||
const p = (x) => String(x).padStart(2, '0')
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||
}
|
||
|
||
function absURL(u) {
|
||
if (!u) return ''
|
||
if (/^https?:\/\//i.test(u)) return u
|
||
const base = serverBase() || (typeof location !== 'undefined' ? location.origin : '')
|
||
return base.replace(/\/+$/, '') + (u.startsWith('/') ? u : '/' + u)
|
||
}
|
||
|
||
async function load() {
|
||
data.value = await http.get('/admin/version')
|
||
notes.value = data.value.release_notes || ''
|
||
force.value = Number(data.value.force) === 1
|
||
}
|
||
|
||
async function saveMeta() {
|
||
saving.value = true
|
||
try {
|
||
data.value = await http.put('/admin/version', {
|
||
release_notes: notes.value,
|
||
force: force.value ? 1 : 0,
|
||
})
|
||
toast('已保存', 'success')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function onPick(e) {
|
||
const f = e.target.files?.[0]
|
||
pickedName.value = f ? f.name : ''
|
||
}
|
||
|
||
async function upload() {
|
||
const f = fileInput.value?.files?.[0]
|
||
if (!f) {
|
||
toast('请先选择安装包')
|
||
return
|
||
}
|
||
uploading.value = true
|
||
progress.value = 0
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('file', f)
|
||
fd.append('release_notes', notes.value || '')
|
||
fd.append('force', force.value ? '1' : '0')
|
||
data.value = await http.post('/admin/version/upload', fd, {
|
||
timeout: 300000,
|
||
onUploadProgress: (ev) => {
|
||
if (ev.total) progress.value = Math.round((ev.loaded / ev.total) * 100)
|
||
},
|
||
})
|
||
notes.value = data.value.release_notes || ''
|
||
force.value = Number(data.value.force) === 1
|
||
pickedName.value = ''
|
||
if (fileInput.value) fileInput.value.value = ''
|
||
toast(`已发布 v${data.value.version},自动保留最近 ${data.value.keep} 个包`, 'success')
|
||
} finally {
|
||
uploading.value = false
|
||
progress.value = 0
|
||
}
|
||
}
|
||
|
||
async function remove(item) {
|
||
if (!confirm(`确定删除 ${item.filename}?`)) return
|
||
data.value = await http.delete('/admin/version/' + encodeURIComponent(item.filename))
|
||
toast('已删除', 'success')
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="data">
|
||
<h2 class="page-title">桌面版本</h2>
|
||
<div class="panel intro">
|
||
<p>
|
||
上传 Electron 安装包(如 <code>PixelArcade-Setup-1.0.1.exe</code>),系统从<strong>文件名</strong>解析版本号,
|
||
写入公开接口 <code>/api/version/latest</code>,桌面端「检查更新」会在应用内下载并启动安装程序。
|
||
安装包地址为 <code>/api/download/…</code>(走已有 API 反代)。磁盘上<strong>只保留最近 {{ data.keep }} 个</strong>安装包。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="panel cur">
|
||
<h3 class="sec-head"><NavIcon name="download" :size="16" /> 当前对外版本</h3>
|
||
<div class="cur-row">
|
||
<div class="kv"><span class="k">版本号</span><b class="v">{{ data.version || '-' }}</b></div>
|
||
<div class="kv grow">
|
||
<span class="k">下载地址</span>
|
||
<a v-if="data.download_url" class="v link" :href="absURL(data.download_url)" target="_blank" rel="noopener">{{ absURL(data.download_url) }}</a>
|
||
<span v-else class="v text-dim">尚未上传</span>
|
||
</div>
|
||
</div>
|
||
<label class="field">
|
||
<span>更新说明</span>
|
||
<textarea v-model="notes" rows="2" placeholder="一行简短文案,桌面端更新弹窗展示" />
|
||
</label>
|
||
<label class="force">
|
||
<input v-model="force" type="checkbox" />
|
||
强制更新(桌面端不允许跳过)
|
||
</label>
|
||
<div class="actions">
|
||
<button class="btn primary" :disabled="saving" @click="saveMeta">
|
||
<NavIcon name="save" :size="15" /> {{ saving ? '保存中…' : '保存说明' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel upload">
|
||
<h3 class="sec-head"><NavIcon name="upload" :size="16" /> 上传安装包</h3>
|
||
<div class="pick">
|
||
<input ref="fileInput" type="file" accept=".exe,.msi,.dmg,.zip,.7z" @change="onPick" />
|
||
<span class="picked text-dim">{{ pickedName || '未选择文件' }}</span>
|
||
</div>
|
||
<div v-if="uploading" class="bar"><i :style="{ width: progress + '%' }" /></div>
|
||
<div class="actions">
|
||
<button class="btn primary" :disabled="uploading || !pickedName" @click="upload">
|
||
{{ uploading ? `上传中 ${progress}%` : '上传并发布' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel list">
|
||
<h3 class="sec-head">已保留的安装包({{ releases.length }} / {{ data.keep }})</h3>
|
||
<table v-if="releases.length" class="tbl">
|
||
<thead>
|
||
<tr>
|
||
<th>版本</th>
|
||
<th>文件名</th>
|
||
<th>大小</th>
|
||
<th>上传时间</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in releases" :key="r.filename" :class="{ cur: r.is_current }">
|
||
<td>
|
||
<b>v{{ r.version || '?' }}</b>
|
||
<span v-if="r.is_current" class="badge">当前</span>
|
||
</td>
|
||
<td class="fn">
|
||
<a :href="absURL(r.url)" target="_blank" rel="noopener">{{ r.filename }}</a>
|
||
</td>
|
||
<td>{{ fmtSize(r.size) }}</td>
|
||
<td>{{ fmtTime(r.mod_time) }}</td>
|
||
<td class="ops">
|
||
<button class="btn ghost danger" @click="remove(r)">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="text-dim empty">暂无安装包,请先上传。</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.intro { padding: 14px 16px; margin-bottom: 14px; line-height: 1.65; color: var(--text-dim); }
|
||
.intro code { font-size: 12px; padding: 1px 6px; border-radius: 4px; background: var(--bg-2, rgba(0,0,0,.06)); }
|
||
.panel { padding: 16px; margin-bottom: 14px; }
|
||
.sec-head { display: flex; align-items: center; gap: 6px; font-size: 14px; margin: 0 0 12px; }
|
||
.cur-row { display: flex; flex-wrap: wrap; gap: 16px 28px; margin-bottom: 12px; }
|
||
.kv { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
|
||
.kv.grow { flex: 1; min-width: 220px; }
|
||
.k { font-size: 12px; color: var(--text-dim); }
|
||
.v { font-size: 15px; word-break: break-all; }
|
||
.link { color: var(--primary-2); text-decoration: none; }
|
||
.link:hover { text-decoration: underline; }
|
||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; font-size: 13px; }
|
||
.field textarea {
|
||
resize: vertical; min-height: 56px; padding: 8px 10px; border-radius: 8px;
|
||
border: 1px solid var(--border); background: var(--bg, transparent); color: inherit; font: inherit;
|
||
}
|
||
.force { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 12px; cursor: pointer; }
|
||
.actions { display: flex; gap: 10px; }
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
border: 0; border-radius: 8px; padding: 8px 16px; font-size: 13px; cursor: pointer;
|
||
background: var(--bg-2, #2a2e52); color: inherit;
|
||
}
|
||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||
.btn.primary { background: linear-gradient(135deg, var(--primary), var(--primary-2, var(--primary))); color: #fff; }
|
||
.btn.ghost { background: transparent; border: 1px solid var(--border); }
|
||
.btn.danger { color: #ff7b8a; }
|
||
.pick { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||
.picked { font-size: 13px; }
|
||
.bar { height: 6px; border-radius: 99px; background: var(--border); overflow: hidden; margin-bottom: 12px; }
|
||
.bar i { display: block; height: 100%; background: var(--primary); transition: width .15s; }
|
||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.tbl th, .tbl td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); }
|
||
.tbl th { color: var(--text-dim); font-weight: 500; }
|
||
.tbl tr.cur td { background: color-mix(in srgb, var(--primary) 8%, transparent); }
|
||
.fn a { color: inherit; word-break: break-all; }
|
||
.badge {
|
||
display: inline-block; margin-left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 99px;
|
||
background: color-mix(in srgb, var(--primary) 22%, transparent); color: var(--primary-2, var(--primary));
|
||
}
|
||
.ops { white-space: nowrap; }
|
||
.empty { padding: 8px 0; }
|
||
</style>
|