mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-01-13 19:50:28 +00:00
feat: WebView optimization
This commit is contained in:
parent
eb42c7101d
commit
dd762d53b2
@ -27,9 +27,6 @@ class RustWebViewController {
|
||||
/// 本地化脚本(从 assets 加载)
|
||||
String _localizationScript = "";
|
||||
|
||||
/// 请求拦截器脚本
|
||||
String _requestInterceptorScript = "";
|
||||
|
||||
/// 当前 URL
|
||||
String _currentUrl = "";
|
||||
String get currentUrl => _currentUrl;
|
||||
@ -76,7 +73,6 @@ class RustWebViewController {
|
||||
Future<void> _loadScripts() async {
|
||||
try {
|
||||
_localizationScript = await rootBundle.loadString('assets/web_script.js');
|
||||
_requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js');
|
||||
} catch (e) {
|
||||
dPrint("Failed to load scripts: $e");
|
||||
}
|
||||
@ -120,10 +116,6 @@ class RustWebViewController {
|
||||
case rust_webview.WebViewEvent_NavigationCompleted(:final url):
|
||||
dPrint("Navigation completed: $url");
|
||||
_currentUrl = url;
|
||||
// 注入请求拦截器
|
||||
if (_requestInterceptorScript.isNotEmpty) {
|
||||
executeScript(_requestInterceptorScript);
|
||||
}
|
||||
// 导航完成回调(用于注入脚本)
|
||||
for (final callback in _navigationCompletedCallbacks) {
|
||||
callback(url);
|
||||
|
||||
@ -36,7 +36,6 @@ class WebViewModel {
|
||||
final localizationResource = <String, dynamic>{};
|
||||
|
||||
var localizationScript = "";
|
||||
var requestInterceptorScript = "";
|
||||
|
||||
bool enableCapture = false;
|
||||
|
||||
@ -75,7 +74,6 @@ class WebViewModel {
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
|
||||
);
|
||||
|
||||
webview.addOnNavigationCallback(_onNavigation);
|
||||
// 添加导航完成回调(用于注入脚本)
|
||||
webview.addOnNavigationCompletedCallback(_onNavigationCompleted);
|
||||
|
||||
@ -208,7 +206,6 @@ class WebViewModel {
|
||||
|
||||
Future<void> initLocalization(AppWebLocalizationVersionsData v) async {
|
||||
localizationScript = await rootBundle.loadString('assets/web_script.js');
|
||||
requestInterceptorScript = await rootBundle.loadString('assets/request_interceptor.js');
|
||||
|
||||
/// https://github.com/CxJuice/Uex_Chinese_Translate
|
||||
// get versions
|
||||
@ -282,19 +279,10 @@ class WebViewModel {
|
||||
|
||||
FutureOr<void> dispose() {
|
||||
webview.removeOnNavigationCompletedCallback(_onNavigationCompleted);
|
||||
webview.removeOnNavigationCallback(_onNavigation);
|
||||
if (loginMode && !_loginModeSuccess) {
|
||||
loginCallback?.call(null, false);
|
||||
}
|
||||
_isClosed = true;
|
||||
webview.dispose();
|
||||
}
|
||||
|
||||
void _onNavigation(String url) {
|
||||
// 在页面加载时注入拦截器
|
||||
if (requestInterceptorScript.isNotEmpty) {
|
||||
dPrint("Injecting request interceptor for: $url");
|
||||
webview.executeScript(requestInterceptorScript);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ use std::sync::atomic::Ordering;
|
||||
use flutter_rust_bridge::frb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::webview::{WEBVIEW_INSTANCES, send_command};
|
||||
use crate::webview::{send_command, WEBVIEW_INSTANCES};
|
||||
|
||||
// ============ Data Structures ============
|
||||
|
||||
@ -84,6 +84,7 @@ pub enum WebViewCommand {
|
||||
Close,
|
||||
SetWindowSize(u32, u32),
|
||||
SetWindowPosition(i32, i32),
|
||||
SetWindowTitle(String),
|
||||
}
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
/// ------- Request Interceptor Script --------------
|
||||
/// 轻量级网络请求拦截器,不破坏网页正常功能
|
||||
(function() {
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
||||
if (window._sctRequestInterceptorInstalled) {
|
||||
console.log('[SCToolbox] Request interceptor already installed');
|
||||
return;
|
||||
}
|
||||
window._sctRequestInterceptorInstalled = true;
|
||||
|
||||
|
||||
// 被屏蔽的域名和路径
|
||||
const blockedPatterns = [
|
||||
'google-analytics.com',
|
||||
@ -28,24 +28,24 @@
|
||||
'facebook.net',
|
||||
'gstatic.com/firebasejs'
|
||||
];
|
||||
|
||||
|
||||
// 判断 URL 是否应该被屏蔽
|
||||
const shouldBlock = (url) => {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
const urlLower = url.toLowerCase();
|
||||
return blockedPatterns.some(pattern => urlLower.includes(pattern.toLowerCase()));
|
||||
};
|
||||
|
||||
|
||||
// 记录被拦截的请求
|
||||
const logBlocked = (type, url) => {
|
||||
console.log(`[SCToolbox] ❌ Blocked ${type}:`, url);
|
||||
};
|
||||
|
||||
|
||||
const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
|
||||
|
||||
// ============ 1. 拦截 Fetch API ============
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
window.fetch = function (...args) {
|
||||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
|
||||
if (shouldBlock(url)) {
|
||||
logBlocked('fetch', url);
|
||||
@ -53,13 +53,13 @@
|
||||
}
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
|
||||
// ============ 2. 拦截 XMLHttpRequest ============
|
||||
const OriginalXHR = window.XMLHttpRequest;
|
||||
const originalXHROpen = OriginalXHR.prototype.open;
|
||||
const originalXHRSend = OriginalXHR.prototype.send;
|
||||
|
||||
OriginalXHR.prototype.open = function(method, url, ...rest) {
|
||||
|
||||
OriginalXHR.prototype.open = function (method, url, ...rest) {
|
||||
this._url = url;
|
||||
if (shouldBlock(url)) {
|
||||
logBlocked('XHR', url);
|
||||
@ -67,8 +67,8 @@
|
||||
}
|
||||
return originalXHROpen.apply(this, [method, url, ...rest]);
|
||||
};
|
||||
|
||||
OriginalXHR.prototype.send = function(...args) {
|
||||
|
||||
OriginalXHR.prototype.send = function (...args) {
|
||||
if (this._blocked) {
|
||||
setTimeout(() => {
|
||||
const errorEvent = new Event('error');
|
||||
@ -78,13 +78,13 @@
|
||||
}
|
||||
return originalXHRSend.apply(this, args);
|
||||
};
|
||||
|
||||
|
||||
// ============ 3. 拦截 Image 元素的 src 属性 ============
|
||||
const imgSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');
|
||||
if (imgSrcDescriptor && imgSrcDescriptor.set) {
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
||||
get: imgSrcDescriptor.get,
|
||||
set: function(value) {
|
||||
set: function (value) {
|
||||
if (shouldBlock(value)) {
|
||||
logBlocked('IMG.src', value);
|
||||
// 设置为透明 GIF,避免请求
|
||||
@ -98,13 +98,13 @@
|
||||
enumerable: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ============ 3.5. 拦截 Script 元素的 src 属性 ============
|
||||
const scriptSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src');
|
||||
if (scriptSrcDescriptor && scriptSrcDescriptor.set) {
|
||||
Object.defineProperty(HTMLScriptElement.prototype, 'src', {
|
||||
get: scriptSrcDescriptor.get,
|
||||
set: function(value) {
|
||||
set: function (value) {
|
||||
if (shouldBlock(value)) {
|
||||
logBlocked('SCRIPT.src', value);
|
||||
// 阻止加载,不设置 src
|
||||
@ -117,10 +117,10 @@
|
||||
enumerable: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ============ 4. 拦截 setAttribute(用于 img.setAttribute('src', ...))============
|
||||
const originalSetAttribute = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function(name, value) {
|
||||
Element.prototype.setAttribute = function (name, value) {
|
||||
if (name.toLowerCase() === 'src' && this.tagName === 'IMG' && shouldBlock(value)) {
|
||||
logBlocked('IMG setAttribute', value);
|
||||
originalSetAttribute.call(this, name, TRANSPARENT_GIF);
|
||||
@ -133,11 +133,11 @@
|
||||
}
|
||||
return originalSetAttribute.call(this, name, value);
|
||||
};
|
||||
|
||||
|
||||
// ============ 5. 拦截 navigator.sendBeacon ============
|
||||
if (navigator.sendBeacon) {
|
||||
const originalSendBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(url, data) {
|
||||
navigator.sendBeacon = function (url, data) {
|
||||
if (shouldBlock(url)) {
|
||||
logBlocked('sendBeacon', url);
|
||||
return true; // 假装成功
|
||||
@ -145,13 +145,13 @@
|
||||
return originalSendBeacon(url, data);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============ 6. 使用 MutationObserver 监听动态添加的元素 ============
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== 1) return; // 只处理元素节点
|
||||
|
||||
|
||||
try {
|
||||
// 检查 IMG 元素
|
||||
if (node.tagName === 'IMG') {
|
||||
@ -180,7 +180,7 @@
|
||||
node.style.cssText += 'display:none !important;';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 递归检查子元素
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('img').forEach(img => {
|
||||
@ -191,7 +191,7 @@
|
||||
img.style.cssText += 'display:none !important;width:0;height:0;';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
node.querySelectorAll('script[src]').forEach(script => {
|
||||
const src = script.getAttribute('src');
|
||||
if (src && shouldBlock(src)) {
|
||||
@ -207,7 +207,7 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// 延迟启动 observer,等待页面初始化完成
|
||||
const startObserver = () => {
|
||||
if (document.body) {
|
||||
@ -220,13 +220,13 @@
|
||||
setTimeout(startObserver, 50);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startObserver);
|
||||
} else {
|
||||
startObserver();
|
||||
}
|
||||
|
||||
|
||||
console.log('[SCToolbox] ✅ Request interceptor installed');
|
||||
console.log('[SCToolbox] 🛡️ Blocking', blockedPatterns.length, 'patterns');
|
||||
})();
|
||||
@ -22,12 +22,26 @@ use crate::api::webview_api::{
|
||||
WebViewCommand, WebViewConfiguration, WebViewEvent, WebViewNavigationState,
|
||||
};
|
||||
|
||||
// ============ Loading Progress Animation ============
|
||||
|
||||
/// Braille spinner characters for loading animation
|
||||
/// These are standard Unicode characters that work across all platforms
|
||||
const SPINNER_CHARS: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
/// Get the progress animation character for the current frame
|
||||
fn get_progress_char(frame: usize) -> char {
|
||||
SPINNER_CHARS[frame % SPINNER_CHARS.len()]
|
||||
}
|
||||
|
||||
// Embed the app icon at compile time
|
||||
static APP_ICON_DATA: &[u8] = include_bytes!("../../../windows/runner/resources/app_icon.ico");
|
||||
|
||||
// Embed the init script at compile time
|
||||
static INIT_SCRIPT: &str = include_str!("webview_init_script.js");
|
||||
|
||||
// Embed the request interceptor script at compile time
|
||||
static REQUEST_INTERCEPTOR_SCRIPT: &str = include_str!("request_interceptor.js");
|
||||
|
||||
// Loading page HTML for about:blank initialization
|
||||
static LOADING_PAGE_HTML: &str = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
@ -286,10 +300,14 @@ fn run_webview_loop(
|
||||
// When CF challenge is detected, we set this to 1, skip next load, then resume normal injection
|
||||
let cf_skip_count = Arc::new(AtomicU32::new(0));
|
||||
|
||||
// Store original title for restoration after loading
|
||||
let original_title = Arc::new(RwLock::new(config.title.clone()));
|
||||
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let nav_history_ipc = Arc::clone(&nav_history);
|
||||
let state_ipc = Arc::clone(&state);
|
||||
let cf_skip_count_ipc = Arc::clone(&cf_skip_count);
|
||||
let original_title_ipc = Arc::clone(&original_title);
|
||||
|
||||
// Create web context with custom data directory if provided
|
||||
let mut web_context = config
|
||||
@ -302,7 +320,8 @@ fn run_webview_loop(
|
||||
.with_url("about:blank")
|
||||
.with_devtools(config.enable_devtools)
|
||||
.with_transparent(config.transparent)
|
||||
.with_background_color((10, 29, 41, 255));
|
||||
.with_background_color((10, 29, 41, 255))
|
||||
.with_initialization_script(REQUEST_INTERCEPTOR_SCRIPT);
|
||||
|
||||
// Set user agent if provided
|
||||
if let Some(ref user_agent) = config.user_agent {
|
||||
@ -362,6 +381,36 @@ fn run_webview_loop(
|
||||
let _ = proxy_ipc.send_event(UserEvent::Command(WebViewCommand::Reload));
|
||||
return;
|
||||
}
|
||||
"loading_progress" => {
|
||||
// Update window title with loading progress
|
||||
if let Some(payload) = parsed.get("payload") {
|
||||
let progress = payload.get("progress").and_then(|v| v.as_f64());
|
||||
let is_loading = payload.get("is_loading").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let dot_frame = payload.get("dot_frame").and_then(|v| v.as_i64()).unwrap_or(0) as usize;
|
||||
|
||||
let base_title = original_title_ipc.read().clone();
|
||||
|
||||
let new_title = if is_loading {
|
||||
// Get Windows progress animation character
|
||||
let progress_char = get_progress_char(dot_frame);
|
||||
|
||||
if let Some(p) = progress {
|
||||
let percent = (p * 100.0).round() as i32;
|
||||
format!("{} {} {}%", base_title, progress_char, percent)
|
||||
} else {
|
||||
format!("{} {} Loading", base_title, progress_char)
|
||||
}
|
||||
} else {
|
||||
// Loading complete, restore original title
|
||||
base_title
|
||||
};
|
||||
|
||||
let _ = proxy_ipc.send_event(UserEvent::Command(
|
||||
WebViewCommand::SetWindowTitle(new_title)
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@ -375,6 +424,7 @@ fn run_webview_loop(
|
||||
let state = Arc::clone(&state);
|
||||
let nav_history = Arc::clone(&nav_history);
|
||||
let proxy = proxy.clone();
|
||||
let original_title = Arc::clone(&original_title);
|
||||
move |uri| {
|
||||
// Block navigation to about:blank (except initial load)
|
||||
// This prevents users from going back to the blank initial page
|
||||
@ -408,6 +458,17 @@ fn run_webview_loop(
|
||||
state_guard.can_go_forward = can_forward;
|
||||
}
|
||||
|
||||
// Update window title immediately to show loading state
|
||||
// This provides instant feedback before JS is injected
|
||||
{
|
||||
let base_title = original_title.read().clone();
|
||||
let progress_char = get_progress_char(0);
|
||||
let loading_title = format!("{} {} 0%", base_title, progress_char);
|
||||
let _ = proxy.send_event(UserEvent::Command(
|
||||
WebViewCommand::SetWindowTitle(loading_title)
|
||||
));
|
||||
}
|
||||
|
||||
// Send loading state to JS immediately when navigation starts
|
||||
// This makes the spinner appear on the current page before navigating away
|
||||
let state_json = serde_json::json!({
|
||||
@ -436,8 +497,15 @@ fn run_webview_loop(
|
||||
if url == "about:blank" {
|
||||
if matches!(event, PageLoadEvent::Finished) {
|
||||
// if url is about:blank, show loading
|
||||
// Add URL check in JS to prevent injection if page has already navigated away
|
||||
let loading_script = format!(
|
||||
r#"document.open(); document.write({}); document.close();"#,
|
||||
r#"(function() {{
|
||||
if (window.location.href === 'about:blank') {{
|
||||
document.open();
|
||||
document.write({});
|
||||
document.close();
|
||||
}}
|
||||
}})();"#,
|
||||
serde_json::to_string(LOADING_PAGE_HTML).unwrap_or_default()
|
||||
);
|
||||
let _ = proxy.send_event(UserEvent::Command(
|
||||
@ -728,6 +796,9 @@ fn handle_command(
|
||||
WebViewCommand::SetWindowPosition(x, y) => {
|
||||
window.set_outer_position(LogicalPosition::new(x, y));
|
||||
}
|
||||
WebViewCommand::SetWindowTitle(title) => {
|
||||
window.set_title(&title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,144 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Loading Progress Tracker ==========
|
||||
// Tracks page loading progress and updates window title via IPC
|
||||
// Uses Braille spinner characters: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ (10 frames)
|
||||
(function initProgressTracker() {
|
||||
var dotFrame = 0;
|
||||
var progressInterval = null;
|
||||
var estimatedProgress = 0;
|
||||
var isPageLoading = true;
|
||||
var lastProgressSent = -1;
|
||||
|
||||
// Send progress update to Rust (for window title)
|
||||
function sendProgressUpdate(progress, loading) {
|
||||
// Always send update for animation frames
|
||||
lastProgressSent = (loading ? 1 : 0) * 1000 + Math.round((progress || 0) * 100);
|
||||
|
||||
sendToRust('loading_progress', {
|
||||
progress: progress,
|
||||
is_loading: loading,
|
||||
dot_frame: dotFrame
|
||||
});
|
||||
}
|
||||
|
||||
// Start the dot animation and progress estimation
|
||||
function startProgressAnimation() {
|
||||
if (progressInterval) return;
|
||||
isPageLoading = true;
|
||||
estimatedProgress = 0;
|
||||
dotFrame = 0;
|
||||
|
||||
// Animation interval: update every 80ms for smooth Braille spinner animation
|
||||
// 10 frames at 80ms = 800ms per full cycle
|
||||
progressInterval = setInterval(function () {
|
||||
if (!isPageLoading) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment frame counter (will be modulo'd by Rust to 10 frames)
|
||||
dotFrame = dotFrame + 1;
|
||||
|
||||
// Estimate progress based on document state
|
||||
var progress = estimateProgress();
|
||||
sendProgressUpdate(progress, true);
|
||||
}, 80);
|
||||
|
||||
// Send initial progress
|
||||
sendProgressUpdate(0, true);
|
||||
}
|
||||
|
||||
// Estimate loading progress based on various signals
|
||||
function estimateProgress() {
|
||||
// Start with a base progress based on document ready state
|
||||
var baseProgress = 0;
|
||||
switch (document.readyState) {
|
||||
case 'loading':
|
||||
baseProgress = 0.15;
|
||||
break;
|
||||
case 'interactive':
|
||||
baseProgress = 0.6;
|
||||
break;
|
||||
case 'complete':
|
||||
baseProgress = 1.0;
|
||||
break;
|
||||
}
|
||||
|
||||
// Use Performance API if available for more accurate estimation
|
||||
if (window.performance && window.performance.timing) {
|
||||
var timing = window.performance.timing;
|
||||
var now = Date.now();
|
||||
|
||||
if (timing.loadEventEnd > 0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
if (timing.navigationStart > 0) {
|
||||
// Estimate based on time elapsed (assuming 3-5 second load)
|
||||
var elapsed = now - timing.navigationStart;
|
||||
var timeProgress = Math.min(elapsed / 4000, 0.95);
|
||||
baseProgress = Math.max(baseProgress, timeProgress);
|
||||
}
|
||||
}
|
||||
|
||||
// Use PerformanceObserver data if available
|
||||
if (window.performance && window.performance.getEntriesByType) {
|
||||
var resources = window.performance.getEntriesByType('resource');
|
||||
if (resources.length > 0) {
|
||||
// Count completed resources vs estimated total
|
||||
var completedCount = resources.filter(function (r) {
|
||||
return r.responseEnd > 0;
|
||||
}).length;
|
||||
// Assume average page has ~30 resources
|
||||
var resourceProgress = Math.min(completedCount / 30, 0.9);
|
||||
baseProgress = Math.max(baseProgress, resourceProgress * 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth progress (never go backwards, approach target gradually)
|
||||
if (baseProgress > estimatedProgress) {
|
||||
estimatedProgress = estimatedProgress + (baseProgress - estimatedProgress) * 0.3;
|
||||
}
|
||||
|
||||
return Math.min(estimatedProgress, 0.99);
|
||||
}
|
||||
|
||||
// Stop progress tracking and notify completion
|
||||
function stopProgressAnimation() {
|
||||
isPageLoading = false;
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
// Send final update with 100% and loading=false
|
||||
sendProgressUpdate(1.0, false);
|
||||
}
|
||||
|
||||
// Start tracking when page starts loading
|
||||
startProgressAnimation();
|
||||
|
||||
// Update on ready state changes
|
||||
document.addEventListener('readystatechange', function () {
|
||||
if (document.readyState === 'complete') {
|
||||
setTimeout(stopProgressAnimation, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: stop on window load
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(stopProgressAnimation, 200);
|
||||
});
|
||||
|
||||
// Expose for external control
|
||||
window._sctProgressTracker = {
|
||||
start: startProgressAnimation,
|
||||
stop: stopProgressAnimation
|
||||
};
|
||||
})();
|
||||
|
||||
// ========== 导航栏 UI ==========
|
||||
const icons = {
|
||||
back: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user