Files
nl-game/src/views/admin/AdminVersion.vue

230 lines
8.9 KiB
Vue
Raw Normal View History

<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>解析版本号
2026-08-18 12:29:50 +08:00
写入公开接口 <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>