加入首次使用的引导页、写入readme文件
This commit is contained in:
+189
-84
@@ -1,12 +1,8 @@
|
||||
// app.js - 普通脚本(非模块)
|
||||
|
||||
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||
|
||||
// ============================================================
|
||||
// 1. 把 modules/*.js 的内容直接合并进来(因为是普通脚本,不能 import)
|
||||
// API
|
||||
// ============================================================
|
||||
|
||||
// ---------- api ----------
|
||||
const API_BASE = "/api";
|
||||
function getToken() {
|
||||
return localStorage.getItem("frpc_token") || "";
|
||||
@@ -19,10 +15,9 @@ function getHeaders() {
|
||||
}
|
||||
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 || {}) },
|
||||
@@ -30,7 +25,9 @@ async function apiFetch(endpoint, options = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
// ============================================================
|
||||
// Auth
|
||||
// ============================================================
|
||||
function parseJwtExpire(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
@@ -74,7 +71,9 @@ async function login(username, password) {
|
||||
return { success: false, error: data.msg || "登录失败" };
|
||||
}
|
||||
|
||||
// ---------- config ----------
|
||||
// ============================================================
|
||||
// Config
|
||||
// ============================================================
|
||||
const defaultConfig = {
|
||||
serverAddr: "frp.whitetop.xyz",
|
||||
serverPort: 9358,
|
||||
@@ -106,7 +105,9 @@ async function saveConfigApi(config) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- proxies ----------
|
||||
// ============================================================
|
||||
// Proxies
|
||||
// ============================================================
|
||||
async function loadProxies() {
|
||||
try {
|
||||
const data = await apiFetch("/proxies");
|
||||
@@ -141,15 +142,16 @@ async function deleteProxy(id) {
|
||||
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 ----------
|
||||
// ============================================================
|
||||
// Frpc
|
||||
// ============================================================
|
||||
async function getFrpcStatus() {
|
||||
try {
|
||||
const data = await apiFetch("/frpc/status");
|
||||
@@ -161,27 +163,11 @@ async function getFrpcStatus() {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function reloadFrpc() {
|
||||
const data = await apiFetch("/frpc/reload", { method: "POST" });
|
||||
return data;
|
||||
}
|
||||
async function startFrpc() {
|
||||
const data = await apiFetch("/frpc/start", { method: "POST" });
|
||||
return data;
|
||||
}
|
||||
async function stopFrpc() {
|
||||
const data = await apiFetch("/frpc/stop", { method: "POST" });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- ui ----------
|
||||
// Tab 切换
|
||||
// ============================================================
|
||||
// UI State
|
||||
// ============================================================
|
||||
const activeTab = ref("proxies");
|
||||
function switchTab(tab) {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
// 弹窗控制
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("add");
|
||||
const dialogForm = ref({
|
||||
@@ -192,17 +178,11 @@ const dialogForm = ref({
|
||||
localPort: 0,
|
||||
remotePort: 0,
|
||||
});
|
||||
const searchKeyword = ref("");
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = "add";
|
||||
dialogForm.value = {
|
||||
id: null,
|
||||
name: "",
|
||||
type: "tcp",
|
||||
localIP: "",
|
||||
localPort: 0,
|
||||
remotePort: 0,
|
||||
};
|
||||
dialogForm.value = { id: null, name: "", type: "tcp", localIP: "", localPort: 0, remotePort: 0 };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
function openEditDialog(proxy) {
|
||||
@@ -214,9 +194,9 @@ function closeDialog() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
const searchKeyword = ref("");
|
||||
|
||||
// ---------- import/export 功能 ----------
|
||||
// ============================================================
|
||||
// Import / Export
|
||||
// ============================================================
|
||||
async function triggerImport() {
|
||||
document.getElementById("tomlFileInput").click();
|
||||
}
|
||||
@@ -244,42 +224,35 @@ async function handleImport(e) {
|
||||
e.target.value = "";
|
||||
}
|
||||
async function exportToml() {
|
||||
console.log('🔍 exportToml 被调用了');
|
||||
try {
|
||||
const res = await fetch('/api/export/toml', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
const res = await fetch("/api/export/toml", {
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer " + getToken() },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert('导出失败: ' + (data.msg || '未知错误'));
|
||||
alert("导出失败: " + (data.msg || "未知错误"));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'frpc.toml';
|
||||
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);
|
||||
alert("导出失败: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. Vue 应用
|
||||
// Vue App
|
||||
// ============================================================
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
// ===== 登录态 =====
|
||||
// ---- 登录态 ----
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const loginError = ref("");
|
||||
@@ -290,13 +263,32 @@ const app = createApp({
|
||||
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 showDetail = ref(false);
|
||||
|
||||
// ===== 初始化 =====
|
||||
// ---- 修改密码 ----
|
||||
const passwordChange = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const passwordChangeError = ref("");
|
||||
const passwordChangeSuccess = ref("");
|
||||
|
||||
// ---- 初始化 ----
|
||||
onMounted(() => {
|
||||
const saved = loadAuthState();
|
||||
if (saved.valid) {
|
||||
@@ -308,10 +300,24 @@ const app = createApp({
|
||||
contentVisible.value = true;
|
||||
startExpireTimer();
|
||||
});
|
||||
} else {
|
||||
checkUsers();
|
||||
}
|
||||
});
|
||||
|
||||
const startExpireTimer = () => {
|
||||
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) {
|
||||
@@ -319,9 +325,9 @@ const app = createApp({
|
||||
alert("登录已超时,请重新登录");
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
const loadAllData = async () => {
|
||||
async function loadAllData() {
|
||||
const [cfg, list, running] = await Promise.all([
|
||||
loadConfig(),
|
||||
loadProxies(),
|
||||
@@ -330,9 +336,9 @@ const app = createApp({
|
||||
Object.assign(globalConfig, cfg);
|
||||
proxies.value = list;
|
||||
frpcRunning.value = running;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 登录/退出 =====
|
||||
// ---- 登录 ----
|
||||
const doLogin = async () => {
|
||||
if (!loginForm.username || !loginForm.password) {
|
||||
loginError.value = "请输入用户名和密码";
|
||||
@@ -361,6 +367,58 @@ const app = createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 注册 ----
|
||||
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) {
|
||||
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);
|
||||
contentVisible.value = false;
|
||||
@@ -374,7 +432,42 @@ const app = createApp({
|
||||
clearAuthState();
|
||||
};
|
||||
|
||||
// ===== 保存配置 =====
|
||||
// ---- 修改密码 ----
|
||||
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) {
|
||||
@@ -385,7 +478,7 @@ const app = createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 隧道操作 =====
|
||||
// ---- 隧道操作 ----
|
||||
const toggleProxy = async (p) => {
|
||||
const result = await updateProxy(p);
|
||||
if (result.code !== 0) {
|
||||
@@ -395,9 +488,7 @@ const app = createApp({
|
||||
frpcRunning.value = await getFrpcStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const removeProxy = async (id) => {
|
||||
console.log("🔍 removeProxy 被调用, id:", id); // ← 加这行
|
||||
if (!confirm("确定要删除这条隧道吗?")) return;
|
||||
const result = await deleteProxy(id);
|
||||
if (result.code === 0) {
|
||||
@@ -407,17 +498,15 @@ const app = createApp({
|
||||
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);
|
||||
const result = dialogMode.value === "add"
|
||||
? await createProxy(form)
|
||||
: await updateProxy(form);
|
||||
if (result.code === 0) {
|
||||
closeDialog();
|
||||
proxies.value = await loadProxies();
|
||||
@@ -427,11 +516,12 @@ const app = createApp({
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 计算属性 =====
|
||||
// ---- 计算属性 ----
|
||||
const filteredProxies = computed(() =>
|
||||
filterProxies(proxies.value, searchKeyword.value),
|
||||
filterProxies(proxies.value, searchKeyword.value)
|
||||
);
|
||||
|
||||
// ---- 返回 ----
|
||||
return {
|
||||
loggedIn,
|
||||
loading,
|
||||
@@ -439,16 +529,24 @@ const app = createApp({
|
||||
loginForm,
|
||||
doLogin,
|
||||
doLogout,
|
||||
|
||||
isFirstRun,
|
||||
registerForm,
|
||||
registerError,
|
||||
registering,
|
||||
doRegister,
|
||||
|
||||
activeTab,
|
||||
switchTab,
|
||||
globalConfig,
|
||||
showDetail,
|
||||
saveConfig,
|
||||
|
||||
proxies,
|
||||
filteredProxies,
|
||||
toggleProxy,
|
||||
removeProxy,
|
||||
frpcRunning,
|
||||
|
||||
dialogVisible,
|
||||
dialogMode,
|
||||
dialogForm,
|
||||
@@ -456,14 +554,21 @@ const app = createApp({
|
||||
openEditDialog,
|
||||
closeDialog,
|
||||
confirmDialog,
|
||||
|
||||
searchKeyword,
|
||||
transitioning,
|
||||
contentVisible,
|
||||
|
||||
triggerImport,
|
||||
handleImport,
|
||||
exportToml,
|
||||
|
||||
passwordChange,
|
||||
passwordChangeError,
|
||||
passwordChangeSuccess,
|
||||
changePassword,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
app.mount("#app");
|
||||
app.mount("#app");
|
||||
+129
-121
@@ -8,11 +8,7 @@
|
||||
|
||||
<!-- Vue 3 CDN -->
|
||||
<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>-->
|
||||
<!-- 自定义样式 -->
|
||||
<!-- 样式 -->
|
||||
<link rel="stylesheet" href="/static/style-1.css" />
|
||||
<link rel="stylesheet" href="/static/style-2.css" />
|
||||
<link rel="stylesheet" href="/static/style-3.css" />
|
||||
@@ -20,40 +16,86 @@
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 背景层:始终存在的磨砂玻璃质感 -->
|
||||
<!-- 背景层 -->
|
||||
<div class="app-backdrop" :class="{ 'is-logged-in': loggedIn }"></div>
|
||||
<div class="loading-ring" :class="{ visible: transitioning }"></div>
|
||||
|
||||
<!-- 主容器:登录态决定尺寸 -->
|
||||
<!-- 主容器 -->
|
||||
<div class="app-container" :class="{ 'is-logged-in': loggedIn }">
|
||||
<!-- ====== 登录页 ====== -->
|
||||
|
||||
<!-- ====== 登录/注册页 ====== -->
|
||||
<div v-if="!loggedIn" class="login-card">
|
||||
<div class="login-header">
|
||||
<span class="login-icon">🚀</span>
|
||||
<h1>console login</h1>
|
||||
|
||||
<!-- Logo 区域(可替换) -->
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 64 64" preserveAspectRatio="xMidYMid meet" style="width:100%; height:100%; display:block;" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="56" height="56" rx="16" stroke="#4a7cff" stroke-width="2" opacity="0.3"/>
|
||||
<path d="M20 44L32 20L44 44H36L32 36L28 44H20Z" fill="#4a7cff" opacity="0.9"/>
|
||||
<circle cx="32" cy="32" r="4" fill="#4a7cff" opacity="0.6"/>
|
||||
<path d="M32 12V18M32 46V52M14 32H20M44 32H50M18 18L22 22M42 42L46 46M18 46L22 42M42 22L46 18" stroke="#4a7cff" stroke-width="1.5" stroke-linecap="round" opacity="0.4"/>
|
||||
</svg>
|
||||
<span class="logo-text">frpc-console</span>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<div class="input-group">
|
||||
<label>user</label>
|
||||
<input v-model="loginForm.username" type="text" placeholder="用户名" @keydown.enter="doLogin" />
|
||||
|
||||
<!-- 首次启动:注册页 -->
|
||||
<template v-if="isFirstRun">
|
||||
<div class="login-header">
|
||||
<h1>首次设置</h1>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>passwd</label>
|
||||
<input v-model="loginForm.password" type="password" placeholder="密码" @keydown.enter="doLogin" />
|
||||
<div class="login-form">
|
||||
<div class="input-group">
|
||||
<label>用户名(至少 5 位)</label>
|
||||
<input v-model="registerForm.username" type="text" placeholder="设置管理员用户名" @keydown.enter="doRegister" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>密码</label>
|
||||
<input v-model="registerForm.password" type="password" placeholder="至少8位,含大小写/数字/特殊字符" @keydown.enter="doRegister" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>确认密码</label>
|
||||
<input v-model="registerForm.confirmPassword" type="password" placeholder="再次输入密码" @keydown.enter="doRegister" />
|
||||
</div>
|
||||
<button class="login-btn" @click="doRegister" :disabled="registering">
|
||||
{{ registering ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
<p v-if="registerError" class="login-error">{{ registerError }}</p>
|
||||
</div>
|
||||
<button class="login-btn" @click="doLogin" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
<p v-if="loginError" class="login-error">{{ loginError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 正常:登录页 -->
|
||||
<template v-else>
|
||||
<div class="login-header">
|
||||
<h1>console login</h1>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<div class="input-group">
|
||||
<label>user</label>
|
||||
<input v-model="loginForm.username" type="text" placeholder="用户名" @keydown.enter="doLogin" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>passwd</label>
|
||||
<input v-model="loginForm.password" type="password" placeholder="密码" @keydown.enter="doLogin" />
|
||||
</div>
|
||||
<button class="login-btn" @click="doLogin" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
<p v-if="loginError" class="login-error">{{ loginError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ====== 主界面(登录后) ====== -->
|
||||
<!-- ====== 主界面 ====== -->
|
||||
<div v-else class="main-panel" :class="{ visible: contentVisible }">
|
||||
<!-- 顶部导航 -->
|
||||
|
||||
<!-- 顶部导航(含 Logo 小标) -->
|
||||
<div class="top-bar">
|
||||
<div class="top-left">
|
||||
<span class="logo">🚀 frpc-console</span>
|
||||
<svg viewBox="0 0 28 28" preserveAspectRatio="xMidYMid meet" style="width:100%; height:100%; display:block;" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
|
||||
<rect x="2" y="2" width="24" height="24" rx="6" stroke="#4a7cff" stroke-width="1.5" opacity="0.3"/>
|
||||
<path d="M9 19L14 9L19 19H16L14 16L12 19H9Z" fill="#4a7cff" opacity="0.9"/>
|
||||
<circle cx="14" cy="14" r="2" fill="#4a7cff" opacity="0.5"/>
|
||||
</svg>
|
||||
<span class="logo">frpc-console</span>
|
||||
<span class="status-dot" :class="{ active: frpcRunning }"></span>
|
||||
<span class="status-text">{{ frpcRunning ? 'frpc 运行中' : 'frpc 已停止' }}</span>
|
||||
</div>
|
||||
@@ -63,20 +105,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<!-- Tab 栏 -->
|
||||
<div class="tab-bar">
|
||||
<span class="tab-item" :class="{ active: activeTab === 'proxies' }" @click="activeTab = 'proxies'">
|
||||
隧道列表
|
||||
</span>
|
||||
<span class="tab-item" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">
|
||||
全局配置信息
|
||||
</span>
|
||||
<span class="tab-item" :class="{ active: activeTab === 'proxies' }" @click="activeTab = 'proxies'">隧道列表</span>
|
||||
<span class="tab-item" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">全局配置信息</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="content-area">
|
||||
<!-- 全局配置 -->
|
||||
|
||||
<!-- ====== 全局配置 ====== -->
|
||||
<div v-if="activeTab === 'config'" class="tab-content">
|
||||
|
||||
<!-- 账户管理 -->
|
||||
<div class="profile-section">
|
||||
<div class="profile-header">
|
||||
<span>👤 账户管理</span>
|
||||
<span class="profile-username">{{ loginForm.username }}</span>
|
||||
</div>
|
||||
<div class="profile-form">
|
||||
<div class="form-row">
|
||||
<label>当前密码</label>
|
||||
<input v-model="passwordChange.oldPassword" type="password" placeholder="输入当前密码" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>新密码</label>
|
||||
<input v-model="passwordChange.newPassword" type="password" placeholder="至少8位,含大小写/数字/特殊字符" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>确认新密码</label>
|
||||
<input v-model="passwordChange.confirmPassword" type="password" placeholder="再次输入新密码" />
|
||||
</div>
|
||||
<button class="save-btn" @click="changePassword">修改密码</button>
|
||||
<p v-if="passwordChangeError" class="login-error">{{ passwordChangeError }}</p>
|
||||
<p v-if="passwordChangeSuccess" class="login-success">{{ passwordChangeSuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置卡片 -->
|
||||
<div class="config-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">TCP Multiplexing</div>
|
||||
@@ -106,74 +172,39 @@
|
||||
<span>{{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }}</span>
|
||||
</div>
|
||||
<div v-show="showDetail" class="detail-panel">
|
||||
<div class="form-row">
|
||||
<label>Server Address</label>
|
||||
<input v-model="globalConfig.serverAddr" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Server Port</label>
|
||||
<input type="number" v-model="globalConfig.serverPort" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Token</label>
|
||||
<input type="password" v-model="globalConfig.token" />
|
||||
</div>
|
||||
<div class="form-row"><label>Server Address</label><input v-model="globalConfig.serverAddr" /></div>
|
||||
<div class="form-row"><label>Server Port</label><input type="number" v-model="globalConfig.serverPort" /></div>
|
||||
<div class="form-row"><label>Token</label><input type="password" v-model="globalConfig.token" /></div>
|
||||
<div class="form-row">
|
||||
<label>Log Level</label>
|
||||
<select v-model="globalConfig.logLevel">
|
||||
<option value="trace">trace</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="trace">trace</option><option value="debug">debug</option>
|
||||
<option value="info">info</option><option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Log Max Days</label>
|
||||
<input type="number" v-model="globalConfig.logMaxDays" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>TCP Mux Keepalive</label>
|
||||
<input type="number" v-model="globalConfig.tcpMuxKeepalive" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Heartbeat Interval</label>
|
||||
<input type="number" v-model="globalConfig.heartbeatInterval" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Heartbeat Timeout</label>
|
||||
<input type="number" v-model="globalConfig.heartbeatTimeout" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Pool Count</label>
|
||||
<input type="number" v-model="globalConfig.poolCount" />
|
||||
</div>
|
||||
<button class="save-btn" @click="saveConfig">
|
||||
保存配置并热加载
|
||||
</button>
|
||||
<div class="form-row"><label>Log Max Days</label><input type="number" v-model="globalConfig.logMaxDays" /></div>
|
||||
<div class="form-row"><label>TCP Mux Keepalive</label><input type="number" v-model="globalConfig.tcpMuxKeepalive" /></div>
|
||||
<div class="form-row"><label>Heartbeat Interval</label><input type="number" v-model="globalConfig.heartbeatInterval" /></div>
|
||||
<div class="form-row"><label>Heartbeat Timeout</label><input type="number" v-model="globalConfig.heartbeatTimeout" /></div>
|
||||
<div class="form-row"><label>Pool Count</label><input type="number" v-model="globalConfig.poolCount" /></div>
|
||||
<button class="save-btn" @click="saveConfig">保存配置并热加载</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隧道列表 -->
|
||||
<!-- ====== 隧道列表 ====== -->
|
||||
<div v-else-if="activeTab === 'proxies'" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="btn-import" @click="triggerImport">
|
||||
导入 TOML
|
||||
</button>
|
||||
<button class="btn-export" @click="exportToml">
|
||||
导出 TOML
|
||||
</button>
|
||||
<button class="btn-add" @click="openAddDialog">
|
||||
+ 新增隧道
|
||||
</button>
|
||||
<button class="btn-import" @click="triggerImport">导入 TOML</button>
|
||||
<button class="btn-export" @click="exportToml">导出 TOML</button>
|
||||
<button class="btn-add" @click="openAddDialog">+ 新增隧道</button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<input class="search-input" placeholder="搜索隧道..." v-model="searchKeyword" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隧道卡片列表 -->
|
||||
<div class="proxy-list">
|
||||
<div v-for="p in filteredProxies" :key="p.id" class="proxy-card">
|
||||
<div class="proxy-row">
|
||||
@@ -185,13 +216,8 @@
|
||||
</div>
|
||||
<div class="proxy-right">
|
||||
<span class="remote-port">{{ p.remotePort }}</span>
|
||||
<button class="icon-btn" @click="openEditDialog(p)">
|
||||
✎
|
||||
</button>
|
||||
<button class="icon-btn danger"
|
||||
@click="(function(){ console.log('点击了删除按钮, id:', p.id); removeProxy(p.id); })()">
|
||||
✕
|
||||
</button>
|
||||
<button class="icon-btn" @click="openEditDialog(p)">✎</button>
|
||||
<button class="icon-btn danger" @click="removeProxy(p.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-footer">
|
||||
@@ -202,61 +228,43 @@
|
||||
<span class="status-label">{{ p.enabled ? '已启用' : '已禁用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredProxies.length === 0" class="empty-state">
|
||||
暂无隧道,点击「新增隧道」添加
|
||||
</div>
|
||||
<div v-if="filteredProxies.length === 0" class="empty-state">暂无隧道,点击「新增隧道」添加</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
</div>
|
||||
<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>
|
||||
<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 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="dialog-actions">
|
||||
<button class="btn-cancel" @click="dialogVisible = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn-cancel" @click="dialogVisible = false">取消</button>
|
||||
<button class="btn-confirm" @click="confirmDialog">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ====== 隐藏的文件输入(导入 TOML) ====== -->
|
||||
<input type="file" id="tomlFileInput" accept=".toml" style="display: none" @change="handleImport" />
|
||||
<!-- 隐藏文件选择器 -->
|
||||
<input type="file" id="tomlFileInput" accept=".toml" style="display:none" @change="handleImport" />
|
||||
</div>
|
||||
|
||||
<!-- 应用逻辑 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -97,4 +97,24 @@
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
/* ---------- Logo 区域 ---------- */
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.login-logo .logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.login-logo svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -604,4 +604,76 @@ select:disabled {
|
||||
.dialog-leave-from .dialog-card {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- 账户管理区域 ---------- */
|
||||
.profile-section {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.profile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.profile-username {
|
||||
color: var(--primary-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
.profile-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
align-items: end;
|
||||
}
|
||||
.profile-form .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.profile-form .form-row label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.profile-form .form-row input {
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.profile-form .form-row input:focus {
|
||||
border-color: var(--primary-blue-border);
|
||||
}
|
||||
.profile-form .save-btn {
|
||||
grid-column: auto;
|
||||
padding: 8px 20px;
|
||||
margin-top: 0;
|
||||
height: 40px;
|
||||
}
|
||||
.login-success {
|
||||
color: #63e2b7;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.top-left svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user