优化客户端更新流程和包管理界面

This commit is contained in:
Scout开发者 2026-08-31 15:47:55 +08:00
parent 8ec5141cc0
commit f1546b9efc
5 changed files with 211 additions and 25 deletions

View File

@ -101,6 +101,20 @@ func uploadCommand(args []string) {
if !isScout && (*name == "" || *version == "" || *target.platform == "" || *target.architecture == "") {
fatal("\u666e\u901a\u5305\u5fc5\u987b\u6307\u5b9a --package\u3001--version\u3001--platform \u548c --architecture")
}
packageTypeValue := strings.TrimSpace(*packageType)
channelValue := strings.TrimSpace(*channel)
statusValue := strings.TrimSpace(*status)
if !isScout {
if packageTypeValue == "" {
packageTypeValue = "full"
}
if channelValue == "" {
channelValue = "stable"
}
if statusValue == "" {
statusValue = "published"
}
}
file, err := os.Open(*filePath)
if err != nil {
fatal("\u6253\u5f00\u5305\u6587\u4ef6\u5931\u8d25: %v", err)
@ -113,9 +127,9 @@ func uploadCommand(args []string) {
Description: *description,
Platform: *target.platform,
Architecture: *target.architecture,
PackageType: *packageType,
Channel: *channel,
Status: *status,
PackageType: packageTypeValue,
Channel: channelValue,
Status: statusValue,
UpgradeFrom: splitPackages(*upgradeFrom),
FileName: filepath.Base(*filePath),
File: file,

View File

@ -89,8 +89,9 @@ h3 {
}
.toolbar {
display: flex;
align-items: flex-end;
display: grid;
grid-template-columns: minmax(180px, 1.4fr) repeat(4, minmax(120px, 1fr)) auto;
align-items: end;
gap: 14px;
margin-bottom: 18px;
padding: 18px;
@ -125,6 +126,29 @@ input:focus {
box-shadow: 0 0 0 3px rgba(101, 230, 194, 0.12);
}
select {
width: 100%;
box-sizing: border-box;
border: 1px solid #2a3751;
border-radius: 10px;
background: #10192b;
color: #eaf0fa;
font: inherit;
padding: 12px 13px;
outline: none;
}
select:focus {
border-color: #65e6c2;
box-shadow: 0 0 0 3px rgba(101, 230, 194, 0.12);
}
input:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button,
a {
font: inherit;
@ -271,6 +295,12 @@ button:disabled {
font-weight: 800;
}
.target-count,
.target-summary {
color: #8c9ab4;
font-size: 11px;
}
.empty-state {
min-height: 160px;
display: grid;
@ -361,6 +391,10 @@ button:disabled {
flex-direction: column;
}
.toolbar {
display: flex;
}
.content-grid,
.upload-layout {
grid-template-columns: 1fr;

View File

@ -1,4 +1,4 @@
import { computed, ref } from 'vue'
import { computed, ref } from 'vue'
const defaultApi = import.meta.env.VITE_API_BASE || (['5173', '5174'].includes(window.location.port) ? 'http://127.0.0.1:8080' : window.location.origin)
const sessionKey = 'scout-session'
@ -13,6 +13,7 @@ export function useHome() {
const resetForm = ref({ email: '', token: '', password: '' })
const adminUsers = ref([])
const search = ref('')
const filters = ref({ platform: '', architecture: '', channel: '', currentVersion: '' })
const packages = ref([])
const selectedPackage = ref(null)
const versions = ref([])
@ -22,6 +23,10 @@ export function useHome() {
description: '',
platform: '',
architecture: '',
packageType: '',
channel: '',
status: '',
upgradeFrom: '',
file: null,
})
const loading = ref(false)
@ -34,6 +39,19 @@ export function useHome() {
const isLoggedIn = computed(() => Boolean(user.value && session.value))
const canUpload = computed(() => user.value?.role === 'developer' || user.value?.role === 'admin')
const isAdmin = computed(() => user.value?.role === 'admin')
const isScoutPackage = computed(() => uploadForm.value.file?.name.toLowerCase().endsWith('.scout'))
const platformOptions = ['windows', 'macos', 'linux', 'android', 'any']
const architectureOptions = computed(() => {
const platform = filters.value.platform || uploadForm.value.platform
const options = {
windows: ['amd64', 'arm64', '386'],
macos: ['amd64', 'arm64'],
linux: ['amd64', 'arm64', '386', 'armv7'],
android: ['arm64', 'armv7', '386'],
}
return options[platform] || ['amd64', 'arm64', '386', 'armv7', 'universal']
})
const channelOptions = ['stable', 'beta', 'dev']
function roleText(role) {
return { user: '普通用户', developer: '开发者', admin: '管理员' }[role] || role
@ -68,7 +86,13 @@ export function useHome() {
loading.value = true
error.value = ''
try {
const query = search.value.trim() ? `?query=${encodeURIComponent(search.value.trim())}` : ''
const params = new URLSearchParams()
if (search.value.trim()) params.set('query', search.value.trim())
if (filters.value.platform) params.set('platform', filters.value.platform)
if (filters.value.architecture) params.set('architecture', filters.value.architecture)
if (filters.value.channel) params.set('channel', filters.value.channel)
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
const query = params.toString() ? `?${params.toString()}` : ''
const payload = await request(`/api/v1/packages${query}`)
packages.value = payload.packages || []
if (selectedPackage.value) {
@ -90,7 +114,13 @@ export function useHome() {
selectedPackage.value = item
error.value = ''
try {
const payload = await request(`/api/v1/packages/${encodeURIComponent(item.name)}/versions`)
const params = new URLSearchParams()
if (filters.value.platform) params.set('platform', filters.value.platform)
if (filters.value.architecture) params.set('architecture', filters.value.architecture)
if (filters.value.channel) params.set('channel', filters.value.channel)
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
const query = params.toString() ? `?${params.toString()}` : ''
const payload = await request(`/api/v1/packages/${encodeURIComponent(item.name)}/versions${query}`)
versions.value = payload.versions || []
} catch (reason) {
error.value = reason.message
@ -99,6 +129,12 @@ export function useHome() {
function onFileChange(event) {
uploadForm.value.file = event.target.files?.[0] || null
if (isScoutPackage.value) {
uploadForm.value.name = ''
uploadForm.value.version = ''
uploadForm.value.platform = ''
uploadForm.value.architecture = ''
}
}
async function uploadPackage() {
@ -106,16 +142,17 @@ export function useHome() {
error.value = '请先登录开发者账户,或填写兼容开发者令牌'
return
}
if (!uploadForm.value.name || !uploadForm.value.version || !uploadForm.value.file) {
error.value = '请填写包名、版本并选择包文件'
if (!uploadForm.value.file || (!isScoutPackage.value && (!uploadForm.value.name || !uploadForm.value.version || !uploadForm.value.platform || !uploadForm.value.architecture))) {
error.value = isScoutPackage.value ? '请选择 Scout 包文件,包名、版本和目标将从清单读取' : '请填写包名、版本、平台、架构并选择包文件'
return
}
uploading.value = true
error.value = ''
message.value = ''
const formData = new FormData()
const fieldNames = { packageType: 'package_type', upgradeFrom: 'upgrade_from' }
Object.entries(uploadForm.value).forEach(([key, value]) => {
if (value) formData.append(key, value)
if (value) formData.append(fieldNames[key] || key, value)
})
try {
const headers = session.value
@ -251,12 +288,26 @@ export function useHome() {
}
function downloadUrl(item) {
return endpoint(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download`)
const params = new URLSearchParams()
if (filters.value.platform) params.set('platform', filters.value.platform)
if (filters.value.architecture) params.set('architecture', filters.value.architecture)
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
const query = params.toString() ? `?${params.toString()}` : ''
return endpoint(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download${query}`)
}
function startNotifications() {
stopNotifications()
eventSource = new EventSource(endpoint('/api/v1/updates/stream'))
const params = new URLSearchParams()
if (packages.value.length) params.set('packages', packages.value.map((item) => item.name).join(','))
if (filters.value.platform) params.set('platform', filters.value.platform)
if (filters.value.architecture) params.set('architecture', filters.value.architecture)
if (filters.value.channel) params.set('channel', filters.value.channel)
if (selectedPackage.value && filters.value.currentVersion) {
params.set('versions', `${selectedPackage.value.name}:${filters.value.currentVersion}`)
}
const query = params.toString() ? `?${params.toString()}` : ''
eventSource = new EventSource(endpoint(`/api/v1/updates/stream${query}`))
eventSource.addEventListener('package_updated', async (event) => {
const payload = JSON.parse(event.data)
message.value = `收到更新通知:${payload.package.name} ${payload.package.version}`
@ -282,6 +333,7 @@ export function useHome() {
resetForm,
adminUsers,
search,
filters,
packages,
selectedPackage,
versions,
@ -294,6 +346,10 @@ export function useHome() {
isLoggedIn,
canUpload,
isAdmin,
isScoutPackage,
platformOptions,
architectureOptions,
channelOptions,
roleText,
loadPackages,
selectPackage,

View File

@ -1,4 +1,4 @@
<script setup>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useHome } from './Home.js'
import './Home.css'
@ -12,6 +12,7 @@ const {
resetForm,
adminUsers,
search,
filters,
packages,
selectedPackage,
versions,
@ -24,6 +25,10 @@ const {
isLoggedIn,
canUpload,
isAdmin,
isScoutPackage,
platformOptions,
architectureOptions,
channelOptions,
roleText,
loadPackages,
selectPackage,
@ -73,6 +78,31 @@ onUnmounted(stopNotifications)
<span>搜索包</span>
<input v-model="search" type="search" placeholder="输入包名或描述" @keyup.enter="loadPackages" />
</label>
<label>
<span>平台筛选</span>
<select v-model="filters.platform">
<option value="">全部平台</option>
<option v-for="item in platformOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>架构筛选</span>
<select v-model="filters.architecture">
<option value="">全部架构</option>
<option v-for="item in architectureOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>发布通道</span>
<select v-model="filters.channel">
<option value="">全部通道</option>
<option v-for="item in channelOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>当前版本</span>
<input v-model="filters.currentVersion" type="text" placeholder="例如:1.0.0" @keyup.enter="loadPackages" />
</label>
<button class="secondary-button" type="button" @click="loadPackages">刷新包列表</button>
<div v-if="isLoggedIn" class="user-summary">
<span>{{ user.name }}({{ roleText(user.role) }})</span>
@ -160,6 +190,7 @@ onUnmounted(stopNotifications)
<small>{{ item.description || '暂无描述' }}</small>
</span>
<span class="version-tag">v{{ item.version }}</span>
<small class="target-count">{{ item.targets?.length || 0 }} 个目标</small>
</button>
</div>
</div>
@ -189,6 +220,7 @@ onUnmounted(stopNotifications)
<div v-for="item in versions" :key="item.version" class="version-row">
<span>v{{ item.version }}</span>
<small>{{ new Date(item.uploaded_at).toLocaleString('zh-CN') }}</small>
<span class="target-summary">{{ item.platform || '通用' }} / {{ item.architecture || '通用' }} · {{ item.package_type || 'full' }}</span>
<a :href="downloadUrl(item)">下载</a>
</div>
</div>
@ -212,19 +244,53 @@ onUnmounted(stopNotifications)
</label>
<label>
<span>包名</span>
<input v-model="uploadForm.name" type="text" maxlength="64" placeholder="例如:demo-core" />
<input v-model="uploadForm.name" :disabled="isScoutPackage" type="text" maxlength="64" placeholder="例如:demo-core" />
</label>
<label>
<span>版本</span>
<input v-model="uploadForm.version" type="text" maxlength="64" placeholder="例如:1.2.0" />
<input v-model="uploadForm.version" :disabled="isScoutPackage" type="text" maxlength="64" placeholder="例如:1.2.0" />
</label>
<label>
<span>平台</span>
<input v-model="uploadForm.platform" type="text" placeholder="例如:windows" />
<select v-model="uploadForm.platform" :disabled="isScoutPackage">
<option value="">请选择平台</option>
<option v-for="item in platformOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>架构</span>
<input v-model="uploadForm.architecture" type="text" placeholder="例如:amd64" />
<select v-model="uploadForm.architecture" :disabled="isScoutPackage">
<option value="">请选择架构</option>
<option v-for="item in architectureOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>包类型</span>
<select v-model="uploadForm.packageType">
<option value="">按 Scout 清单或服务端默认</option>
<option value="full">完整包</option>
<option value="delta">差分包</option>
</select>
</label>
<label>
<span>发布通道</span>
<select v-model="uploadForm.channel">
<option value="">按 Scout 清单或服务端默认</option>
<option v-for="item in channelOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>发布状态</span>
<select v-model="uploadForm.status">
<option value="">服务端默认发布</option>
<option value="published">立即发布</option>
<option value="draft">草稿</option>
<option value="deprecated">已弃用</option>
</select>
</label>
<label>
<span>差分来源版本</span>
<input v-model="uploadForm.upgradeFrom" type="text" placeholder="例如:1.0.0,多个版本用逗号分隔" />
</label>
<label class="wide-field">
<span>描述</span>
@ -235,6 +301,7 @@ onUnmounted(stopNotifications)
<input id="package-file" type="file" @change="onFileChange" />
</label>
</div>
<p v-if="isScoutPackage" class="helper-text">已选择 Scout 包:服务端会读取清单中的包名、版本、平台、架构和目标。</p>
<button class="primary-button upload-button" type="button" :disabled="uploading || (isLoggedIn && !canUpload)" @click="uploadPackage">
{{ uploading ? '正在上传……' : '上传并通知客户端' }}
</button>

View File

@ -198,9 +198,16 @@ func (manager *Manager) Prepare(ctx context.Context, version string) (ReleasePoi
} else if !os.IsNotExist(err) {
return ReleasePointer{}, fmt.Errorf("\u68c0\u67e5\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err)
}
if err := os.MkdirAll(releaseDir, 0o755); err != nil {
return ReleasePointer{}, fmt.Errorf("\u521b\u5efa\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err)
stagingDir, err := os.MkdirTemp(filepath.Dir(releaseDir), ".staging-"+item.Version+"-")
if err != nil {
return ReleasePointer{}, fmt.Errorf("\u521b\u5efa\u7248\u672c\u4e34\u65f6\u76ee\u5f55\u5931\u8d25: %w", err)
}
stagingReady := false
defer func() {
if !stagingReady {
_ = os.RemoveAll(stagingDir)
}
}()
fileName := item.FileName
if fileName == "" && len(item.Targets) > 0 {
@ -217,12 +224,12 @@ func (manager *Manager) Prepare(ctx context.Context, version string) (ReleasePoi
entrypoint := manager.config.Entrypoint
if strings.EqualFold(filepath.Ext(fileName), ".scout") {
entrypoint, err = manager.extractScout(downloadPath, releaseDir, entrypoint)
entrypoint, err = manager.extractScout(downloadPath, stagingDir, entrypoint)
if err != nil {
return ReleasePointer{}, err
}
} else {
destination := filepath.Join(releaseDir, filepath.Base(fileName))
destination := filepath.Join(stagingDir, filepath.Base(fileName))
if err := moveDownloadedFile(downloadPath, destination); err != nil {
return ReleasePointer{}, err
}
@ -233,7 +240,7 @@ func (manager *Manager) Prepare(ctx context.Context, version string) (ReleasePoi
if entrypoint == "" {
return ReleasePointer{}, errors.New("\u66f4\u65b0\u5305\u672a\u6307\u5b9a\u7a0b\u5e8f\u5165\u53e3")
}
executable, err := safeEntrypoint(releaseDir, entrypoint)
executable, err := safeEntrypoint(stagingDir, entrypoint)
if err != nil {
return ReleasePointer{}, err
}
@ -257,9 +264,13 @@ func (manager *Manager) Prepare(ctx context.Context, version string) (ReleasePoi
SHA256: downloaded.SHA256,
PreparedAt: now,
}
if err := manager.writeReleaseMetadata(pointer); err != nil {
if err := manager.writeReleaseMetadataAt(stagingDir, pointer); err != nil {
return ReleasePointer{}, err
}
if err := os.Rename(stagingDir, releaseDir); err != nil {
return ReleasePointer{}, fmt.Errorf("\u6fc0\u6d3b\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err)
}
stagingReady = true
if err := manager.writeJSON("pending.json", pointer); err != nil {
return ReleasePointer{}, err
}
@ -550,11 +561,15 @@ func (manager *Manager) currentVersion() string {
}
func (manager *Manager) writeReleaseMetadata(pointer ReleasePointer) error {
return manager.writeReleaseMetadataAt(pointer.Path, pointer)
}
func (manager *Manager) writeReleaseMetadataAt(directory string, pointer ReleasePointer) error {
data, err := json.MarshalIndent(pointer, "", " ")
if err != nil {
return fmt.Errorf("\u7f16\u7801\u7248\u672c\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
if err := os.WriteFile(filepath.Join(pointer.Path, "scout-release.json"), data, 0o644); err != nil {
if err := os.WriteFile(filepath.Join(directory, "scout-release.json"), data, 0o644); err != nil {
return fmt.Errorf("\u4fdd\u5b58\u7248\u672c\u5143\u6570\u636e\u5931\u8d25: %w", err)
}
return nil