Files
frpc-console/static/app.js
T

703 lines
22 KiB
JavaScript

// 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 || {}) },
});
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,
};
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;
// ---- 初始化 ----
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();
}
});
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();
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)
);
// ---- 返回 ----
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,
// 日志相关
logLines,
logTotal,
logError,
logLastUpdate,
logAutoScroll,
refreshLogs,
scrollLogToBottom,
// Token 显示切换
showToken,
};
},
});
app.mount("#app");