适配ios端

This commit is contained in:
qaq
2025-08-21 15:40:35 +08:00
parent 821ab5c76b
commit 6c4f9cd24c
15 changed files with 256 additions and 216 deletions
+1
View File
@@ -255,3 +255,4 @@ gitee/
*.crt
*.cert
android-app/
ios-app/
+1 -1
View File
@@ -1 +1 @@
VITE_API_BASE=http://127.0.0.1:6365
VITE_API_BASE=http://192.168.100.9:6365
+20
View File
@@ -67,6 +67,9 @@
background-color: #ffffff;
color: #000000;
transition: background-color 0.2s ease, color 0.2s ease;
/* 禁用弹性滚动 */
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* 暗色主题样式 */
@@ -78,6 +81,8 @@
#root {
min-height: 100vh;
background-color: inherit;
/* 确保根元素也禁用弹性滚动 */
overscroll-behavior: none;
}
/* 防止加载时的闪烁 */
@@ -88,6 +93,8 @@
/* 确保在主题切换时平滑过渡 */
html {
transition: color-scheme 0.2s ease;
/* 禁用整个页面的弹性滚动 */
overscroll-behavior: none;
}
/* 防止布局切换时的闪屏 */
@@ -106,6 +113,19 @@
.pc-mode.dark #root {
background-color: #000000;
}
/* 移动端滚动优化 */
@media (max-width: 768px) {
html, body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* 确保固定定位的元素不受滚动影响 */
.fixed {
overscroll-behavior: none;
}
}
</style>
<meta key="title" content="flux-panel" property="og:title" />
<meta
+2 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "tsc && vite build && node toFile.mjs",
"lint": "eslint --fix",
"preview": "vite preview"
},
@@ -69,6 +69,7 @@
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@vitejs/plugin-legacy": "^5.4.3",
"@vitejs/plugin-react": "4.4.1",
"eslint": "9.25.1",
"eslint-config-prettier": "9.1.0",
+7 -2
View File
@@ -59,7 +59,7 @@ const useH5Mode = () => {
};
// 简化的路由保护组件 - 使用 React Router 导航避免循环
const ProtectedRoute = ({ children, useSimpleLayout = false }: { children: React.ReactNode, useSimpleLayout?: boolean }) => {
const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false }: { children: React.ReactNode, useSimpleLayout?: boolean, skipLayout?: boolean }) => {
const authenticated = isLoggedIn();
const isH5 = useH5Mode();
const navigate = useNavigate();
@@ -79,6 +79,11 @@ const ProtectedRoute = ({ children, useSimpleLayout = false }: { children: React
);
}
// 如果跳过布局,直接返回子组件
if (skipLayout) {
return <>{children}</>;
}
// 根据模式和页面类型选择布局
let Layout;
if (isH5 && useSimpleLayout) {
@@ -147,7 +152,7 @@ function App() {
<Route
path="/change-password"
element={
<ProtectedRoute>
<ProtectedRoute skipLayout={true}>
<ChangePasswordPage />
</ProtectedRoute>
}
+24 -50
View File
@@ -1,76 +1,50 @@
import axios, { AxiosResponse } from 'axios';
import { getPanelAddresses} from '@/utils/panel';
// 检测是否在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) {
if((window as any).JsInterface !== undefined) {
return true;
}else if((window as any).webkit && (window as any).webkit.messageHandlers) {
return true;
}else {
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;
interface PanelAddress {
name: string;
address: string;
inx: boolean;
}
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
newAddress.forEach(item => {
if (item.inx) {
baseURL = `${item.address}/api/v1/`;
axios.defaults.baseURL = baseURL;
}
return null;
} catch (error) {
return null;
}
});
}
function getWebViewPanelAddress() {
(window as any).setAddresses = setPanelAddressesFunc
getPanelAddresses("setAddresses");
};
// 初始化时获取基础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;
}
getWebViewPanelAddress();
} 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) {
console.log('🌐 API Configuration:');
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> {
code: number;
msg: string;
+8 -15
View File
@@ -18,21 +18,14 @@ export const Navbar = () => {
// 检测是否在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();
if((window as any).JsInterface !== undefined) {
setIsWebView(true);
}else if((window as any).webkit && (window as any).webkit.messageHandlers) {
setIsWebView(true);
}else {
setIsWebView(false);
}
}, []);
useEffect(() => {
+5 -2
View File
@@ -4,14 +4,15 @@ export type SiteConfig = typeof siteConfig;
// 缓存相关常量
const CACHE_PREFIX = 'vite_config_';
const VERSION = "1.3.0";
const VERSION = "1.3.1";
const APP_VERSION = "1.0.1";
const getInitialConfig = () => {
if (typeof window === 'undefined') {
return {
name: "flux-panel",
version: VERSION,
app_version: APP_VERSION,
};
}
@@ -20,11 +21,13 @@ const getInitialConfig = () => {
return {
name: cachedAppName,
version: VERSION,
app_version: APP_VERSION,
};
}
return {
name: "flux-panel",
version: VERSION,
app_version: APP_VERSION,
};
};
+37 -3
View File
@@ -6,6 +6,7 @@ import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast';
import axios from 'axios';
import { siteConfig } from '@/config/site';
import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha } from "@/api";
@@ -52,7 +53,7 @@ export default function IndexPage() {
const navigate = useNavigate();
const tacInstanceRef = useRef<any>(null);
const captchaContainerRef = useRef<HTMLDivElement>(null);
const [isWebView, setIsWebView] = useState(false);
// 清理验证码实例
useEffect(() => {
return () => {
@@ -62,7 +63,16 @@ export default function IndexPage() {
}
};
}, []);
// 检测是否在WebView中运行
useEffect(() => {
if((window as any).JsInterface !== undefined) {
setIsWebView(true);
}else if((window as any).webkit && (window as any).webkit.messageHandlers) {
setIsWebView(true);
}else {
setIsWebView(false);
}
}, []);
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -249,7 +259,7 @@ export default function IndexPage() {
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 min-h-[calc(100vh-120px)] sm:min-h-[calc(100vh-200px)]">
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100vh-120px)] sm:min-h-[calc(100vh-200px)]">
<div className="w-full max-w-md px-4 sm:px-0">
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
@@ -298,6 +308,30 @@ export default function IndexPage() {
</Card>
</div>
{/* 版权信息 - 固定在底部,不占据布局空间 */}
{isWebView && (
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-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"
>
flux-panel
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{siteConfig.app_version}
</p>
</div>
)}
{/* 验证码弹层 */}
{showCaptcha && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
-18
View File
@@ -8,7 +8,6 @@ import { useNavigate } from 'react-router-dom';
import { updatePassword } from '@/api';
import { safeLogout } from '@/utils/logout';
import { siteConfig } from '@/config/site';
interface PasswordForm {
newUsername: string;
@@ -240,23 +239,6 @@ export default function ProfilePage() {
</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/flux-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"
>
flux-panel
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{siteConfig.version}
</p>
</div>
{/* 修改密码弹窗 */}
<Modal
+35 -123
View File
@@ -5,100 +5,40 @@ import { Card, CardBody } from "@heroui/card";
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { reinitializeBaseURL } from '@/api/network';
import {
getPanelAddresses,
savePanelAddress,
setCurrentPanelAddress,
deletePanelAddress,
validatePanelAddress,
} from '@/utils/panel';
interface PanelAddress {
name: string;
address: string;
address: string;
inx: boolean;
}
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 setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
}
// 加载面板地址列表
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 loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc
getPanelAddresses();
};
// 添加新面板地址
const addPanelAddress = () => {
const addPanelAddress = async () => {
if (!newName.trim() || !newAddress.trim()) {
toast.error('请输入名称和地址');
return;
@@ -109,54 +49,26 @@ export const SettingsPage = () => {
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('添加失败' + error);
}
(window as any).setPanelAddresses = setPanelAddressesFunc
savePanelAddress(newName.trim(), newAddress.trim());
setNewName('');
setNewAddress('');
toast.success('添加成功');
};
// 设置当前面板地址
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 setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc
setCurrentPanelAddress(name);
reinitializeBaseURL();
};
// 删除面板地址
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('删除失败');
}
const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc
deletePanelAddress(name);
reinitializeBaseURL();
toast.success('删除成功');
};
// 页面加载时获取数据
@@ -228,7 +140,7 @@ export const SettingsPage = () => {
<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 && (
{panel.inx && (
<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>
@@ -237,12 +149,12 @@ export const SettingsPage = () => {
<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 && (
{!panel.inx && (
<Button
size="sm"
color="primary"
variant="flat"
onClick={() => setCurrentPanel(panel.address)}
onClick={() => setCurrentPanel(panel.name)}
>
设为当前
</Button>
@@ -251,7 +163,7 @@ export const SettingsPage = () => {
size="sm"
color="danger"
variant="light"
onClick={() => deletePanelAddress(panel.name, panel.address)}
onClick={() => handleDeletePanelAddress(panel.name)}
>
删除
</Button>
+3
View File
@@ -793,6 +793,9 @@ export default function TunnelPage() {
<SelectItem key="tls">TLS</SelectItem>
<SelectItem key="wss">WSS</SelectItem>
<SelectItem key="tcp">TCP</SelectItem>
<SelectItem key="mtls">MTLS</SelectItem>
<SelectItem key="mwss">MWSS</SelectItem>
<SelectItem key="mtcp">MTCP</SelectItem>
</Select>
<Select
+93
View File
@@ -0,0 +1,93 @@
// 获取面板地址列表
export async function getPanelAddresses(callback: string = "setPanelAddresses"){
if ((window as any).JsInterface && (window as any).JsInterface.getPanelAddresses) {
(window as any).JsInterface.getPanelAddresses(callback);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.getPanelAddresses.postMessage(callback);
}
}
// 保存面板地址
export async function savePanelAddress(name: string, address: string){
if ((window as any).JsInterface) {
(window as any).JsInterface.savePanelAddress(name, address);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.savePanelAddress.postMessage({ name, address });
}
}
// 设置当前面板地址
export async function setCurrentPanelAddress(name: string) {
if ((window as any).JsInterface) {
(window as any).JsInterface.setCurrentPanelAddress(name);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.setCurrentPanelAddress.postMessage({ name });
}
}
// 删除面板地址
export async function deletePanelAddress(name: string){
if ((window as any).JsInterface) {
(window as any).JsInterface.deletePanelAddress(name);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.deletePanelAddress.postMessage({ name });
}
}
// 验证面板地址格式
export function 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;
}
}
+15
View File
@@ -0,0 +1,15 @@
import fs from 'fs';
console.time('转换耗时');
const distPath = './dist/index.html';
let htmlText = fs.readFileSync(distPath, 'utf8');
let resultText = '';
let htmlArr = htmlText.match(/.*\n/g) || [];
htmlArr.forEach(str => {
str = str.replace(/\s?nomodule\s?/g,' ');
str = str.replace(/\s?crossorigin\s?/g,' ');
str = str.replace(/data-src/g,'src');
if(!/type="module"/i.test(str)) resultText += str;
});
fs.writeFileSync(distPath,resultText,'utf8');
console.timeEnd('转换耗时');
+5 -1
View File
@@ -1,10 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import legacy from '@vitejs/plugin-legacy'
export default defineConfig({
plugins: [
react()
react(),
legacy({
targets: ['defaults', 'not IE 11']
})
],
base: './',
resolve: {