fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500
This commit is contained in:
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">
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user