Update application UI and functionality
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user