修复转发暂停问题 增加安卓apk
@@ -9,6 +9,7 @@ on:
|
||||
- 'vite-frontend/**'
|
||||
- 'springboot-backend/**'
|
||||
- 'go-gost/**'
|
||||
- 'android-app/**'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -254,3 +254,4 @@ gitee/
|
||||
# 证书文件
|
||||
*.crt
|
||||
*.cert
|
||||
android-app/app/release/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.doraemon'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.doraemon"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.doraemon
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.doraemon", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools" >
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Doraemon"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31" >
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="uiMode|orientation|screenSize" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,6 @@
|
||||
import { d as domAnimation } from "./index-jxskBxXq.js";
|
||||
"use client";
|
||||
var index_default = domAnimation;
|
||||
export {
|
||||
index_default as default
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
;
|
||||
(function () {
|
||||
System.register(['./index-legacy-OLEVtAva.js'], function (exports, module) {
|
||||
'use strict';
|
||||
|
||||
var domAnimation;
|
||||
return {
|
||||
setters: [module => {
|
||||
domAnimation = module.d;
|
||||
}],
|
||||
execute: function () {
|
||||
"use client";
|
||||
|
||||
var index_default = exports("default", domAnimation);
|
||||
}
|
||||
};
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>哆啦A梦</title>
|
||||
<script>
|
||||
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
||||
(function() {
|
||||
// 立即检测系统主题并应用
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// 立即设置主题类,避免闪烁
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检测H5模式并设置标记,避免布局切换时的闪屏
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
if (isMobile || isMobileBrowser || isH5Param) {
|
||||
document.documentElement.classList.add('h5-mode');
|
||||
} else {
|
||||
document.documentElement.classList.add('pc-mode');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* 防止页面闪烁的基础样式 */
|
||||
:root {
|
||||
--toaster-bg: #ffffff;
|
||||
--toaster-color: #000000;
|
||||
--toaster-border: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--toaster-bg: #374151;
|
||||
--toaster-color: #ffffff;
|
||||
--toaster-border: #4b5563;
|
||||
}
|
||||
|
||||
/* 默认浅色主题样式 */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 暗色主题样式 */
|
||||
html.dark body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* 防止加载时的闪烁 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 确保在主题切换时平滑过渡 */
|
||||
html {
|
||||
transition: color-scheme 0.2s ease;
|
||||
}
|
||||
|
||||
/* 防止布局切换时的闪屏 */
|
||||
.h5-mode #root {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.h5-mode.dark #root {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.pc-mode #root {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.pc-mode.dark #root {
|
||||
background-color: #000000;
|
||||
}
|
||||
</style>
|
||||
<meta key="title" content="哆啦A梦" property="og:title" />
|
||||
<meta
|
||||
content="哆啦A梦"
|
||||
property="og:description"
|
||||
/>
|
||||
<meta
|
||||
content="哆啦A梦"
|
||||
name="description"
|
||||
/>
|
||||
<meta
|
||||
key="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
name="viewport"
|
||||
/>
|
||||
<link href="./assets/favicon-DI5FVxIz.ico" rel="icon" />
|
||||
<script type="module" crossorigin src="./assets/index-jxskBxXq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DXlBHwCy.css">
|
||||
<script type="module">import.meta.url;import("_").catch(()=>1);(async function*(){})().next();if(location.protocol!="file:"){window.__vite_is_modern_browser=true}</script>
|
||||
<script type="module">!function(){if(window.__vite_is_modern_browser)return;console.warn("vite: loading legacy chunks, syntax error above and the same error below should be ignored");var e=document.getElementById("vite-legacy-polyfill"),n=document.createElement("script");n.src=e.src,n.onload=function(){System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))},document.body.appendChild(n)}();</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script nomodule>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
|
||||
<script nomodule crossorigin id="vite-legacy-polyfill" src="./assets/polyfills-legacy-sZVJv9Pp.js"></script>
|
||||
<script nomodule crossorigin id="vite-legacy-entry" data-src="./assets/index-legacy-OLEVtAva.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 406 KiB |
@@ -0,0 +1,247 @@
|
||||
package com.doraemon
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsetsController
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.webkit.WebSettings
|
||||
import android.content.res.Configuration
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
webView = findViewById(R.id.webView)
|
||||
setupWebView()
|
||||
loadUrl()
|
||||
|
||||
// 设置状态栏和导航栏颜色跟随系统主题
|
||||
setupSystemBars()
|
||||
|
||||
// 设置根布局背景色
|
||||
setupRootBackground()
|
||||
}
|
||||
|
||||
private fun setupWebView() {
|
||||
val settings = webView.settings
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.databaseEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
settings.setSupportZoom(true)
|
||||
settings.builtInZoomControls = true
|
||||
settings.displayZoomControls = false
|
||||
|
||||
// 允许加载本地文件
|
||||
settings.allowFileAccess = true
|
||||
settings.allowContentAccess = true
|
||||
settings.allowFileAccessFromFileURLs = true
|
||||
settings.allowUniversalAccessFromFileURLs = true
|
||||
|
||||
// 设置混合内容模式
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
|
||||
settings.mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
}
|
||||
|
||||
// 设置WebView背景色跟随系统主题
|
||||
val isDarkTheme = resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
if (isDarkTheme) {
|
||||
webView.setBackgroundColor(Color.BLACK)
|
||||
} else {
|
||||
webView.setBackgroundColor(Color.WHITE)
|
||||
}
|
||||
|
||||
// 添加JavaScript接口
|
||||
webView.addJavascriptInterface(object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun isWebView(): String {
|
||||
return "true"
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun savePanelAddress(name: String, address: String) {
|
||||
val sharedPrefs =
|
||||
getSharedPreferences("panel_config", android.content.Context.MODE_PRIVATE)
|
||||
val editor = sharedPrefs.edit()
|
||||
|
||||
// 获取现有的地址列表
|
||||
val existingAddresses =
|
||||
sharedPrefs.getStringSet("panel_addresses", mutableSetOf()) ?: mutableSetOf()
|
||||
val newAddresses = existingAddresses.toMutableSet()
|
||||
|
||||
// 添加新地址(格式:name|address)
|
||||
newAddresses.add("$name|$address")
|
||||
|
||||
editor.putStringSet("panel_addresses", newAddresses)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun getPanelAddresses(): String {
|
||||
val sharedPrefs =
|
||||
getSharedPreferences("panel_config", android.content.Context.MODE_PRIVATE)
|
||||
val addresses =
|
||||
sharedPrefs.getStringSet("panel_addresses", mutableSetOf()) ?: mutableSetOf()
|
||||
return if (addresses.isEmpty()) "" else addresses.joinToString(",")
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun setCurrentPanelAddress(address: String) {
|
||||
val sharedPrefs =
|
||||
getSharedPreferences("panel_config", android.content.Context.MODE_PRIVATE)
|
||||
val editor = sharedPrefs.edit()
|
||||
editor.putString("current_panel_address", address)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun getCurrentPanelAddress(): String {
|
||||
val sharedPrefs =
|
||||
getSharedPreferences("panel_config", android.content.Context.MODE_PRIVATE)
|
||||
return sharedPrefs.getString("current_panel_address", "") ?: ""
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun deletePanelAddress(name: String, address: String) {
|
||||
val sharedPrefs =
|
||||
getSharedPreferences("panel_config", android.content.Context.MODE_PRIVATE)
|
||||
val editor = sharedPrefs.edit()
|
||||
|
||||
val existingAddresses =
|
||||
sharedPrefs.getStringSet("panel_addresses", mutableSetOf()) ?: mutableSetOf()
|
||||
val newAddresses = existingAddresses.toMutableSet()
|
||||
|
||||
// 删除指定地址
|
||||
newAddresses.remove("$name|$address")
|
||||
|
||||
// 如果删除的是当前选中的地址,清除当前地址
|
||||
val currentAddress = sharedPrefs.getString("current_panel_address", "") ?: ""
|
||||
if (currentAddress == address) {
|
||||
editor.remove("current_panel_address")
|
||||
}
|
||||
|
||||
editor.putStringSet("panel_addresses", newAddresses)
|
||||
editor.apply()
|
||||
}
|
||||
}, "AndroidInterface")
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
|
||||
url?.let { view?.loadUrl(it) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUrl() {
|
||||
webView.getSettings().setJavaScriptEnabled(true);
|
||||
webView.getSettings().setAllowFileAccess(true); // 允许访问 assets
|
||||
webView.getSettings().setAllowFileAccessFromFileURLs(true); // Android 4.1+
|
||||
webView.getSettings().setAllowUniversalAccessFromFileURLs(true); // Android 4.1+
|
||||
|
||||
webView.loadUrl("file:///android_asset/index.html")
|
||||
}
|
||||
|
||||
private fun setupSystemBars() {
|
||||
// 检测当前是否为暗色主题
|
||||
val isDarkTheme = resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
if (isDarkTheme) {
|
||||
// 暗色主题:深色状态栏,浅色文字
|
||||
window.statusBarColor = Color.BLACK
|
||||
window.navigationBarColor = Color.BLACK
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.insetsController?.setSystemBarsAppearance(
|
||||
0, // 清除浅色状态栏标志
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
|
||||
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
|
||||
)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility =
|
||||
window.decorView.systemUiVisibility and
|
||||
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility =
|
||||
window.decorView.systemUiVisibility and
|
||||
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 浅色主题:浅色状态栏,深色文字
|
||||
window.statusBarColor = Color.WHITE
|
||||
window.navigationBarColor = Color.WHITE
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.insetsController?.setSystemBarsAppearance(
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
|
||||
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
|
||||
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
|
||||
)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility =
|
||||
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility =
|
||||
window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (webView.canGoBack()) webView.goBack() else super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
// 当系统主题变化时,重新设置状态栏、WebView背景色和根布局背景色
|
||||
setupSystemBars()
|
||||
setupWebViewBackground()
|
||||
setupRootBackground()
|
||||
}
|
||||
|
||||
private fun setupWebViewBackground() {
|
||||
val isDarkTheme = resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
if (isDarkTheme) {
|
||||
webView.setBackgroundColor(Color.BLACK)
|
||||
} else {
|
||||
webView.setBackgroundColor(Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRootBackground() {
|
||||
val isDarkTheme = resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
val rootView = findViewById<android.view.View>(android.R.id.content)
|
||||
if (isDarkTheme) {
|
||||
rootView.setBackgroundColor(Color.BLACK)
|
||||
} else {
|
||||
rootView.setBackgroundColor(Color.WHITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.Doraemon" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">哆啦A梦</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.Doraemon" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Doraemon" parent="Base.Theme.Doraemon" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.doraemon
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,6 @@
|
||||
#Wed Aug 20 09:15:27 CST 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Doraemon"
|
||||
include ':app'
|
||||
@@ -2,6 +2,13 @@
|
||||
# 下载地址
|
||||
DOWNLOAD_URL="https://github.com/bqlpfy/forward-panel/releases/download/gost-latest/gost"
|
||||
INSTALL_DIR="/etc/gost"
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# 显示菜单
|
||||
show_menu() {
|
||||
@@ -127,19 +134,7 @@ done
|
||||
install_gost() {
|
||||
echo "🚀 开始安装 GOST..."
|
||||
get_config_params
|
||||
|
||||
# 询问是否有加速下载地址
|
||||
echo ""
|
||||
echo "📥 检查下载地址..."
|
||||
echo "加速下载地址需提供完整的地址,浏览器打开就能直接下载的那种!!!!!"
|
||||
read -p "是否有加速下载地址?(留空使用默认地址): " custom_url
|
||||
if [[ -n "$custom_url" ]]; then
|
||||
DOWNLOAD_URL="$custom_url"
|
||||
echo "✅ 使用自定义下载地址: $DOWNLOAD_URL"
|
||||
else
|
||||
echo "✅ 使用默认下载地址: $DOWNLOAD_URL"
|
||||
fi
|
||||
|
||||
|
||||
# 检查并安装 tcpkill
|
||||
check_and_install_tcpkill
|
||||
|
||||
@@ -235,16 +230,7 @@ update_gost() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 询问是否有加速下载地址
|
||||
echo ""
|
||||
echo "📥 检查下载地址..."
|
||||
read -p "是否有加速下载地址?(留空使用默认地址): " custom_url
|
||||
if [[ -n "$custom_url" ]]; then
|
||||
DOWNLOAD_URL="$custom_url"
|
||||
echo "✅ 使用自定义下载地址: $DOWNLOAD_URL"
|
||||
else
|
||||
echo "✅ 使用默认下载地址: $DOWNLOAD_URL"
|
||||
fi
|
||||
echo "📥 使用下载地址: $DOWNLOAD_URL"
|
||||
|
||||
# 检查并安装 tcpkill
|
||||
check_and_install_tcpkill
|
||||
|
||||
@@ -12,6 +12,16 @@ DOCKER_COMPOSEV4_URL="https://raw.githubusercontent.com/bqlpfy/forward-panel/ref
|
||||
DOCKER_COMPOSEV6_URL="https://raw.githubusercontent.com/bqlpfy/forward-panel/refs/heads/main/docker-compose-v6.yml"
|
||||
GOST_SQL_URL="https://raw.githubusercontent.com/bqlpfy/forward-panel/refs/heads/main/gost.sql"
|
||||
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
||||
GOST_SQL_URL="https://ghfast.top/${GOST_SQL_URL}"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# 根据IPv6支持情况选择docker-compose URL
|
||||
get_docker_compose_url() {
|
||||
if check_ipv6_support > /dev/null 2>&1; then
|
||||
|
||||
@@ -27,7 +27,7 @@ public class JwtUtil {
|
||||
private static String SECRET_KEY;
|
||||
|
||||
// token有效期,7天
|
||||
private static final long EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000;
|
||||
private static final long EXPIRE_TIME = 90L * 24 * 60 * 60 * 1000;
|
||||
// 算法
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ public class FlowController extends BaseController {
|
||||
String name = buildServiceName(forwardId, userId, userTunnelId);
|
||||
if (!Objects.equals(userTunnelId, DEFAULT_USER_TUNNEL_ID)) { // 非管理员的转发需要检测流量限制
|
||||
checkUserRelatedLimits(userId, name);
|
||||
checkUserTunnelRelatedLimits(userTunnelId, name);
|
||||
checkUserTunnelRelatedLimits(userTunnelId, name, userId);
|
||||
}
|
||||
|
||||
return SUCCESS_RESPONSE;
|
||||
@@ -272,30 +272,30 @@ public class FlowController extends BaseController {
|
||||
pauseService(forwardList, name);
|
||||
}
|
||||
|
||||
public void checkUserTunnelRelatedLimits(String userTunnelId, String name) {
|
||||
public void checkUserTunnelRelatedLimits(String userTunnelId, String name, String userId) {
|
||||
|
||||
UserTunnel userTunnel = userTunnelService.getById(userTunnelId);
|
||||
if (userTunnel == null) return;
|
||||
long flow = userTunnel.getInFlow() + userTunnel.getOutFlow();
|
||||
if (flow >= userTunnel.getFlow() * BYTES_TO_GB) {
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name);
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userTunnel.getExpTime() != null && userTunnel.getExpTime() <= System.currentTimeMillis()) {
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name);
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userTunnel.getStatus() != 1) {
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name);
|
||||
pauseSpecificForward(userTunnel.getTunnelId(), name, userId);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private void pauseSpecificForward(Integer tunnelId, String name) {
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("tunnel_id", tunnelId));
|
||||
private void pauseSpecificForward(Integer tunnelId, String name, String userId) {
|
||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("tunnel_id", tunnelId).eq("user_id", userId));
|
||||
pauseService(forwardList, name);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,44 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>哆啦A梦</title>
|
||||
<script>
|
||||
// 防止暗黑模式闪烁:在页面加载前立即应用主题
|
||||
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
||||
(function() {
|
||||
const theme = localStorage.getItem('heroui-theme') || 'light';
|
||||
if (theme === 'dark') {
|
||||
// 立即检测系统主题并应用
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// 立即设置主题类,避免闪烁
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检测H5模式并设置标记,避免布局切换时的闪屏
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
if (isMobile || isMobileBrowser || isH5Param) {
|
||||
document.documentElement.classList.add('h5-mode');
|
||||
} else {
|
||||
document.documentElement.classList.add('pc-mode');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
@@ -32,19 +60,51 @@
|
||||
--toaster-border: #4b5563;
|
||||
}
|
||||
|
||||
/* 默认浅色主题样式 */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f8fafc;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 暗色主题样式 */
|
||||
html.dark body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* 防止加载时的闪烁 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 确保在主题切换时平滑过渡 */
|
||||
html {
|
||||
transition: color-scheme 0.2s ease;
|
||||
}
|
||||
|
||||
/* 防止布局切换时的闪屏 */
|
||||
.h5-mode #root {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.h5-mode.dark #root {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.pc-mode #root {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.pc-mode.dark #root {
|
||||
background-color: #000000;
|
||||
}
|
||||
</style>
|
||||
<meta key="title" content="哆啦A梦" property="og:title" />
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"@eslint/compat": "1.2.8",
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@eslint/js": "9.25.1",
|
||||
"@types/node": "20.5.7",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
@@ -83,7 +83,7 @@
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.2.0",
|
||||
"vite": "5.2",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import IndexPage from "@/pages/index";
|
||||
import ChangePasswordPage from "@/pages/change-password";
|
||||
@@ -8,15 +8,60 @@ import ForwardPage from "@/pages/forward";
|
||||
import TunnelPage from "@/pages/tunnel";
|
||||
import NodePage from "@/pages/node";
|
||||
import UserPage from "@/pages/user";
|
||||
import ProfilePage from "@/pages/profile";
|
||||
import LimitPage from "@/pages/limit";
|
||||
import ConfigPage from "@/pages/config";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import H5Layout from "@/layouts/h5";
|
||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { siteConfig } from "@/config/site";
|
||||
|
||||
// 检测是否为H5模式
|
||||
const useH5Mode = () => {
|
||||
// 立即检测H5模式,避免初始渲染时的闪屏
|
||||
const getInitialH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// 检测是否为移动端浏览器
|
||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
// 检测URL参数是否包含h5模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
return isMobile || isMobileBrowser || isH5Param;
|
||||
};
|
||||
|
||||
const [isH5, setIsH5] = useState(getInitialH5Mode);
|
||||
|
||||
useEffect(() => {
|
||||
const checkH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// 检测是否为移动端浏览器
|
||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
// 检测URL参数是否包含h5模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
setIsH5(isMobile || isMobileBrowser || isH5Param);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', checkH5Mode);
|
||||
|
||||
return () => window.removeEventListener('resize', checkH5Mode);
|
||||
}, []);
|
||||
|
||||
return isH5;
|
||||
};
|
||||
|
||||
// 简化的路由保护组件 - 使用浏览器重定向避免React循环
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const ProtectedRoute = ({ children, useSimpleLayout = false }: { children: React.ReactNode, useSimpleLayout?: boolean }) => {
|
||||
const authenticated = isLoggedIn();
|
||||
const isH5 = useH5Mode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
@@ -27,13 +72,23 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">重定向中...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
|
||||
<div className="text-lg text-gray-700 dark:text-gray-200"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
// 根据模式和页面类型选择布局
|
||||
let Layout;
|
||||
if (isH5 && useSimpleLayout) {
|
||||
Layout = H5SimpleLayout;
|
||||
} else if (isH5) {
|
||||
Layout = H5Layout;
|
||||
} else {
|
||||
Layout = AdminLayout;
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
|
||||
@@ -50,8 +105,8 @@ const LoginRoute = () => {
|
||||
|
||||
if (authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">重定向中...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
|
||||
<div className="text-lg text-gray-700 dark:text-gray-200"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,15 +185,23 @@ function App() {
|
||||
<Route
|
||||
path="/user"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<UserPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/limit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<LimitPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -146,11 +209,15 @@ function App() {
|
||||
<Route
|
||||
path="/config"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<ConfigPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
|
||||
// 配置axios基础URL
|
||||
const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/';
|
||||
axios.defaults.baseURL = baseURL;
|
||||
// 检测是否在WebView中运行
|
||||
const isWebView = (): boolean => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const result = (window as any).AndroidInterface.isWebView();
|
||||
return result === "true";
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取WebView中当前选中的面板地址
|
||||
const getWebViewPanelAddress = (): string | null => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const currentAddress = (window as any).AndroidInterface.getCurrentPanelAddress();
|
||||
// 确保返回的地址不为空且已选中
|
||||
if (currentAddress && currentAddress.trim()) {
|
||||
return currentAddress;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化时获取基础URL
|
||||
let baseURL: string = '';
|
||||
|
||||
// 重新初始化baseURL的函数
|
||||
export const reinitializeBaseURL = () => {
|
||||
if (isWebView()) {
|
||||
const panelAddress = getWebViewPanelAddress();
|
||||
if (panelAddress && panelAddress.trim()) {
|
||||
baseURL = `${panelAddress}/api/v1/`;
|
||||
axios.defaults.baseURL = baseURL;
|
||||
return true;
|
||||
} else {
|
||||
baseURL = '';
|
||||
axios.defaults.baseURL = '';
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// 非WebView环境使用默认配置
|
||||
baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/';
|
||||
axios.defaults.baseURL = baseURL;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始调用
|
||||
reinitializeBaseURL();
|
||||
|
||||
|
||||
|
||||
// 在开发环境下输出API配置信息
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -10,6 +65,10 @@ if (import.meta.env.DEV) {
|
||||
console.log(' - VITE_API_BASE:', import.meta.env.VITE_API_BASE || '(undefined)');
|
||||
console.log(' - Base URL:', baseURL);
|
||||
console.log(' - Environment:', import.meta.env.MODE);
|
||||
console.log(' - Is WebView:', isWebView());
|
||||
if (isWebView()) {
|
||||
console.log(' - WebView Panel Address:', getWebViewPanelAddress() || '(not set)');
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
@@ -42,6 +101,12 @@ function isTokenExpired(response: ApiResponse) {
|
||||
const Network = {
|
||||
get: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
|
||||
return new Promise(function(resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === '') {
|
||||
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T});
|
||||
return;
|
||||
}
|
||||
|
||||
axios.get(path, {
|
||||
params: data,
|
||||
timeout: 30000,
|
||||
@@ -73,6 +138,12 @@ const Network = {
|
||||
|
||||
post: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
|
||||
return new Promise(function(resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === '') {
|
||||
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T});
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post(path, data, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
|
||||
@@ -5,14 +5,35 @@ import {
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
} from "@heroui/navbar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ThemeSwitch } from "@/components/theme-switch";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
|
||||
export const Navbar = () => {
|
||||
const navigate = useNavigate();
|
||||
// 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁
|
||||
const [appName, setAppName] = useState(siteConfig.name);
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
// 检测是否在WebView中运行
|
||||
useEffect(() => {
|
||||
const detectWebView = () => {
|
||||
try {
|
||||
// 尝试调用Android注册的JavaScript接口
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const result = (window as any).AndroidInterface.isWebView();
|
||||
setIsWebView(result === "true");
|
||||
} else {
|
||||
setIsWebView(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsWebView(false);
|
||||
}
|
||||
};
|
||||
|
||||
detectWebView();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 异步检查是否有更新的配置
|
||||
@@ -54,23 +75,37 @@ export const Navbar = () => {
|
||||
}, [appName]);
|
||||
|
||||
return (
|
||||
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0">
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
|
||||
<NavbarBrand className="gap-2 max-w-fit">
|
||||
<Link
|
||||
className="flex justify-start items-center gap-2 max-w-[200px] sm:max-w-none"
|
||||
color="foreground"
|
||||
href="/"
|
||||
>
|
||||
<Logo size={24} />
|
||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||
</Link>
|
||||
</NavbarBrand>
|
||||
</NavbarContent>
|
||||
<>
|
||||
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0">
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
|
||||
<NavbarBrand className="gap-2 max-w-fit">
|
||||
<Link
|
||||
className="flex justify-start items-center gap-2 max-w-[200px] sm:max-w-none"
|
||||
color="foreground"
|
||||
href="/"
|
||||
>
|
||||
<Logo size={24} />
|
||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||
</Link>
|
||||
</NavbarBrand>
|
||||
</NavbarContent>
|
||||
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="end">
|
||||
<ThemeSwitch />
|
||||
</NavbarContent>
|
||||
</HeroUINavbar>
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="end">
|
||||
{/* WebView设置图标 */}
|
||||
{isWebView && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="面板设置"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</NavbarContent>
|
||||
</HeroUINavbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,8 +31,8 @@ export default function PageWrapper({
|
||||
<div className="container mx-auto max-w-7xl px-3 lg:px-6 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
<span className="text-default-600">正在加载页面...</span>
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full"></div>
|
||||
<span className="text-default-600"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,18 +18,26 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
}
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('heroui-theme', currentTheme);
|
||||
};
|
||||
|
||||
// 初始化时检查localStorage
|
||||
const savedTheme = localStorage.getItem('heroui-theme');
|
||||
if (savedTheme && savedTheme !== theme) {
|
||||
setTheme(savedTheme);
|
||||
// 始终跟随系统主题
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (systemTheme !== theme) {
|
||||
setTheme(systemTheme);
|
||||
}
|
||||
|
||||
// 监听主题变化
|
||||
updateThemeClass(theme);
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleThemeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleThemeChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleThemeChange);
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { FC, useState, useEffect } from "react";
|
||||
import { VisuallyHidden } from "@react-aria/visually-hidden";
|
||||
import { SwitchProps, useSwitch } from "@heroui/switch";
|
||||
import clsx from "clsx";
|
||||
import { useTheme } from "@heroui/use-theme";
|
||||
|
||||
import { SunFilledIcon, MoonFilledIcon } from "@/components/icons";
|
||||
|
||||
export interface ThemeSwitchProps {
|
||||
className?: string;
|
||||
classNames?: SwitchProps["classNames"];
|
||||
}
|
||||
|
||||
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
className,
|
||||
classNames,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const {
|
||||
Component,
|
||||
slots,
|
||||
isSelected,
|
||||
getBaseProps,
|
||||
getInputProps,
|
||||
getWrapperProps,
|
||||
} = useSwitch({
|
||||
isSelected: theme === "light",
|
||||
onChange: () => setTheme(theme === "light" ? "dark" : "light"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, [isMounted]);
|
||||
|
||||
// Prevent Hydration Mismatch
|
||||
if (!isMounted) return <div className="w-6 h-6" />;
|
||||
|
||||
return (
|
||||
<Component
|
||||
aria-label={isSelected ? "Switch to dark mode" : "Switch to light mode"}
|
||||
{...getBaseProps({
|
||||
className: clsx(
|
||||
"px-px transition-opacity hover:opacity-80 cursor-pointer",
|
||||
className,
|
||||
classNames?.base,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
{...getWrapperProps()}
|
||||
className={slots.wrapper({
|
||||
class: clsx(
|
||||
[
|
||||
"w-auto h-auto",
|
||||
"bg-transparent",
|
||||
"rounded-lg",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[selected=true]:bg-transparent",
|
||||
"!text-default-500",
|
||||
"pt-px",
|
||||
"px-0",
|
||||
"mx-0",
|
||||
],
|
||||
classNames?.wrapper,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{isSelected ? (
|
||||
<MoonFilledIcon size={22} />
|
||||
) : (
|
||||
<SunFilledIcon size={22} />
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "1.2.10";
|
||||
const VERSION = "1.2.11";
|
||||
|
||||
|
||||
const getInitialConfig = () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Input } from "@heroui/input";
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
import { Logo } from '@/components/icons';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
import { updatePassword } from '@/api';
|
||||
import { safeLogout } from '@/utils/logout';
|
||||
import { siteConfig } from '@/config/site';
|
||||
@@ -243,7 +242,7 @@ export default function AdminLayout({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${isMobile ? 'min-h-screen' : 'h-screen'} bg-gray-50 dark:bg-black`}>
|
||||
<div className={`flex ${isMobile ? 'min-h-screen' : 'h-screen'} bg-gray-100 dark:bg-black`}>
|
||||
{/* 移动端遮罩层 */}
|
||||
{isMobile && mobileMenuVisible && (
|
||||
<div
|
||||
@@ -345,9 +344,7 @@ export default function AdminLayout({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSwitch />
|
||||
|
||||
{/* 用户菜单 */}
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button variant="light" className="text-sm font-medium text-foreground">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from "@heroui/button";
|
||||
|
||||
import { Logo } from '@/components/icons';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
export default function H5SimpleLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/profile');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-black">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={handleBack}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Logo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">{siteConfig.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-100 dark:bg-black">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Logo } from '@/components/icons';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
interface TabItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function H5Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Tabbar配置
|
||||
const tabItems: TabItem[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: '首页',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/forward',
|
||||
label: '转发',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/tunnel',
|
||||
label: '隧道',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
path: '/node',
|
||||
label: '节点',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
adminOnly: true
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
label: '我的',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
|
||||
let adminFlag = localStorage.getItem('admin') === 'true';
|
||||
if (localStorage.getItem('admin') === null) {
|
||||
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10);
|
||||
adminFlag = roleId === 0;
|
||||
// 补充设置admin字段,避免下次再次判断
|
||||
localStorage.setItem('admin', adminFlag.toString());
|
||||
}
|
||||
|
||||
|
||||
setIsAdmin(adminFlag);
|
||||
}, []);
|
||||
|
||||
// Tab点击处理
|
||||
const handleTabClick = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
// 过滤tab项(根据权限)
|
||||
const filteredTabItems = tabItems.filter(item =>
|
||||
!item.adminOnly || isAdmin
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-black">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">{siteConfig.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-100 dark:bg-black">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* 底部Tabbar */}
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 flex items-center justify-around px-2 relative z-10">
|
||||
{filteredTabItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => handleTabClick(item.path)}
|
||||
className={`
|
||||
flex flex-col items-center justify-center flex-1 h-full
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${isActive
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0 mb-1">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,22 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import { Provider } from "./provider.tsx";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<HashRouter>
|
||||
<Provider>
|
||||
<App />
|
||||
</Provider>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
*/
|
||||
@@ -9,7 +9,7 @@ import { Select, SelectItem } from "@heroui/select";
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateConfigs } from '@/api';
|
||||
import { SettingsIcon } from '@/components/icons';
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import { isAdmin } from '@/utils/auth';
|
||||
import { getCachedConfigs, clearConfigCache, updateSiteConfig } from '@/config/site';
|
||||
|
||||
@@ -323,16 +323,16 @@ export default function ConfigPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner size="lg" label="加载配置中..." />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -422,6 +422,6 @@ export default function ConfigPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useState, useEffect } from "react";
|
||||
import toast from 'react-hot-toast';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import { getUserPackageInfo } from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
@@ -574,21 +574,21 @@ export default function DashboardPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full"></div>
|
||||
<span className="text-default-600">正在加载数据...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
|
||||
{/* 响应式统计卡片 */}
|
||||
@@ -950,6 +950,6 @@ export default function DashboardPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import {
|
||||
createForward,
|
||||
getForwardList,
|
||||
@@ -1338,26 +1338,25 @@ export default function ForwardPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
const userGroups = groupForwardsByUserAndTunnel();
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 显示模式切换按钮 */}
|
||||
@@ -2144,6 +2143,6 @@ export default function ForwardPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
import axios from 'axios';
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
@@ -103,12 +104,12 @@ export default function IndexPage() {
|
||||
tacInstanceRef.current = null;
|
||||
}
|
||||
|
||||
// 获取API基础URL
|
||||
const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1` : '/api/v1';
|
||||
// 使用axios的baseURL,确保在WebView中使用正确的面板地址
|
||||
const baseURL = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||
|
||||
const config: CaptchaConfig = {
|
||||
requestCaptchaDataUrl: `${baseURL}/captcha/generate`,
|
||||
validCaptchaUrl: `${baseURL}/captcha/verify`,
|
||||
requestCaptchaDataUrl: `${baseURL}captcha/generate`,
|
||||
validCaptchaUrl: `${baseURL}captcha/verify`,
|
||||
bindEl: "#captcha-container",
|
||||
validSuccess: (res: any, _: any, tac: any) => {
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
@@ -209,34 +209,34 @@ export default function LimitPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">限速管理</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
新增规则
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
@@ -466,6 +466,6 @@ export default function LimitPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import toast from 'react-hot-toast';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import {
|
||||
createNode,
|
||||
getNodeList,
|
||||
@@ -120,9 +121,9 @@ export default function NodePage() {
|
||||
closeWebSocket();
|
||||
}
|
||||
|
||||
// 构建WebSocket URL,使用与API相同的地址逻辑
|
||||
let baseUrl = import.meta.env.VITE_API_BASE || window.location.origin;
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
||||
// 构建WebSocket URL,使用axios的baseURL
|
||||
const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/api\/v1\/$/, '') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
||||
|
||||
try {
|
||||
websocketRef.current = new WebSocket(wsUrl);
|
||||
@@ -546,43 +547,23 @@ export default function NodePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
新增节点
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
</div>
|
||||
|
||||
{/* WebSocket支持说明 */}
|
||||
<div className="mb-6 p-4 bg-warning-50 dark:bg-warning-100/10 border border-warning-200 dark:border-warning-300/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-warning-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
<p className="text-sm text-warning-700 dark:text-warning-300">
|
||||
节点实时监控依赖WebSocket连接。如果面板开启了<strong>反向代理</strong>或使用了<strong>CDN</strong>,
|
||||
请确保已开启WebSocket支持,否则节点状态可能不显示节点信息。
|
||||
具体配置方法请自行搜索相关教程或询问AI助手。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
@@ -954,6 +935,6 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal";
|
||||
import { Input } from "@heroui/input";
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { updatePassword } from '@/api';
|
||||
import { safeLogout } from '@/utils/logout';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
interface PasswordForm {
|
||||
newUsername: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [username, setUsername] = useState('');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 获取用户信息
|
||||
const name = localStorage.getItem('name') || 'Admin';
|
||||
|
||||
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
|
||||
let adminFlag = localStorage.getItem('admin') === 'true';
|
||||
if (localStorage.getItem('admin') === null) {
|
||||
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10);
|
||||
adminFlag = roleId === 0;
|
||||
// 补充设置admin字段,避免下次再次判断
|
||||
localStorage.setItem('admin', adminFlag.toString());
|
||||
}
|
||||
|
||||
setUsername(name);
|
||||
setIsAdmin(adminFlag);
|
||||
}, []);
|
||||
|
||||
// 管理员菜单项
|
||||
const adminMenuItems: MenuItem[] = [
|
||||
{
|
||||
path: '/limit',
|
||||
label: '限速管理',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400',
|
||||
description: '管理用户限速策略'
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
label: '用户管理',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
description: '管理系统用户'
|
||||
},
|
||||
{
|
||||
path: '/config',
|
||||
label: '网站配置',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400',
|
||||
description: '配置网站设置'
|
||||
}
|
||||
];
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
safeLogout();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
// 密码表单验证
|
||||
const validatePasswordForm = (): boolean => {
|
||||
if (!passwordForm.newUsername.trim()) {
|
||||
toast.error('请输入新用户名');
|
||||
return false;
|
||||
}
|
||||
if (passwordForm.newUsername.length < 3) {
|
||||
toast.error('用户名长度至少3位');
|
||||
return false;
|
||||
}
|
||||
if (!passwordForm.currentPassword) {
|
||||
toast.error('请输入当前密码');
|
||||
return false;
|
||||
}
|
||||
if (!passwordForm.newPassword) {
|
||||
toast.error('请输入新密码');
|
||||
return false;
|
||||
}
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
toast.error('新密码长度不能少于6位');
|
||||
return false;
|
||||
}
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
toast.error('两次输入密码不一致');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 提交密码修改
|
||||
const handlePasswordSubmit = async () => {
|
||||
if (!validatePasswordForm()) return;
|
||||
|
||||
setPasswordLoading(true);
|
||||
try {
|
||||
const response = await updatePassword(passwordForm);
|
||||
if (response.code === 0) {
|
||||
toast.success('密码修改成功,请重新登录');
|
||||
onOpenChange();
|
||||
handleLogout();
|
||||
} else {
|
||||
toast.error(response.msg || '密码修改失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('修改密码时发生错误');
|
||||
console.error('修改密码错误:', error);
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置密码表单
|
||||
const resetPasswordForm = () => {
|
||||
setPasswordForm({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8 flex flex-col h-full">
|
||||
|
||||
<div className="space-y-6 flex-1">
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-foreground">{username}</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
isAdmin
|
||||
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
|
||||
: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{isAdmin ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
<span className="text-xs text-default-500">
|
||||
{new Date().toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 功能网格 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* 管理员功能 */}
|
||||
{isAdmin && adminMenuItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className={`w-10 h-10 ${item.color} rounded-full flex items-center justify-center mb-2`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-full flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">修改密码</span>
|
||||
</button>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-full flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="text-center py-4 mt-auto">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{' '}
|
||||
<a
|
||||
href="https://github.com/bqlpfy/forward-panel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
哆啦A梦
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={() => {
|
||||
onOpenChange();
|
||||
resetPasswordForm();
|
||||
}}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose: () => void) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">修改密码</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="新用户名"
|
||||
placeholder="请输入新用户名(至少3位)"
|
||||
value={passwordForm.newUsername}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))}
|
||||
variant="bordered"
|
||||
/>
|
||||
<Input
|
||||
label="当前密码"
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||
variant="bordered"
|
||||
/>
|
||||
<Input
|
||||
label="新密码"
|
||||
type="password"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||
variant="bordered"
|
||||
/>
|
||||
<Input
|
||||
label="确认密码"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
variant="bordered"
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="default" variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handlePasswordSubmit}
|
||||
isLoading={passwordLoading}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { reinitializeBaseURL } from '@/api/network';
|
||||
|
||||
interface PanelAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||
const [currentAddress, setCurrentAddress] = useState<string>('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newAddress, setNewAddress] = useState('');
|
||||
|
||||
// 验证面板地址格式
|
||||
const validatePanelAddress = (address: string): boolean => {
|
||||
try {
|
||||
// 基本格式检查:必须以 http:// 或 https:// 开头
|
||||
if (!address.startsWith('http://') && !address.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用URL构造函数验证完整URL格式
|
||||
const url = new URL(address);
|
||||
|
||||
// 检查主机名不能为空
|
||||
if (!url.hostname || url.hostname.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查主机名
|
||||
const hostname = url.hostname;
|
||||
|
||||
// 支持 localhost
|
||||
if (hostname === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 支持 IPv4 地址
|
||||
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (ipv4Pattern.test(hostname)) {
|
||||
const parts = hostname.split('.');
|
||||
return parts.every(part => {
|
||||
const num = parseInt(part);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
// 支持 IPv6 地址
|
||||
const ipv6Pattern = /^\[([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$|^\[([0-9a-fA-F]{1,4}:)*:([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}\]$/;
|
||||
if (ipv6Pattern.test(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 支持域名
|
||||
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
|
||||
if (domainPattern.test(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// URL构造函数失败说明格式不正确
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载面板地址列表
|
||||
const loadPanelAddresses = () => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const addressesStr = (window as any).AndroidInterface.getPanelAddresses();
|
||||
const currentAddr = (window as any).AndroidInterface.getCurrentPanelAddress();
|
||||
|
||||
if (addressesStr && addressesStr.trim()) {
|
||||
const addresses = addressesStr.split(',').filter((item: string) => item.trim()).map((item: string) => {
|
||||
const [name, address] = item.split('|');
|
||||
return { name: name || '未命名', address: address || '' };
|
||||
});
|
||||
setPanelAddresses(addresses);
|
||||
} else {
|
||||
// 如果没有数据或数据为空,清空列表
|
||||
setPanelAddresses([]);
|
||||
}
|
||||
|
||||
setCurrentAddress(currentAddr || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载面板地址失败:', error);
|
||||
setPanelAddresses([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新面板地址
|
||||
const addPanelAddress = () => {
|
||||
if (!newName.trim() || !newAddress.trim()) {
|
||||
toast.error('请输入名称和地址');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证地址格式
|
||||
if (!validatePanelAddress(newAddress.trim())) {
|
||||
toast.error('地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
(window as any).AndroidInterface.savePanelAddress(newName.trim(), newAddress.trim());
|
||||
toast.success('添加成功');
|
||||
setNewName('');
|
||||
setNewAddress('');
|
||||
loadPanelAddresses();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 设置当前面板地址
|
||||
const setCurrentPanel = (address: string) => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
(window as any).AndroidInterface.setCurrentPanelAddress(address);
|
||||
setCurrentAddress(address);
|
||||
reinitializeBaseURL();
|
||||
toast.success('设置成功,API地址已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除面板地址
|
||||
const deletePanelAddress = (name: string, address: string) => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
(window as any).AndroidInterface.deletePanelAddress(name, address);
|
||||
|
||||
// 如果删除的是当前选中的地址,重新初始化baseURL
|
||||
if (currentAddress === address) {
|
||||
setCurrentAddress('');
|
||||
reinitializeBaseURL();
|
||||
toast.success('删除成功,当前面板地址已清空');
|
||||
} else {
|
||||
toast.success('删除成功');
|
||||
}
|
||||
|
||||
loadPanelAddresses();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载时获取数据
|
||||
useEffect(() => {
|
||||
loadPanelAddresses();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* 顶部导航 */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">面板设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="space-y-6">
|
||||
{/* 添加新地址 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">添加新面板地址</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="名称"
|
||||
placeholder="请输入面板名称"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="地址"
|
||||
placeholder="http://192.168.1.100:3000"
|
||||
value={newAddress}
|
||||
onChange={(e) => setNewAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button color="primary" onClick={addPanelAddress}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 地址列表 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">已保存的面板地址</h2>
|
||||
{panelAddresses.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">暂无保存的面板地址</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{panelAddresses.map((panel, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{panel.name}</span>
|
||||
{currentAddress === panel.address && (
|
||||
<span className="px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded">
|
||||
当前
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{panel.address}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentAddress !== panel.address && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onClick={() => setCurrentPanel(panel.address)}
|
||||
>
|
||||
设为当前
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="light"
|
||||
onClick={() => deletePanelAddress(panel.name, panel.address)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { Divider } from "@heroui/divider";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
@@ -412,34 +412,35 @@ export default function TunnelPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">隧道管理</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
新增隧道
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
@@ -1039,6 +1040,6 @@ export default function TunnelPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -48,9 +48,9 @@ import {
|
||||
getSpeedLimitList,
|
||||
resetUserFlow
|
||||
} from '@/api';
|
||||
import { SearchIcon, PlusIcon, EditIcon, DeleteIcon, UserIcon, SettingsIcon } from '@/components/icons';
|
||||
import { SearchIcon, EditIcon, DeleteIcon, UserIcon, SettingsIcon } from '@/components/icons';
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = 'bytes'): string => {
|
||||
@@ -527,13 +527,11 @@ export default function UserPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserIcon className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
||||
@@ -563,13 +561,13 @@ export default function UserPage() {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<PlusIcon className="w-4 h-4" />}
|
||||
onClick={handleAdd}
|
||||
className="min-h-8"
|
||||
>
|
||||
新增用户
|
||||
</Button>
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1436,6 +1434,6 @@ export default function UserPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -3,22 +3,5 @@
|
||||
* 清除登录相关数据,但保留用户偏好设置(如主题)
|
||||
*/
|
||||
export const safeLogout = () => {
|
||||
// 需要保留的用户偏好设置
|
||||
const preservedSettings = {
|
||||
theme: localStorage.getItem('heroui-theme'),
|
||||
// 可以在这里添加其他需要保留的设置
|
||||
};
|
||||
|
||||
// 清除所有localStorage数据
|
||||
localStorage.clear();
|
||||
|
||||
// 恢复保留的设置
|
||||
Object.entries(preservedSettings).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
if (key === 'theme') {
|
||||
localStorage.setItem('heroui-theme', value);
|
||||
}
|
||||
// 可以在这里添加其他设置的恢复逻辑
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import legacy from "@vitejs/plugin-legacy";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
legacy({
|
||||
targets: ['defaults', 'not IE 11']
|
||||
}),
|
||||
react()
|
||||
],
|
||||
base: './',
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
@@ -15,11 +21,12 @@ export default defineConfig({
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: ['es2015', 'chrome63'],
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
minify: false, // 先关闭压缩,排查是否压缩导致
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
treeshake: false, // 关闭 treeshake(摇树优化),排查是否优化导致
|
||||
treeshake: false,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||