Files
nl-game/src/views/admin/AdminVersion.vue
2026-08-18 12:29:50 +08:00

230 lines
8.9 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.
<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>