优化客户端更新流程和包管理界面
This commit is contained in:
parent
8ec5141cc0
commit
f1546b9efc
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user