// app.js - 普通脚本(非模块) const { createApp, ref, reactive, computed, onMounted, nextTick, watch } = Vue; // ============================================================ // 1. 基础 API // ============================================================ const API_BASE = "/api"; function getToken() { return localStorage.getItem("frpc_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 || {}) }, }); // ---- 统一处理 401 认证失效 ---- if (res.status === 401) { clearAuthState(); window.dispatchEvent(new CustomEvent("auth:expired")); return { code: 401, msg: "认证已过期,请重新登录" }; } 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("frpc_token", token); localStorage.setItem("frpc_token_expire", String(expire)); localStorage.setItem("frpc_username", username); return expire; } function loadAuthState() { const token = localStorage.getItem("frpc_token"); const expire = parseInt(localStorage.getItem("frpc_token_expire") || "0", 10); const username = localStorage.getItem("frpc_username") || ""; if (token && expire > Date.now()) { return { token, expire, username, valid: true }; } clearAuthState(); return { token: "", expire: 0, username: "", valid: false }; } function clearAuthState() { localStorage.removeItem("frpc_token"); localStorage.removeItem("frpc_token_expire"); localStorage.removeItem("frpc_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 = { serverAddr: "frp.example.com", serverPort: 7000, token: "CHANGE_ME", logLevel: "info", logMaxDays: 3, tcpMux: true, tcpMuxKeepalive: 30, heartbeatInterval: 15, heartbeatTimeout: 70, poolCount: 8, wireProtocolV2: false, }; 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. Proxies // ============================================================ async function loadProxies() { try { const data = await apiFetch("/proxies"); if (data.code === 0) { return data.data || []; } } catch (e) { console.warn("加载隧道失败", e); } return []; } async function createProxy(proxy) { const data = await apiFetch("/proxy", { method: "POST", body: JSON.stringify(proxy), }); return data; } async function updateProxy(proxy) { const data = await apiFetch("/proxy/" + proxy.id, { method: "PUT", body: JSON.stringify(proxy), }); return data; } async function deleteProxy(id) { const data = await apiFetch("/proxy/" + id, { method: "DELETE", }); return data; } function filterProxies(list, keyword) { if (!keyword) return list; const kw = keyword.toLowerCase(); return list.filter( (p) => p.name.toLowerCase().includes(kw) || p.localIP.includes(kw) || String(p.remotePort).includes(kw) ); } // ============================================================ // 5. Frpc // ============================================================ async function getFrpcStatus() { try { const data = await apiFetch("/frpc/status"); if (data.code === 0) { return data.data.running || false; } } catch (e) { console.warn("获取 frpc 状态失败", e); } return false; } // ============================================================ // 6. Logs // ============================================================ async function fetchLogsApi() { try { const data = await apiFetch("/frpc/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("proxies"); const dialogVisible = ref(false); const dialogMode = ref("add"); const dialogForm = ref({ id: null, name: "", type: "tcp", localIP: "", localPort: 0, remotePort: 0, }); const searchKeyword = ref(""); const showToken = ref(false); function openAddDialog() { dialogMode.value = "add"; dialogForm.value = { id: null, name: "", type: "tcp", localIP: "", localPort: 0, remotePort: 0, }; dialogVisible.value = true; } function openEditDialog(proxy) { dialogMode.value = "edit"; dialogForm.value = { ...proxy }; dialogVisible.value = true; } function closeDialog() { dialogVisible.value = 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 proxies = ref([]); const frpcRunning = 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; // ---- 认证过期处理 ---- const handleAuthExpired = () => { doLogout(); }; // ---- 初始化 ---- onMounted(() => { // 监听认证过期事件 window.addEventListener("auth:expired", handleAuthExpired); 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(); } }); 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, list, running] = await Promise.all([ loadConfig(), loadProxies(), getFrpcStatus(), ]); Object.assign(globalConfig, cfg); proxies.value = list; frpcRunning.value = running; } // ---- 日志轮询 ---- async function fetchLogs() { if (logFetching) return; logFetching = true; try { const result = await fetchLogsApi(); // ---- 如果 API 返回 401,停止轮询(事件已触发登出) ---- if (result.code === 401) { logFetching = false; return; } 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) { frpcRunning.value = await getFrpcStatus(); alert("配置已更新并热加载"); } else { alert("保存失败: " + result.msg); } }; // ---- 隧道操作 ---- const toggleProxy = async (p) => { const result = await updateProxy(p); if (result.code !== 0) { alert("更新失败: " + result.msg); proxies.value = await loadProxies(); } else { frpcRunning.value = await getFrpcStatus(); } }; const removeProxy = async (id) => { if (!confirm("确定要删除这条隧道吗?")) return; const result = await deleteProxy(id); if (result.code === 0) { proxies.value = await loadProxies(); frpcRunning.value = await getFrpcStatus(); } else { alert("删除失败: " + result.msg); } }; const confirmDialog = async () => { const form = dialogForm.value; if (!form.name || !form.localIP || !form.localPort || !form.remotePort) { alert("请填写完整信息"); return; } const result = dialogMode.value === "add" ? await createProxy(form) : await updateProxy(form); if (result.code === 0) { closeDialog(); proxies.value = await loadProxies(); frpcRunning.value = await getFrpcStatus(); } 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 = "frpc.toml"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); } catch (e) { alert("导出失败: " + e.message); } }; // ---- 计算属性 ---- const filteredProxies = computed(() => filterProxies(proxies.value, searchKeyword.value) ); const showDefaultTip = computed(() => { const addr = globalConfig.serverAddr; return !addr || addr === "frp.example.com" || addr.trim() === ""; }); // ---- 返回 ---- return { loggedIn, loading, loginError, loginForm, doLogin, doLogout, isFirstRun, registerForm, registerError, registering, doRegister, activeTab, globalConfig, saveConfig, proxies, filteredProxies, toggleProxy, removeProxy, frpcRunning, dialogVisible, dialogMode, dialogForm, openAddDialog, openEditDialog, closeDialog, confirmDialog, searchKeyword, transitioning, contentVisible, triggerImport, handleImport, exportToml, passwordChange, passwordChangeError, passwordChangeSuccess, changePassword, showDefaultTip, logLines, logTotal, logError, logLastUpdate, logAutoScroll, refreshLogs, scrollLogToBottom, showToken, }; }, }); app.mount("#app");