fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500

This commit is contained in:
AskaEth committed 2026-09-09 19:57:23 +08:00
1 parent 72462792ec
commit 5ae55b8057
36 files changed
+2672 -108

No files matched your search

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../MxIcon.vue'
import MockBadge from './MockBadge.vue'
import { backupsApi, type BackupItem } from './systemApi'
import { backupsApi, describeError, type BackupItem } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -35,7 +35,7 @@ async function load(): Promise<void> {
rows.value = result.data
usingMock.value = result.mock
} catch (caught) {
loadError.value = caught instanceof Error ? caught.message : '加载备份列表失败'
loadError.value = describeError(caught)
} finally {
loading.value = false
}
@@ -49,12 +49,24 @@ async function createBackup(): Promise<void> {
toast.success('已生成备份', result.data.name)
await load()
} catch (caught) {
toast.error('备份失败', caught instanceof Error ? caught.message : null)
toast.error('备份失败', describeError(caught))
} finally {
backingUp.value = false
}
}
/** 服务端 reason 取值来自文件名前缀:event(赛事库)/ system(系统库),手动备份落在赛事库上。 */
const REASON_LABEL: Record<string, string> = {
event: '赛事库',
system: '系统库',
manual: '手动',
auto: '自动',
}
function reasonLabel(reason: string): string {
return REASON_LABEL[reason] ?? (reason === '' ? '—' : reason)
}
function formatSize(bytes: number): string {
if (bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
@@ -93,7 +105,7 @@ onMounted(() => {
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<p v-if="latest !== null" class="latest">
最近一次备份:{{ formatDateTime(latest.createdAt) }}
<span class="muted">({{ latest.reason === 'manual' ? '手动' : '自动' }})</span>
<span class="muted">({{ reasonLabel(latest.reason) }})</span>
</p>
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在读取备份目录…</div>
@@ -117,8 +129,8 @@ onMounted(() => {
<tr v-for="row in rows" :key="row.name">
<td class="mono name">{{ row.name }}</td>
<td>
<span class="mx-badge" :class="row.reason === 'manual' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ row.reason === 'manual' ? '手动' : '自动' }}
<span class="mx-badge" :class="row.reason === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ reasonLabel(row.reason) }}
</span>
</td>
<td class="right mono">{{ formatSize(row.size) }}</td>
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import type { EventSettings } from '@/shared'
import MxIcon from '../MxIcon.vue'
import MockBadge from './MockBadge.vue'
import { settingsApi, type EventSettingsPatch } from './systemApi'
import { describeError, settingsApi, type EventSettingsPatch } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useEventStore } from '../../stores/event'
import { useToastStore } from '../../stores/toast'
@@ -84,7 +84,7 @@ async function save(): Promise<void> {
form.value = clone(result.data)
toast.success('赛事信息已保存', result.mock ? '示例数据:改动仅保留在本地' : null)
} catch (caught) {
saveError.value = caught instanceof Error ? caught.message : '保存失败'
saveError.value = describeError(caught)
} finally {
saving.value = false
}
@@ -4,7 +4,7 @@ import { formatDateTime } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import MockBadge from './MockBadge.vue'
import { USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
import { describeError, USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -46,7 +46,7 @@ async function load(): Promise<void> {
rows.value = result.data
usingMock.value = result.mock
} catch (caught) {
loadError.value = caught instanceof Error ? caught.message : '加载用户列表失败'
loadError.value = describeError(caught)
} finally {
loading.value = false
}
@@ -115,7 +115,7 @@ async function commitSave(): Promise<void> {
editorOpen.value = false
await load()
} catch (caught) {
formError.value = caught instanceof Error ? caught.message : '保存失败'
formError.value = describeError(caught)
} finally {
saving.value = false
}
@@ -133,7 +133,7 @@ async function resetPassword(): Promise<void> {
revealed.value = { username: target.username, password: result.data.password }
await load()
} catch (caught) {
toast.error('重置密码失败', caught instanceof Error ? caught.message : null)
toast.error('重置密码失败', describeError(caught))
} finally {
resetting.value = false
}
@@ -269,7 +269,7 @@ onMounted(() => {
placeholder="至少 8 位,首次登录强制修改"
/>
</div>
<div class="field switch-field">
<div v-if="editingId !== null" class="field switch-field">
<span class="mx-label">启用</span>
<span class="switch-row">
<label class="mx-switch">
@@ -281,6 +281,9 @@ onMounted(() => {
</div>
</div>
<p class="notice">
角色只有「管理员 / 录入员」两种;未登录访客的只读身份由系统默认提供,不需要建账号。
</p>
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
<div class="editor-foot">
+42 -16
View File
@@ -74,6 +74,19 @@ export async function withMock<T>(real: () => Promise<T>, mock: () => T | Promis
return (await withMockInfo(real, mock)).data
}
/**
* 服务端只回一句「服务端异常」,真正的线索在 error.detail 里(例如「赛事项目名非法: 名称为空」)。
* 展示时把 detail 一并带上,现场才不用去翻服务端日志。
*/
export function describeError(error: unknown): string {
if (error instanceof ApiError) {
const detail =
typeof error.detail === 'string' && error.detail.trim() !== '' ? `:${error.detail}` : ''
return `${error.message}${detail}`
}
return error instanceof Error ? error.message : '未知错误'
}
// ---------------------------------------------------------------------------
// 底层 HTTP(补齐 ApiClient 未覆盖的路径)
// ---------------------------------------------------------------------------
@@ -206,14 +219,6 @@ async function requestJson<T>(path: string, options: RawOptions = {}): Promise<T
return unwrap<T>(body, response.status)
}
async function requestVoid(path: string, options: RawOptions = {}): Promise<void> {
const response = await send(path, options)
const body = await readBody(response)
if (!response.ok) throw toApiError(body, response.status)
if (body === null || body === undefined) return
unwrap<unknown>(body, response.status)
}
async function requestBlob(path: string, options: RawOptions = {}): Promise<Blob> {
const response = await send(path, options)
if (!response.ok) throw toApiError(await readBody(response), response.status)
@@ -257,10 +262,13 @@ export interface AdminUserInput {
password: string
}
/**
* 可落库的角色只有 recorder / admin:guest 是「未登录」的默认身份,不是一条用户记录
* (服务端 userRole() 会直接拒绝 guest,这里不给出这个选项)。
*/
export const USER_ROLES: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'admin', label: '管理员' },
{ value: 'recorder', label: '录入员' },
{ value: 'guest', label: '只读访客' },
]
function toAdminUser(value: unknown): AdminUser | null {
@@ -311,9 +319,9 @@ const mockUserRows: AdminUser[] = [
},
{
id: 3,
username: 'lookup',
displayName: '只读账号',
role: 'guest',
username: 'recorder02',
displayName: '录入员二(已停用)',
role: 'recorder',
active: false,
mustChangePassword: true,
lastLoginAt: null,
@@ -329,7 +337,8 @@ function mockUsers() {
username: input.username,
displayName: input.displayName === '' ? input.username : input.displayName,
role: input.role,
active: input.active,
// 与真实服务端一致:新建出来的账号一定是启用状态。
active: true,
mustChangePassword: input.password !== '',
lastLoginAt: null,
}
@@ -379,7 +388,16 @@ export const usersApi = {
async create(input: AdminUserInput): Promise<MockAware<AdminUser>> {
return withMockInfo(
async () => {
const body = await requestJson<unknown>('/users', { method: 'POST', json: input })
const body = await requestJson<unknown>('/users', {
method: 'POST',
// 服务端新建固定启用,不接受 active;多传字段虽被忽略,但少传更不容易误解契约。
json: {
username: input.username,
displayName: input.displayName,
role: input.role,
password: input.password,
},
})
const created = toAdminUser(body)
if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body)
return created
@@ -425,14 +443,20 @@ export interface BackupItem {
createdAt: string
}
/**
* 两种来源要兼容:
* - GET /api/export/backups 返回 export.BackupInfo(name / size / reason 齐全);
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补。
*/
function toBackupItem(value: unknown): BackupItem | null {
if (!isRecord(value)) return null
const name = readString(value, 'name')
const path = readString(value, 'path')
const createdAt = readString(value, 'createdAt')
const name = readString(value, 'name') ?? (path === null ? null : (path.split(/[\\/]/).pop() ?? null))
if (name === null || createdAt === null) return null
return {
name,
path: readString(value, 'path') ?? name,
path: path ?? name,
size: readNumber(value, 'size') ?? 0,
reason: readString(value, 'reason') ?? '',
createdAt,
@@ -455,6 +479,8 @@ export const backupsApi = {
return withMockInfo(
async () => {
const body = await requestJson<unknown>('/export/backups')
// 备份目录为空时服务端把 nil 切片序列化成 null,这不是错误,是「还没有备份」。
if (body === null) return []
if (!Array.isArray(body)) throw new ApiError('PARSE_FAILED', '备份列表响应格式不符', 0, body)
return body.map(toBackupItem).filter((row): row is BackupItem => row !== null)
},