// app.js - 普通脚本(非模块) const { createApp, ref, reactive, computed, onMounted, nextTick, watch } = Vue; // ============================================================ // 1. 基础 API // ============================================================ const API_BASE = "/api"; function getToken() { return localStorage.getItem("frps_token") || ""; } function getHeaders() { return { "Content-Type": "application/json", Authorization: "Bearer " + getToken(), }; } async function apiFetch(endpoint, options = {}) { const url = API_BASE + endpoint; const headers = options.body instanceof FormData ? { Authorization: "Bearer " + getToken() } : getHeaders(); const res = await fetch(url, { ...options, headers: { ...headers, ...(options.headers || {}) }, }); return res.json(); } // ============================================================ // 2. Auth // ============================================================ function parseJwtExpire(token) { try { const payload = JSON.parse(atob(token.split(".")[1])); return payload.exp * 1000; } catch { return Date.now() + 7 * 24 * 60 * 60 * 1000; } } function saveAuthState(token, username) { const expire = parseJwtExpire(token); localStorage.setItem("frps_token", token); localStorage.setItem("frps_token_expire", String(expire)); localStorage.setItem("frps_username", username); return expire; } function loadAuthState() { const token = localStorage.getItem("frps_token"); const expire = parseInt(localStorage.getItem("frps_token_expire") || "0", 10); const username = localStorage.getItem("frps_username") || ""; if (token && expire > Date.now()) { return { token, expire, username, valid: true }; } clearAuthState(); return { token: "", expire: 0, username: "", valid: false }; } function clearAuthState() { localStorage.removeItem("frps_token"); localStorage.removeItem("frps_token_expire"); localStorage.removeItem("frps_username"); } async function login(username, password) { const data = await apiFetch("/login", { method: "POST", body: JSON.stringify({ username, password }), }); if (data.code === 0) { const token = data.data.token; const expire = saveAuthState(token, username); return { success: true, token, expire }; } return { success: false, error: data.msg || "登录失败" }; } // ============================================================ // 3. Config // ============================================================ const defaultConfig = { bindPort: 9358, token: "CHANGE_ME", logLevel: "info", logMaxDays: 3, tcpMux: true, }; async function loadConfig() { try { const data = await apiFetch("/config"); if (data.code === 0) { return { ...defaultConfig, ...data.data }; } } catch (e) { console.warn("加载配置失败", e); } return { ...defaultConfig }; } async function saveConfigApi(config) { const data = await apiFetch("/config", { method: "PUT", body: JSON.stringify(config), }); return data; } // ============================================================ // 4. Clients // ============================================================ async function loadClients() { try { const data = await apiFetch("/clients"); if (data.code === 0) { return data.data || []; } } catch (e) { console.warn("加载客户端失败", e); } return []; } function filterClients(list, keyword) { if (!keyword) return list; const kw = keyword.toLowerCase(); return list.filter( (c) => (c.name || "").toLowerCase().includes(kw) || String(c.id).includes(kw) || (c.os || "").toLowerCase().includes(kw) ); } // ============================================================ // 5. Frps // ============================================================ async function getFrpsStatus() { try { const data = await apiFetch("/frps/status"); if (data.code === 0) { return data.data.running || false; } } catch (e) { console.warn("获取 frps 状态失败", e); } return false; } // ============================================================ // 6. Logs // ============================================================ async function fetchLogsApi() { try { const data = await apiFetch("/frps/log"); if (data.code === 0) { return { lines: data.data.lines || [], total: data.data.total || 0, error: data.data.error || "" }; } return { lines: [], total: 0, error: "加载失败" }; } catch (e) { return { lines: [], total: 0, error: "网络错误" }; } } // ============================================================ // 7. UI State // ============================================================ const activeTab = ref("clients"); const searchKeyword = ref(""); const showToken = ref(false); // ============================================================ // 8. Vue App // ============================================================ const app = createApp({ setup() { // ---- 登录态 ---- const loggedIn = ref(false); const loading = ref(false); const loginError = ref(""); const loginForm = reactive({ username: "", password: "" }); const token = ref(""); const tokenExpireTime = ref(0); const transitioning = ref(false); const contentVisible = ref(false); let expireTimer = null; // ---- 注册 ---- const isFirstRun = ref(false); const registerForm = reactive({ username: "", password: "", confirmPassword: "", }); const registerError = ref(""); const registering = ref(false); // ---- 数据 ---- const globalConfig = reactive({}); const clients = ref([]); const clientsLoaded = ref(false); const frpsRunning = ref(false); // ---- 修改密码 ---- const passwordChange = reactive({ oldPassword: "", newPassword: "", confirmPassword: "", }); const passwordChangeError = ref(""); const passwordChangeSuccess = ref(""); // ---- 日志 ---- const logLines = ref([]); const logTotal = ref(0); const logError = ref(""); const logLastUpdate = ref(""); const logAutoScroll = ref(true); let logTimer = null; let logFetching = false; // ---- 初始化 ---- onMounted(() => { const saved = loadAuthState(); if (saved.valid) { token.value = saved.token; tokenExpireTime.value = saved.expire; loginForm.username = saved.username; loggedIn.value = true; loadAllData().then(() => { contentVisible.value = true; startExpireTimer(); }); } else { checkUsers(); } }); // ---- Tab 切换监听日志轮询 ---- watch(activeTab, (newTab) => { if (newTab === "logs") { startLogPolling(); } else { stopLogPolling(); } if (newTab === "clients") { fetchClients(); } }); async function checkUsers() { try { const res = await fetch("/api/check/users"); const data = await res.json(); if (data.code === 0) { isFirstRun.value = !data.data.hasUsers; } } catch (e) { console.warn("检查用户状态失败", e); } } function startExpireTimer() { if (expireTimer) clearInterval(expireTimer); expireTimer = setInterval(() => { if (Date.now() >= tokenExpireTime.value) { doLogout(); alert("登录已超时,请重新登录"); } }, 5000); } async function loadAllData() { const [cfg, running] = await Promise.all([ loadConfig(), getFrpsStatus(), ]); Object.assign(globalConfig, cfg); frpsRunning.value = running; await fetchClients(); } async function fetchClients() { clientsLoaded.value = false; const list = await loadClients(); clients.value = list; clientsLoaded.value = true; } // ---- 日志轮询 ---- async function fetchLogs() { if (logFetching) return; logFetching = true; try { const result = await fetchLogsApi(); logLines.value = result.lines; logTotal.value = result.total; logError.value = result.error; logLastUpdate.value = new Date().toLocaleTimeString(); if (logAutoScroll.value) { await nextTick(); const container = document.getElementById("logContainer"); if (container) { container.scrollTop = container.scrollHeight; } } } catch (e) { logError.value = "网络错误"; } finally { logFetching = false; } } function startLogPolling() { if (logTimer) return; fetchLogs(); logTimer = setInterval(fetchLogs, 8000); } function stopLogPolling() { if (logTimer) { clearInterval(logTimer); logTimer = null; } } function refreshLogs() { fetchLogs(); } function scrollLogToBottom() { const container = document.getElementById("logContainer"); if (container) { container.scrollTop = container.scrollHeight; } } // ---- 登录 ---- const doLogin = async () => { if (!loginForm.username || !loginForm.password) { loginError.value = "请输入用户名和密码"; return; } loading.value = true; loginError.value = ""; const result = await login(loginForm.username, loginForm.password); if (result.success) { token.value = result.token; tokenExpireTime.value = result.expire; transitioning.value = true; loggedIn.value = true; setTimeout(async () => { transitioning.value = false; await loadAllData(); loading.value = false; setTimeout(() => { contentVisible.value = true; startExpireTimer(); }, 300); }, 500); } else { loginError.value = result.error; loading.value = false; } }; // ---- 注册 ---- const doRegister = async () => { if (registerForm.username.length < 5) { registerError.value = "用户名至少 5 位"; return; } if (registerForm.password !== registerForm.confirmPassword) { registerError.value = "两次输入的密码不一致"; return; } if (registerForm.password.length < 8) { registerError.value = "密码至少 8 位"; return; } registering.value = true; registerError.value = ""; try { const res = await fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: registerForm.username, password: registerForm.password, }), }); const data = await res.json(); if (data.code === 0) { isFirstRun.value = false; const token = data.data.token; const expire = saveAuthState(token, registerForm.username); token.value = token; tokenExpireTime.value = expire; loginForm.username = registerForm.username; loggedIn.value = true; transitioning.value = true; setTimeout(async () => { transitioning.value = false; await loadAllData(); setTimeout(() => { contentVisible.value = true; startExpireTimer(); }, 300); }, 500); } else { registerError.value = data.msg || "注册失败"; } } catch (e) { registerError.value = "网络错误,请重试"; } registering.value = false; }; // ---- 退出 ---- const doLogout = () => { if (expireTimer) clearInterval(expireTimer); stopLogPolling(); contentVisible.value = false; loggedIn.value = false; token.value = ""; tokenExpireTime.value = 0; loginForm.username = ""; loginForm.password = ""; loading.value = false; transitioning.value = false; clearAuthState(); isFirstRun.value = false; registerForm.username = ""; registerForm.password = ""; registerForm.confirmPassword = ""; registerError.value = ""; registering.value = false; }; // ---- 修改密码 ---- const changePassword = async () => { passwordChangeError.value = ""; passwordChangeSuccess.value = ""; if (passwordChange.newPassword !== passwordChange.confirmPassword) { passwordChangeError.value = "两次输入的密码不一致"; return; } if (passwordChange.newPassword.length < 8) { passwordChangeError.value = "密码至少 8 位"; return; } try { const res = await fetch("/api/user/password", { method: "PUT", headers: getHeaders(), body: JSON.stringify({ oldPassword: passwordChange.oldPassword, newPassword: passwordChange.newPassword, }), }); const data = await res.json(); if (data.code === 0) { passwordChangeSuccess.value = "密码修改成功!"; passwordChange.oldPassword = ""; passwordChange.newPassword = ""; passwordChange.confirmPassword = ""; } else { passwordChangeError.value = data.msg || "修改失败"; } } catch (e) { passwordChangeError.value = "网络错误,请重试"; } }; // ---- 保存配置 ---- const saveConfig = async () => { const result = await saveConfigApi(globalConfig); if (result.code === 0) { frpsRunning.value = await getFrpsStatus(); alert("配置已更新并热加载"); } else { alert("保存失败: " + result.msg); } }; // ---- 导入导出 ---- const triggerImport = () => { document.getElementById("tomlFileInput").click(); }; const handleImport = async (e) => { const file = e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); try { const res = await fetch("/api/import/toml", { method: "POST", headers: { Authorization: "Bearer " + getToken() }, body: formData, }); const data = await res.json(); if (data.code === 0) { alert(data.msg); await loadAllData(); } else { alert("导入失败: " + data.msg); } } catch (err) { alert("网络错误: " + err.message); } e.target.value = ""; }; const exportToml = async () => { try { const res = await fetch("/api/export/toml", { method: "GET", headers: { Authorization: "Bearer " + getToken() }, }); if (!res.ok) { const data = await res.json(); alert("导出失败: " + (data.msg || "未知错误")); return; } const blob = await res.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "frps.toml"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); } catch (e) { alert("导出失败: " + e.message); } }; // ---- 计算属性 ---- const filteredClients = computed(() => filterClients(clients.value, searchKeyword.value) ); const showDefaultTip = computed(() => { const token = globalConfig.token; return !token || token === "CHANGE_ME" || token.trim() === ""; }); // ---- 返回 ---- return { loggedIn, loading, loginError, loginForm, doLogin, doLogout, isFirstRun, registerForm, registerError, registering, doRegister, activeTab, globalConfig, saveConfig, clients, clientsLoaded, filteredClients, fetchClients, frpsRunning, searchKeyword, transitioning, contentVisible, triggerImport, handleImport, exportToml, passwordChange, passwordChangeError, passwordChangeSuccess, changePassword, logLines, logTotal, logError, logLastUpdate, logAutoScroll, refreshLogs, scrollLogToBottom, showToken, showDefaultTip, }; }, }); app.mount("#app");