优化frpc.toml导出逻辑,从前端依赖库改为自建设计
This commit is contained in:
+112
-116
@@ -7,21 +7,22 @@ const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||
// ============================================================
|
||||
|
||||
// ---------- api ----------
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = "/api";
|
||||
function getToken() {
|
||||
return localStorage.getItem('frpc_token') || '';
|
||||
return localStorage.getItem("frpc_token") || "";
|
||||
}
|
||||
function getHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + getToken(),
|
||||
"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 headers =
|
||||
options.body instanceof FormData
|
||||
? { Authorization: "Bearer " + getToken() }
|
||||
: getHeaders();
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...headers, ...(options.headers || {}) },
|
||||
@@ -32,7 +33,7 @@ async function apiFetch(endpoint, options = {}) {
|
||||
// ---------- auth ----------
|
||||
function parseJwtExpire(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000;
|
||||
} catch {
|
||||
return Date.now() + 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -40,29 +41,29 @@ function parseJwtExpire(token) {
|
||||
}
|
||||
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);
|
||||
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') || '';
|
||||
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 };
|
||||
return { token: "", expire: 0, username: "", valid: false };
|
||||
}
|
||||
function clearAuthState() {
|
||||
localStorage.removeItem('frpc_token');
|
||||
localStorage.removeItem('frpc_token_expire');
|
||||
localStorage.removeItem('frpc_username');
|
||||
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',
|
||||
const data = await apiFetch("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (data.code === 0) {
|
||||
@@ -70,15 +71,15 @@ async function login(username, password) {
|
||||
const expire = saveAuthState(token, username);
|
||||
return { success: true, token, expire };
|
||||
}
|
||||
return { success: false, error: data.msg || '登录失败' };
|
||||
return { success: false, error: data.msg || "登录失败" };
|
||||
}
|
||||
|
||||
// ---------- config ----------
|
||||
const defaultConfig = {
|
||||
serverAddr: 'frp.whitetop.xyz',
|
||||
serverAddr: "frp.whitetop.xyz",
|
||||
serverPort: 9358,
|
||||
token: '',
|
||||
logLevel: 'info',
|
||||
token: "",
|
||||
logLevel: "info",
|
||||
logMaxDays: 3,
|
||||
tcpMux: true,
|
||||
tcpMuxKeepalive: 30,
|
||||
@@ -88,18 +89,18 @@ const defaultConfig = {
|
||||
};
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const data = await apiFetch('/config');
|
||||
const data = await apiFetch("/config");
|
||||
if (data.code === 0) {
|
||||
return { ...defaultConfig, ...data.data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载配置失败', e);
|
||||
console.warn("加载配置失败", e);
|
||||
}
|
||||
return { ...defaultConfig };
|
||||
}
|
||||
async function saveConfigApi(config) {
|
||||
const data = await apiFetch('/config', {
|
||||
method: 'PUT',
|
||||
const data = await apiFetch("/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return data;
|
||||
@@ -108,96 +109,104 @@ async function saveConfigApi(config) {
|
||||
// ---------- proxies ----------
|
||||
async function loadProxies() {
|
||||
try {
|
||||
const data = await apiFetch('/proxies');
|
||||
const data = await apiFetch("/proxies");
|
||||
if (data.code === 0) {
|
||||
return data.data || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载隧道失败', e);
|
||||
console.warn("加载隧道失败", e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
async function createProxy(proxy) {
|
||||
const data = await apiFetch('/proxy', {
|
||||
method: 'POST',
|
||||
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',
|
||||
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',
|
||||
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)
|
||||
return list.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(kw) ||
|
||||
p.localIP.includes(kw) ||
|
||||
String(p.remotePort).includes(kw),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- frpc ----------
|
||||
async function getFrpcStatus() {
|
||||
try {
|
||||
const data = await apiFetch('/frpc/status');
|
||||
const data = await apiFetch("/frpc/status");
|
||||
if (data.code === 0) {
|
||||
return data.data.running || false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取 frpc 状态失败', e);
|
||||
console.warn("获取 frpc 状态失败", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function reloadFrpc() {
|
||||
const data = await apiFetch('/frpc/reload', { method: 'POST' });
|
||||
const data = await apiFetch("/frpc/reload", { method: "POST" });
|
||||
return data;
|
||||
}
|
||||
async function startFrpc() {
|
||||
const data = await apiFetch('/frpc/start', { method: 'POST' });
|
||||
const data = await apiFetch("/frpc/start", { method: "POST" });
|
||||
return data;
|
||||
}
|
||||
async function stopFrpc() {
|
||||
const data = await apiFetch('/frpc/stop', { method: 'POST' });
|
||||
const data = await apiFetch("/frpc/stop", { method: "POST" });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- ui ----------
|
||||
// Tab 切换
|
||||
const activeTab = ref('proxies');
|
||||
const activeTab = ref("proxies");
|
||||
function switchTab(tab) {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
// 弹窗控制
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref('add');
|
||||
const dialogMode = ref("add");
|
||||
const dialogForm = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'tcp',
|
||||
localIP: '',
|
||||
name: "",
|
||||
type: "tcp",
|
||||
localIP: "",
|
||||
localPort: 0,
|
||||
remotePort: 0,
|
||||
});
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'add';
|
||||
dialogForm.value = { id: null, name: '', type: 'tcp', localIP: '', localPort: 0, remotePort: 0 };
|
||||
dialogMode.value = "add";
|
||||
dialogForm.value = {
|
||||
id: null,
|
||||
name: "",
|
||||
type: "tcp",
|
||||
localIP: "",
|
||||
localPort: 0,
|
||||
remotePort: 0,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function openEditDialog(proxy) {
|
||||
dialogMode.value = 'edit';
|
||||
dialogMode.value = "edit";
|
||||
dialogForm.value = { ...proxy };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
@@ -205,21 +214,21 @@ function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const searchKeyword = ref("");
|
||||
|
||||
// ---------- import/export 功能 ----------
|
||||
async function triggerImport() {
|
||||
document.getElementById('tomlFileInput').click();
|
||||
document.getElementById("tomlFileInput").click();
|
||||
}
|
||||
async function handleImport(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const res = await fetch('/api/import/toml', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
const res = await fetch("/api/import/toml", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer " + getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -227,55 +236,39 @@ async function handleImport(e) {
|
||||
alert(data.msg);
|
||||
await loadAllData();
|
||||
} else {
|
||||
alert('导入失败: ' + data.msg);
|
||||
alert("导入失败: " + data.msg);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误: ' + err.message);
|
||||
alert("网络错误: " + err.message);
|
||||
}
|
||||
e.target.value = '';
|
||||
e.target.value = "";
|
||||
}
|
||||
async function exportToml() {
|
||||
console.log('🔍 exportToml 被调用了');
|
||||
try {
|
||||
const configData = await apiFetch('/config');
|
||||
if (configData.code !== 0) {
|
||||
alert('获取配置失败');
|
||||
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 proxyData = await apiFetch('/proxies');
|
||||
if (proxyData.code !== 0) {
|
||||
alert('获取隧道列表失败');
|
||||
return;
|
||||
}
|
||||
const cfg = configData.data;
|
||||
const proxyList = proxyData.data || [];
|
||||
const tomlObj = {
|
||||
serverAddr: cfg.serverAddr,
|
||||
serverPort: cfg.serverPort,
|
||||
auth: { token: cfg.token },
|
||||
log: { to: './frpc.log', level: cfg.logLevel, maxDays: cfg.logMaxDays },
|
||||
transport: {
|
||||
tcpMux: cfg.tcpMux,
|
||||
tcpMuxKeepaliveInterval: cfg.tcpMuxKeepalive,
|
||||
heartbeatInterval: cfg.heartbeatInterval,
|
||||
heartbeatTimeout: cfg.heartbeatTimeout,
|
||||
poolCount: cfg.poolCount,
|
||||
},
|
||||
proxies: proxyList.map(p => ({
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
localIP: p.localIP,
|
||||
localPort: p.localPort,
|
||||
remotePort: p.remotePort,
|
||||
})),
|
||||
};
|
||||
const tomlStr = TOML.stringify(tomlObj);
|
||||
const blob = new Blob([tomlStr], { type: 'text/plain' });
|
||||
|
||||
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);
|
||||
|
||||
console.log('✅ 导出成功');
|
||||
} catch (e) {
|
||||
console.error('导出失败:', e);
|
||||
alert('导出失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
@@ -289,9 +282,9 @@ const app = createApp({
|
||||
// ===== 登录态 =====
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const loginError = ref('');
|
||||
const loginForm = reactive({ username: '', password: '' });
|
||||
const token = ref('');
|
||||
const loginError = ref("");
|
||||
const loginForm = reactive({ username: "", password: "" });
|
||||
const token = ref("");
|
||||
const tokenExpireTime = ref(0);
|
||||
const transitioning = ref(false);
|
||||
const contentVisible = ref(false);
|
||||
@@ -323,7 +316,7 @@ const app = createApp({
|
||||
expireTimer = setInterval(() => {
|
||||
if (Date.now() >= tokenExpireTime.value) {
|
||||
doLogout();
|
||||
alert('登录已超时,请重新登录');
|
||||
alert("登录已超时,请重新登录");
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
@@ -342,11 +335,11 @@ const app = createApp({
|
||||
// ===== 登录/退出 =====
|
||||
const doLogin = async () => {
|
||||
if (!loginForm.username || !loginForm.password) {
|
||||
loginError.value = '请输入用户名和密码';
|
||||
loginError.value = "请输入用户名和密码";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
loginError.value = '';
|
||||
loginError.value = "";
|
||||
const result = await login(loginForm.username, loginForm.password);
|
||||
if (result.success) {
|
||||
token.value = result.token;
|
||||
@@ -372,10 +365,10 @@ const app = createApp({
|
||||
if (expireTimer) clearInterval(expireTimer);
|
||||
contentVisible.value = false;
|
||||
loggedIn.value = false;
|
||||
token.value = '';
|
||||
token.value = "";
|
||||
tokenExpireTime.value = 0;
|
||||
loginForm.username = '';
|
||||
loginForm.password = '';
|
||||
loginForm.username = "";
|
||||
loginForm.password = "";
|
||||
loading.value = false;
|
||||
transitioning.value = false;
|
||||
clearAuthState();
|
||||
@@ -386,9 +379,9 @@ const app = createApp({
|
||||
const result = await saveConfigApi(globalConfig);
|
||||
if (result.code === 0) {
|
||||
frpcRunning.value = await getFrpcStatus();
|
||||
alert('配置已更新并热加载');
|
||||
alert("配置已更新并热加载");
|
||||
} else {
|
||||
alert('保存失败: ' + result.msg);
|
||||
alert("保存失败: " + result.msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -396,7 +389,7 @@ const app = createApp({
|
||||
const toggleProxy = async (p) => {
|
||||
const result = await updateProxy(p);
|
||||
if (result.code !== 0) {
|
||||
alert('更新失败: ' + result.msg);
|
||||
alert("更新失败: " + result.msg);
|
||||
proxies.value = await loadProxies();
|
||||
} else {
|
||||
frpcRunning.value = await getFrpcStatus();
|
||||
@@ -404,37 +397,40 @@ const app = createApp({
|
||||
};
|
||||
|
||||
const removeProxy = async (id) => {
|
||||
console.log('🔍 removeProxy 被调用, id:', id); // ← 加这行
|
||||
if (!confirm('确定要删除这条隧道吗?')) return;
|
||||
console.log("🔍 removeProxy 被调用, id:", 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);
|
||||
alert("删除失败: " + result.msg);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDialog = async () => {
|
||||
const form = dialogForm.value;
|
||||
if (!form.name || !form.localIP || !form.localPort || !form.remotePort) {
|
||||
alert('请填写完整信息');
|
||||
alert("请填写完整信息");
|
||||
return;
|
||||
}
|
||||
const result = dialogMode.value === 'add'
|
||||
? await createProxy(form)
|
||||
: await updateProxy(form);
|
||||
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);
|
||||
alert("操作失败: " + result.msg);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 计算属性 =====
|
||||
const filteredProxies = computed(() => filterProxies(proxies.value, searchKeyword.value));
|
||||
const filteredProxies = computed(() =>
|
||||
filterProxies(proxies.value, searchKeyword.value),
|
||||
);
|
||||
|
||||
return {
|
||||
loggedIn,
|
||||
@@ -470,4 +466,4 @@ const app = createApp({
|
||||
},
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
app.mount("#app");
|
||||
|
||||
+39
-38
@@ -10,8 +10,8 @@
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- Naive UI CDN -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/naive-ui@2/dist/styles.css" />
|
||||
<!-- TOML 解析库(用于导入/导出) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@iarna/toml@3.0.0/dist/toml.min.js"></script>
|
||||
<!-- 不再需要前端 TOML 库,已改用后端导出 -->
|
||||
<!--<script src="https://cdn.jsdelivr.net/npm/@iarna/toml@3.0.0/dist/toml.min.js"></script>-->
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="/static/style-1.css" />
|
||||
<link rel="stylesheet" href="/static/style-2.css" />
|
||||
@@ -188,7 +188,8 @@
|
||||
<button class="icon-btn" @click="openEditDialog(p)">
|
||||
✎
|
||||
</button>
|
||||
<button class="icon-btn danger" @click="deleteProxy(p.id)">
|
||||
<button class="icon-btn danger"
|
||||
@click="(function(){ console.log('点击了删除按钮, id:', p.id); removeProxy(p.id); })()">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -211,44 +212,44 @@
|
||||
</div>
|
||||
|
||||
<!-- ====== 新增/编辑弹窗 ====== -->
|
||||
<Transition name="dialog">
|
||||
<div v-if="dialogVisible" class="dialog-overlay" @click.self="dialogVisible = false">
|
||||
<div class="dialog-card">
|
||||
<h3>{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}</h3>
|
||||
<div class="dialog-form">
|
||||
<div class="form-row">
|
||||
<label>名称</label>
|
||||
<input v-model="dialogForm.name" />
|
||||
<Transition name="dialog">
|
||||
<div v-if="dialogVisible" class="dialog-overlay" @click.self="dialogVisible = false">
|
||||
<div class="dialog-card">
|
||||
<h3>{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}</h3>
|
||||
<div class="dialog-form">
|
||||
<div class="form-row">
|
||||
<label>名称</label>
|
||||
<input v-model="dialogForm.name" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>类型</label>
|
||||
<select v-model="dialogForm.type">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>本地 IP</label>
|
||||
<input v-model="dialogForm.localIP" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>本地端口</label>
|
||||
<input type="number" v-model="dialogForm.localPort" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>远程端口</label>
|
||||
<input type="number" v-model="dialogForm.remotePort" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>类型</label>
|
||||
<select v-model="dialogForm.type">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>本地 IP</label>
|
||||
<input v-model="dialogForm.localIP" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>本地端口</label>
|
||||
<input type="number" v-model="dialogForm.localPort" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>远程端口</label>
|
||||
<input type="number" v-model="dialogForm.remotePort" />
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" @click="dialogVisible = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn-confirm" @click="confirmDialog">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" @click="dialogVisible = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn-confirm" @click="confirmDialog">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ====== 隐藏的文件输入(导入 TOML) ====== -->
|
||||
|
||||
Reference in New Issue
Block a user