Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
+37 -10
View File
@@ -66,6 +66,7 @@ const route = useRoute();
const router = useRouter();
const currentPath = computed(() => normalizeAdminPath(route.path));
const loading = ref(false);
const loginPending = ref(false);
const toast = ref<ToastState | null>(null);
const autoRefreshPaused = ref(false);
const databaseFormEditing = ref(false);
@@ -490,7 +491,7 @@ function isAuthError(raw: string, message: string) {
}
async function loadCaptcha() {
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
captcha.value = await adminFetch<Captcha>("/api/admin/auth/captcha", {}, { timeoutMs: 5000 });
}
async function loadAuthBootstrap() {
@@ -498,17 +499,35 @@ async function loadAuthBootstrap() {
}
async function login() {
await guarded(async () => {
const data = await api<{ csrfToken: string }>("/api/admin/auth/login", {
if (loginPending.value) return;
if (!loginForm.password || !loginForm.captcha || !captcha.value?.captchaId) {
setToast("请填写密码和验证码", "warn");
return;
}
loginPending.value = true;
loading.value = true;
try {
const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", {
method: "POST",
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
});
}, { timeoutMs: 8000 });
csrf.value = data.csrfToken;
sessionStorage.setItem("ymhut.csrf", csrf.value);
localStorage.removeItem("ymhut.csrf");
connectAdminEvents();
navigate("/admin/dashboard");
});
} catch (error) {
const message = toChineseError(error instanceof Error ? error.message : String(error));
setToast(message, "error");
loginForm.captcha = "";
void loadCaptcha().catch(() => {
captcha.value = null;
});
} finally {
loading.value = false;
loginPending.value = false;
}
}
async function logout() {
@@ -747,6 +766,7 @@ async function uploadPackage() {
setToast("请选择要上传的发布包", "warn");
return;
}
let completed = false;
await guarded(async () => {
const form = new FormData();
form.append("file", uploadDraft.file as File);
@@ -771,6 +791,7 @@ async function uploadPackage() {
uploadDraft.status = "上传完成";
uploadDraft.file = null;
uploadDraft.notes = "";
completed = true;
setToast("发布包已上传并放入下载目录");
await loadReleases();
window.setTimeout(() => {
@@ -783,6 +804,12 @@ async function uploadPackage() {
}, 1200);
}).finally(() => {
uploadDraft.uploading = false;
if (!completed) {
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
uploadDraft.status = "上传失败,可直接重试";
}
});
}
@@ -1709,19 +1736,19 @@ function connectAdminEvents() {
当前使用默认账号{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
</p>
<form class="form-stack" @submit.prevent="login">
<label>账号<input v-model="loginForm.username" autocomplete="username" /></label>
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" /></label>
<label>账号<input v-model="loginForm.username" autocomplete="username" :disabled="loginPending" /></label>
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" :disabled="loginPending" /></label>
<label>
验证码
<div class="captcha-row">
<input v-model="loginForm.captcha" />
<button class="captcha-button" type="button" title="刷新验证码" @click="loadCaptcha">
<input v-model="loginForm.captcha" :disabled="loginPending" autocomplete="off" />
<button class="captcha-button" type="button" title="刷新验证码" :disabled="loginPending" @click="loadCaptcha">
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</label>
<Button class="full" type="submit" label="登录" />
<Button class="full" type="submit" :label="loginPending ? '正在登录…' : '登录'" :loading="loginPending" :disabled="loginPending" />
</form>
</section>
</main>
@@ -5,6 +5,7 @@ export type UploadProgress = {
export type AdminApiOptions = {
csrf?: string;
timeoutMs?: number;
};
const exactMessages: Record<string, string> = {
@@ -31,6 +32,7 @@ const exactMessages: Record<string, string> = {
const codeMessages: Record<string, string> = {
UNAUTHORIZED: "需要登录后继续操作",
LOGIN_FAILED: "登录失败,请检查密码和验证码",
LOGIN_TIMEOUT: "登录校验超时,请稍后重试",
PASSWORD_CHANGE_FAILED: "密码修改失败",
INVALID_PAYLOAD: "提交内容格式不正确",
DATABASE_TEST_FAILED: "数据库连接测试失败",
@@ -42,6 +44,11 @@ const codeMessages: Record<string, string> = {
NOTICE_SAVE_FAILED: "版本日志保存失败",
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
FILE_REQUIRED: "请选择要上传的发布包",
PACKAGE_EMPTY: "发布包不能为空",
PACKAGE_TOO_LARGE: "发布包超过服务端上传上限",
UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件",
MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败",
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
SOURCE_SAVE_FAILED: "接口源保存失败",
CHECK_FAILED: "接口健康检测失败",
@@ -56,12 +63,27 @@ export async function adminFetch<T>(target: string, init: RequestInit = {}, opti
headers.set("Content-Type", "application/json");
}
if (options.csrf) headers.set("X-CSRF-Token", options.csrf);
const res = await fetch(target, { ...init, headers, credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
const controller = new AbortController();
const timeoutMs = Math.max(1000, options.timeoutMs ?? 20000);
const timeout = window.setTimeout(() => controller.abort("timeout"), timeoutMs);
const forwardAbort = () => controller.abort(init.signal?.reason);
init.signal?.addEventListener("abort", forwardAbort, { once: true });
try {
const res = await fetch(target, { ...init, headers, credentials: "include", signal: controller.signal });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(adminErrorMessage(data, res.status));
}
return data as T;
} catch (error) {
if (controller.signal.aborted && !init.signal?.aborted) {
throw new Error("请求超时,服务端未及时响应");
}
throw error;
} finally {
window.clearTimeout(timeout);
init.signal?.removeEventListener("abort", forwardAbort);
}
return data as T;
}
export function uploadAdminFile<T>(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise<T> {
@@ -76,7 +98,7 @@ export function uploadAdminFile<T>(target: string, form: FormData, options: Admi
xhr.onload = () => {
const data = parseJSONSafe(xhr.responseText, {});
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
reject(new Error(adminErrorMessage(data, xhr.status)));
return;
}
resolve(data as T);
@@ -87,6 +109,15 @@ export function uploadAdminFile<T>(target: string, form: FormData, options: Admi
});
}
function adminErrorMessage(data: any, status: number) {
const code = String(data?.error || "").trim();
const detail = String(data?.message || "").trim();
if (code && codeMessages[code]) {
return codeMessages[code];
}
return toChineseError(detail || code || `HTTP ${status}`);
}
export function toChineseError(value: string) {
const raw = String(value || "").trim();
const lower = raw.toLowerCase();
@@ -8,6 +8,64 @@ import "primeicons/primeicons.css";
import App from "./App.vue";
import "./styles.css";
const resourceReloadKey = "ymhut.admin.resource-reload";
function isAdminResourceFailure(value: unknown) {
const message = value instanceof Error ? value.message : String(value || "");
return /failed to fetch dynamically imported module|loading chunk|module script|importing a module/i.test(message);
}
function showResourceFailure() {
if (document.getElementById("admin-resource-failure")) return;
const notice = document.createElement("div");
notice.id = "admin-resource-failure";
notice.setAttribute("role", "alert");
notice.textContent = "后台资源加载失败,请刷新页面后重试。";
Object.assign(notice.style, {
position: "fixed",
inset: "16px 16px auto 16px",
zIndex: "2147483647",
padding: "12px 16px",
border: "1px solid #dc2626",
borderRadius: "6px",
color: "#7f1d1d",
background: "#fef2f2",
fontFamily: "Segoe UI, sans-serif",
fontSize: "14px",
});
document.body.appendChild(notice);
}
function recoverAdminResources() {
const canonical = new URL(location.href);
canonical.searchParams.delete("_admin_reload");
const marker = `${canonical.pathname}${canonical.search}`;
if (sessionStorage.getItem(resourceReloadKey) === marker) {
showResourceFailure();
return;
}
sessionStorage.setItem(resourceReloadKey, marker);
const next = new URL(location.href);
next.searchParams.set("_admin_reload", Date.now().toString());
location.replace(next);
}
window.addEventListener("error", (event) => {
const target = event.target as HTMLScriptElement | HTMLLinkElement | null;
const resource = target instanceof HTMLScriptElement
? target.src
: target instanceof HTMLLinkElement
? target.href
: "";
if (resource.includes("/admin/assets/") || isAdminResourceFailure(event.error || event.message)) {
recoverAdminResources();
}
}, true);
window.addEventListener("unhandledrejection", (event) => {
if (isAdminResourceFailure(event.reason)) recoverAdminResources();
});
const RoutePlaceholder = { template: "<span />" };
const routes = [
@@ -57,3 +115,5 @@ createApp(App)
.use(ToastService)
.use(ConfirmationService)
.mount("#app");
window.setTimeout(() => sessionStorage.removeItem(resourceReloadKey), 30000);