mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
feat: 新增局域网浏览器访问
This commit is contained in:
Generated
+123
-5
@@ -814,14 +814,38 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -837,13 +861,24 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
@@ -880,6 +915,37 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -1532,6 +1598,17 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getset"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2379,6 +2456,17 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "local-ip-address"
|
||||
version = "0.6.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"neli",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2582,6 +2670,35 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neli"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"derive_builder",
|
||||
"getset",
|
||||
"libc",
|
||||
"log",
|
||||
"neli-proc-macros",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neli-proc-macros"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
|
||||
dependencies = [
|
||||
"either",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -3868,7 +3985,7 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
@@ -4222,6 +4339,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"hex",
|
||||
"libc",
|
||||
"local-ip-address",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
@@ -4437,7 +4555,7 @@ version = "3.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
|
||||
@@ -41,6 +41,7 @@ parking_lot = { version = "0.12", features = ["send_guard"] }
|
||||
dirs = "6.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
urlencoding = "2.1"
|
||||
local-ip-address = "0.6.13"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,7 @@ pub fn run() {
|
||||
fs_file_exists,
|
||||
fs_open_path,
|
||||
http_server_start,
|
||||
http_server_refresh_token,
|
||||
http_server_stop,
|
||||
http_server_status,
|
||||
mcp_server_start,
|
||||
@@ -174,6 +175,41 @@ pub fn setup_app(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
setup_deep_link(app)?;
|
||||
|
||||
setup_lan_http_server(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_lan_http_server(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let handle = app.handle().clone();
|
||||
let state = handle.state::<crate::state::SafeAppState>().inner().clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let enabled = {
|
||||
let state_guard = state.read();
|
||||
let db_conn = state_guard.db.read().clone();
|
||||
let mut settings = state_guard.settings.write();
|
||||
settings.attach_db(db_conn);
|
||||
if settings.initialize().await.is_err() {
|
||||
false
|
||||
} else {
|
||||
matches!(
|
||||
settings.get_value(SettingsKey::LanAccessEnabled),
|
||||
SettingsValue::Boolean(true)
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if enabled {
|
||||
if let Err(error) =
|
||||
crate::commands::http_server_start_from_settings(handle.clone(), state.clone())
|
||||
.await
|
||||
{
|
||||
eprintln!("Failed to start LAN server from settings: {}", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct SettingsSpec {
|
||||
pub pg_connection_string: String,
|
||||
pub pg_connection_status: JsonValue,
|
||||
pub mobile_bottom_nav_items: JsonValue,
|
||||
pub lan_access_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsSpec {
|
||||
@@ -51,6 +52,7 @@ impl Default for SettingsSpec {
|
||||
"reasons",
|
||||
"settings"
|
||||
]),
|
||||
lan_access_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +74,7 @@ pub enum SettingsKey {
|
||||
PgConnectionString,
|
||||
PgConnectionStatus,
|
||||
MobileBottomNavItems,
|
||||
LanAccessEnabled,
|
||||
}
|
||||
|
||||
impl SettingsKey {
|
||||
@@ -92,6 +95,7 @@ impl SettingsKey {
|
||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
||||
SettingsKey::MobileBottomNavItems => "mobile_bottom_nav_items",
|
||||
SettingsKey::LanAccessEnabled => "lan_access_enabled",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +116,7 @@ impl SettingsKey {
|
||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
||||
"mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems),
|
||||
"lan_access_enabled" => Some(SettingsKey::LanAccessEnabled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -475,6 +480,16 @@ impl SettingsService {
|
||||
},
|
||||
);
|
||||
|
||||
defs.insert(
|
||||
SettingsKey::LanAccessEnabled,
|
||||
SettingDefinition {
|
||||
kind: SettingValueKind::Boolean,
|
||||
default_value: SettingsValue::Boolean(false),
|
||||
write_permission: PermissionRequirement::Admin,
|
||||
validate: None,
|
||||
},
|
||||
);
|
||||
|
||||
defs
|
||||
}
|
||||
|
||||
@@ -636,6 +651,10 @@ impl SettingsService {
|
||||
"settings"
|
||||
]),
|
||||
},
|
||||
lan_access_enabled: match self.get_value(SettingsKey::LanAccessEnabled) {
|
||||
SettingsValue::Boolean(b) => b,
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Layout, Space, Button, Tag, Spin, Avatar, Popover, Progress } from "antd"
|
||||
import { Layout, Space, Button, Tag, Spin, Avatar, Popover, Progress, Input, QRCode } from "antd"
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LeftOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
CopyOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -84,6 +86,13 @@ interface HeaderStorageUsage {
|
||||
file_count: number
|
||||
}
|
||||
|
||||
interface LanShareUrl {
|
||||
ip: string
|
||||
url: string
|
||||
is_private?: boolean
|
||||
is_192_168?: boolean
|
||||
}
|
||||
|
||||
export function ContentArea({
|
||||
permission,
|
||||
oauthUserName,
|
||||
@@ -107,12 +116,13 @@ export function ContentArea({
|
||||
bottomInset = 0,
|
||||
}: ContentAreaProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const isLanBrowser = Boolean((window as any).__SECSCORE_LAN__)
|
||||
const isMacOS =
|
||||
typeof navigator !== "undefined" &&
|
||||
/mac/i.test(navigator.userAgent) &&
|
||||
!/iphone|ipad|ipod|android/i.test(navigator.userAgent)
|
||||
// macOS 使用原生红绿灯,侧栏隐藏(沉浸/竖屏)时顶部左侧需为红绿灯留白
|
||||
const macTrafficLightsInset = isMacOS && (isPortraitMode || immersiveMode)
|
||||
const macTrafficLightsInset = !isLanBrowser && isMacOS && (isPortraitMode || immersiveMode)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
|
||||
@@ -200,7 +210,21 @@ export function ContentArea({
|
||||
})
|
||||
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||
const [logoutLoading, setLogoutLoading] = useState(false)
|
||||
const [lanPopoverOpen, setLanPopoverOpen] = useState(false)
|
||||
const [lanLoading, setLanLoading] = useState(false)
|
||||
const [lanCopied, setLanCopied] = useState(false)
|
||||
const [selectedLanShareUrl, setSelectedLanShareUrl] = useState<string | null>(null)
|
||||
const [lanStatus, setLanStatus] = useState<{
|
||||
is_running: boolean
|
||||
url?: string | null
|
||||
api_url?: string | null
|
||||
share_url?: string | null
|
||||
share_urls?: LanShareUrl[]
|
||||
token?: string | null
|
||||
config?: any
|
||||
} | null>(null)
|
||||
const copyResetTimerRef = useRef<number | null>(null)
|
||||
const lanCopyResetTimerRef = useRef<number | null>(null)
|
||||
const hasOAuthSession = Boolean(oauthUserName && oauthUserName.trim())
|
||||
const formattedLastSyncTime = (() => {
|
||||
if (!lastSyncTime) return "暂无"
|
||||
@@ -290,6 +314,10 @@ export function ContentArea({
|
||||
window.clearTimeout(copyResetTimerRef.current)
|
||||
copyResetTimerRef.current = null
|
||||
}
|
||||
if (lanCopyResetTimerRef.current) {
|
||||
window.clearTimeout(lanCopyResetTimerRef.current)
|
||||
lanCopyResetTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -329,6 +357,127 @@ export function ContentArea({
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeLanStatus = (data: any) => {
|
||||
if (!data) return null
|
||||
const shareUrls: LanShareUrl[] = Array.isArray(data.share_urls || data.shareUrls)
|
||||
? (data.share_urls || data.shareUrls)
|
||||
.map((item: any) => ({
|
||||
ip: String(item.ip || ""),
|
||||
url: String(item.url || ""),
|
||||
is_private: Boolean(item.is_private ?? item.isPrivate),
|
||||
is_192_168: Boolean(item.is_192_168 ?? item.is192168),
|
||||
}))
|
||||
.filter((item: LanShareUrl) => item.ip && item.url)
|
||||
: []
|
||||
const primaryShareUrl = data.share_url || data.shareUrl || shareUrls[0]?.url || null
|
||||
return {
|
||||
is_running: Boolean(data.is_running ?? data.isRunning),
|
||||
url: data.url || null,
|
||||
api_url: data.api_url || data.apiUrl || null,
|
||||
share_url: primaryShareUrl,
|
||||
share_urls:
|
||||
shareUrls.length > 0
|
||||
? shareUrls
|
||||
: primaryShareUrl
|
||||
? [{ ip: "", url: primaryShareUrl }]
|
||||
: [],
|
||||
token: data.token || null,
|
||||
config: data.config,
|
||||
}
|
||||
}
|
||||
|
||||
const applyLanStatus = (nextStatus: ReturnType<typeof normalizeLanStatus>) => {
|
||||
setLanStatus(nextStatus)
|
||||
setSelectedLanShareUrl((prev) => {
|
||||
const urls = nextStatus?.share_urls || []
|
||||
if (prev && urls.some((item) => item.url === prev)) return prev
|
||||
return urls[0]?.url || nextStatus?.share_url || null
|
||||
})
|
||||
}
|
||||
|
||||
const refreshLanStatus = useCallback(async (refreshToken: boolean) => {
|
||||
const api = (window as any).api
|
||||
if (!api?.httpServerStatus) return
|
||||
setLanLoading(true)
|
||||
try {
|
||||
const statusRes = await api.httpServerStatus()
|
||||
const status = normalizeLanStatus(statusRes?.data)
|
||||
if (refreshToken && status?.is_running && api.httpServerRefreshToken) {
|
||||
const refreshRes = await api.httpServerRefreshToken()
|
||||
if (refreshRes?.success) {
|
||||
applyLanStatus(normalizeLanStatus({ ...refreshRes.data, is_running: true }))
|
||||
return
|
||||
}
|
||||
}
|
||||
applyLanStatus(status)
|
||||
} finally {
|
||||
setLanLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLanPopoverOpenChange = (open: boolean) => {
|
||||
setLanPopoverOpen(open)
|
||||
if (open) {
|
||||
void refreshLanStatus(true)
|
||||
}
|
||||
}
|
||||
|
||||
const startLanAccess = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.httpServerStart) return
|
||||
setLanLoading(true)
|
||||
try {
|
||||
const res = await api.httpServerStart({
|
||||
host: "0.0.0.0",
|
||||
port: 45739,
|
||||
api_port: 45740,
|
||||
})
|
||||
if (res?.success) {
|
||||
if (api.setSetting) {
|
||||
await api.setSetting("lan_access_enabled", true)
|
||||
}
|
||||
applyLanStatus(normalizeLanStatus({ ...res.data, is_running: true }))
|
||||
}
|
||||
} finally {
|
||||
setLanLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopLanAccess = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.httpServerStop) return
|
||||
setLanLoading(true)
|
||||
try {
|
||||
const res = await api.httpServerStop()
|
||||
if (res?.success) {
|
||||
if (api.setSetting) {
|
||||
await api.setSetting("lan_access_enabled", false)
|
||||
}
|
||||
setLanStatus((prev) => ({ ...(prev || { is_running: false }), is_running: false }))
|
||||
setSelectedLanShareUrl(null)
|
||||
}
|
||||
} finally {
|
||||
setLanLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLanLink = async (link = selectedLanShareUrl || lanStatus?.share_url || "") => {
|
||||
if (!link) return
|
||||
await navigator.clipboard.writeText(link)
|
||||
setLanCopied(true)
|
||||
if (lanCopyResetTimerRef.current) {
|
||||
window.clearTimeout(lanCopyResetTimerRef.current)
|
||||
}
|
||||
lanCopyResetTimerRef.current = window.setTimeout(() => {
|
||||
setLanCopied(false)
|
||||
lanCopyResetTimerRef.current = null
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const lanShareUrls = lanStatus?.share_urls || []
|
||||
const activeLanShareUrl =
|
||||
selectedLanShareUrl || lanStatus?.share_url || lanShareUrls[0]?.url || ""
|
||||
|
||||
const profilePopoverContent = (
|
||||
<div style={{ width: "260px", display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<div
|
||||
@@ -408,6 +557,134 @@ export function ContentArea({
|
||||
</div>
|
||||
)
|
||||
|
||||
const lanPopoverContent = (
|
||||
<div style={{ width: "300px", display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: "13px", fontWeight: 600, color: "var(--ss-text-main)" }}>
|
||||
局域网访问
|
||||
</span>
|
||||
<Tag color={lanStatus?.is_running ? "success" : "default"}>
|
||||
{lanStatus?.is_running ? "已启用" : "未启用"}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{!lanStatus?.is_running ? (
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<LinkOutlined />}
|
||||
loading={lanLoading}
|
||||
disabled={permission !== "admin"}
|
||||
onClick={startLanAccess}
|
||||
>
|
||||
启用局域网访问
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
readOnly
|
||||
value={activeLanShareUrl}
|
||||
suffix={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyLanLink()}
|
||||
disabled={!activeLanShareUrl}
|
||||
>
|
||||
{lanCopied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{activeLanShareUrl && (
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<QRCode value={activeLanShareUrl} size={180} />
|
||||
</div>
|
||||
)}
|
||||
{lanShareUrls.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{lanShareUrls.map((item, index) => {
|
||||
const selected = item.url === activeLanShareUrl
|
||||
return (
|
||||
<div
|
||||
key={`${item.ip}-${item.url}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto auto",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 8px",
|
||||
border: selected
|
||||
? "1px solid var(--ant-color-primary)"
|
||||
: "1px solid var(--ss-border-color)",
|
||||
borderRadius: "8px",
|
||||
background: selected
|
||||
? "color-mix(in srgb, var(--ant-color-primary) 8%, transparent)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
color: "var(--ss-text-main)",
|
||||
}}
|
||||
>
|
||||
{item.ip || `地址 ${index + 1}`}
|
||||
{index === 0 && (
|
||||
<Tag color="blue" style={{ marginLeft: "6px" }}>
|
||||
默认
|
||||
</Tag>
|
||||
)}
|
||||
{item.is_192_168 && (
|
||||
<Tag color="green" style={{ marginLeft: "4px" }}>
|
||||
192.168
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={item.url}
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{item.url}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" onClick={() => setSelectedLanShareUrl(item.url)}>
|
||||
二维码
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyLanLink(item.url)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
每次打开此弹窗都会刷新 token,之前复制的链接会失效。
|
||||
</div>
|
||||
<Space>
|
||||
<Button size="small" loading={lanLoading} onClick={() => refreshLanStatus(true)}>
|
||||
刷新链接
|
||||
</Button>
|
||||
<Button size="small" danger loading={lanLoading} onClick={stopLanAccess}>
|
||||
停用
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
@@ -549,7 +826,23 @@ export function ContentArea({
|
||||
}
|
||||
>
|
||||
<Space size="small">
|
||||
{(immersiveMode || (isHomePage && !isMobileDevice)) && (
|
||||
{!isLanBrowser && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={lanPopoverOpen}
|
||||
onOpenChange={handleLanPopoverOpenChange}
|
||||
content={lanPopoverContent}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
title="局域网访问"
|
||||
disabled={permission !== "admin"}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
{!isLanBrowser && (immersiveMode || (isHomePage && !isMobileDevice)) && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SettingOutlined />}
|
||||
|
||||
@@ -42,6 +42,7 @@ type appSettings = {
|
||||
search_keyboard_layout?: "t9" | "qwerty26"
|
||||
disable_search_keyboard?: boolean
|
||||
font_family?: string
|
||||
lan_access_enabled?: boolean
|
||||
}
|
||||
|
||||
interface FontOption {
|
||||
@@ -191,6 +192,7 @@ export const Settings: React.FC<{
|
||||
const [appQuitLoading, setAppQuitLoading] = useState(false)
|
||||
const [appRestartLoading, setAppRestartLoading] = useState(false)
|
||||
const [mcpLoading, setMcpLoading] = useState(false)
|
||||
const [lanAccessLoading, setLanAccessLoading] = useState(false)
|
||||
const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({
|
||||
host: "127.0.0.1",
|
||||
port: 3901,
|
||||
@@ -947,6 +949,47 @@ export const Settings: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLanAccess = async (checked: boolean) => {
|
||||
const api = (window as any).api
|
||||
if (!api?.setSetting) return
|
||||
setLanAccessLoading(true)
|
||||
try {
|
||||
if (checked) {
|
||||
const statusRes = api.httpServerStatus ? await api.httpServerStatus() : null
|
||||
const isRunning = Boolean(statusRes?.data?.is_running ?? statusRes?.data?.isRunning)
|
||||
if (!isRunning) {
|
||||
const startRes = await withTimeout(
|
||||
api.httpServerStart?.({ host: "0.0.0.0", port: 45739, api_port: 45740 }),
|
||||
10_000,
|
||||
"启动局域网访问超时"
|
||||
)
|
||||
if (!startRes?.success) {
|
||||
messageApi.error(startRes?.message || "启动局域网访问失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (api.httpServerStop) {
|
||||
const stopRes = await withTimeout(api.httpServerStop(), 10_000, "停止局域网访问超时")
|
||||
if (!stopRes?.success) {
|
||||
messageApi.error(stopRes?.message || "停止局域网访问失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const saveRes = await api.setSetting("lan_access_enabled", checked)
|
||||
if (saveRes?.success) {
|
||||
setSettings((prev) => ({ ...prev, lan_access_enabled: checked }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
} else {
|
||||
messageApi.error(saveRes?.message || t("settings.general.saveFailed"))
|
||||
}
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || "局域网访问设置失败")
|
||||
} finally {
|
||||
setLanAccessLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const confirmQuitApp = () => {
|
||||
@@ -1938,6 +1981,23 @@ export const Settings: React.FC<{
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>局域网访问</div>
|
||||
<div
|
||||
style={{ color: "var(--ss-text-secondary)", marginBottom: "12px", fontSize: "12px" }}
|
||||
>
|
||||
启用后会开放 45739 前端页面和 45740 API 服务,访问链接由主界面顶部链接按钮生成。
|
||||
</div>
|
||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
<Form.Item label="启用">
|
||||
<Switch
|
||||
checked={Boolean(settings.lan_access_enabled)}
|
||||
loading={lanAccessLoading}
|
||||
disabled={!canAdmin}
|
||||
onChange={toggleLanAccess}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Divider />
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.mcp.title")}
|
||||
</div>
|
||||
|
||||
+10
-1
@@ -8,8 +8,17 @@ import { ClientContext } from "./ClientContext"
|
||||
import { StudentService } from "./services/StudentService"
|
||||
import { ServiceProvider } from "./contexts/ServiceContext"
|
||||
import { api } from "./preload/types"
|
||||
import { lanApi } from "./services/lanApi"
|
||||
|
||||
if (!(window as any).api) {
|
||||
const hasTauriInvoke =
|
||||
typeof (window as any).__TAURI_INTERNALS__?.invoke === "function" ||
|
||||
typeof (window as any).__TAURI__?.core?.invoke === "function"
|
||||
|
||||
if (!hasTauriInvoke) {
|
||||
;(window as any).__SECSCORE_LAN__ = true
|
||||
;(window as any).api = lanApi
|
||||
} else if (!(window as any).api) {
|
||||
;(window as any).__SECSCORE_LAN__ = false
|
||||
;(window as any).api = api
|
||||
}
|
||||
|
||||
|
||||
+44
-3
@@ -96,6 +96,7 @@ export type settingsKey =
|
||||
| "pg_connection_string"
|
||||
| "pg_connection_status"
|
||||
| "mobile_bottom_nav_items"
|
||||
| "lan_access_enabled"
|
||||
|
||||
export interface settingsSpec {
|
||||
is_wizard_completed: boolean
|
||||
@@ -117,6 +118,7 @@ export interface settingsSpec {
|
||||
error?: string
|
||||
}
|
||||
mobile_bottom_nav_items: string[]
|
||||
lan_access_enabled: boolean
|
||||
}
|
||||
|
||||
export interface pluginRuntimeModule {
|
||||
@@ -130,6 +132,13 @@ export interface pluginRuntimeModule {
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface httpServerShareUrl {
|
||||
ip: string
|
||||
url: string
|
||||
is_private?: boolean
|
||||
is_192_168?: boolean
|
||||
}
|
||||
|
||||
const api = {
|
||||
// Theme
|
||||
getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"),
|
||||
@@ -723,13 +732,45 @@ const api = {
|
||||
httpServerStart: (config?: {
|
||||
port?: number
|
||||
host?: string
|
||||
api_port?: number
|
||||
corsOrigin?: string
|
||||
}): Promise<{ success: boolean; data: { url: string; config: any } }> =>
|
||||
invoke("http_server_start", { config }),
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
url: string
|
||||
api_url?: string
|
||||
share_url?: string
|
||||
share_urls?: httpServerShareUrl[]
|
||||
token?: string
|
||||
config: any
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("http_server_start", { config }),
|
||||
httpServerRefreshToken: (): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
url: string
|
||||
api_url?: string
|
||||
share_url?: string
|
||||
share_urls?: httpServerShareUrl[]
|
||||
token?: string
|
||||
config: any
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("http_server_refresh_token"),
|
||||
httpServerStop: (): Promise<{ success: boolean }> => invoke("http_server_stop"),
|
||||
httpServerStatus: (): Promise<{
|
||||
success: boolean
|
||||
data: { isRunning: boolean; config?: any; url?: string }
|
||||
data: {
|
||||
is_running?: boolean
|
||||
isRunning?: boolean
|
||||
config?: any
|
||||
url?: string
|
||||
api_url?: string
|
||||
share_url?: string
|
||||
share_urls?: httpServerShareUrl[]
|
||||
token?: string
|
||||
}
|
||||
}> => invoke("http_server_status"),
|
||||
|
||||
// MCP Server
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
const buildLanApiBase = () => {
|
||||
const { protocol, hostname } = window.location
|
||||
const apiProtocol = protocol === "https:" ? "https:" : "http:"
|
||||
return `${apiProtocol}//${hostname}:45740`
|
||||
}
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const res = await fetch(`${buildLanApiBase()}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
res.status === 401 ? "局域网访问链接已失效,请重新扫码或复制最新链接" : res.statusText,
|
||||
} as T
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
const defaultTheme = {
|
||||
id: "light-default",
|
||||
name: "Default",
|
||||
mode: "light" as const,
|
||||
config: {
|
||||
tdesign: {
|
||||
brandColor: "#1677ff",
|
||||
warningColor: "#faad14",
|
||||
errorColor: "#ff4d4f",
|
||||
successColor: "#52c41a",
|
||||
},
|
||||
custom: {
|
||||
"--ss-bg-color": "#f5f7fb",
|
||||
"--ss-header-bg": "#ffffff",
|
||||
"--ss-card-bg": "#ffffff",
|
||||
"--ss-border-color": "#e7e7e7",
|
||||
"--ss-text-main": "#1f2329",
|
||||
"--ss-text-secondary": "#6b7280",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const noopUnlisten = async () => () => void 0
|
||||
|
||||
const lanApiBase = {
|
||||
getThemes: async () => ({ success: true, data: [defaultTheme] }),
|
||||
getCurrentTheme: async () => ({ success: true, data: defaultTheme }),
|
||||
setTheme: async () => ({ success: true }),
|
||||
saveTheme: async () => ({ success: false, message: "LAN 模式不支持主题管理" }),
|
||||
deleteTheme: async () => ({ success: false, message: "LAN 模式不支持主题管理" }),
|
||||
onThemeChanged: noopUnlisten,
|
||||
|
||||
queryStudents: async () =>
|
||||
request<{ success: boolean; data: any[]; message?: string }>("/api/students"),
|
||||
queryReasons: async () =>
|
||||
request<{ success: boolean; data: any[]; message?: string }>("/api/reasons"),
|
||||
updateStudent: async () => ({ success: false, message: "LAN 模式不支持修改学生信息" }),
|
||||
rewardSettingQuery: async () =>
|
||||
request<{ success: boolean; data: any[]; message?: string }>("/api/rewards"),
|
||||
queryEvents: async (params?: { limit?: number }) => {
|
||||
const limit = Number(params?.limit || 100)
|
||||
return request<{ success: boolean; data: any[]; message?: string }>(
|
||||
`/api/events?limit=${limit}`
|
||||
)
|
||||
},
|
||||
createEvent: async (data: any) =>
|
||||
request<{ success: boolean; data?: number; message?: string }>("/api/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteEvent: async (uuid: string) =>
|
||||
request<{ success: boolean; message?: string }>(`/api/events/${encodeURIComponent(uuid)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
rewardRedeem: async () => ({ success: false, message: "LAN 模式仅支持加分和扣分" }),
|
||||
|
||||
getAllSettings: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
is_wizard_completed: true,
|
||||
log_level: "info",
|
||||
window_zoom: 1,
|
||||
search_keyboard_layout: "qwerty26",
|
||||
disable_search_keyboard: false,
|
||||
font_family: "system",
|
||||
mobile_bottom_nav_items: ["home"],
|
||||
lan_access_enabled: true,
|
||||
},
|
||||
}),
|
||||
getSetting: async () => ({ success: false, message: "LAN 模式不支持设置" }),
|
||||
setSetting: async () => ({ success: false, message: "LAN 模式不支持设置" }),
|
||||
getSystemFonts: async () => ({ success: true, data: [] }),
|
||||
|
||||
dbGetStatus: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
connected: true,
|
||||
type: "sqlite",
|
||||
},
|
||||
}),
|
||||
dbSync: async () => ({ success: true }),
|
||||
dbSyncPreview: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
can_sync: false,
|
||||
need_sync: false,
|
||||
local_only: 0,
|
||||
remote_only: 0,
|
||||
conflicts: [],
|
||||
message: "LAN 模式不支持数据库同步",
|
||||
},
|
||||
}),
|
||||
dbSyncApply: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
success: false,
|
||||
synced_records: 0,
|
||||
resolved_conflicts: 0,
|
||||
message: "LAN 模式不支持数据库同步",
|
||||
},
|
||||
}),
|
||||
|
||||
authGetStatus: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
permission: "points",
|
||||
hasAdminPassword: false,
|
||||
hasPointsPassword: false,
|
||||
hasRecoveryString: false,
|
||||
},
|
||||
}),
|
||||
authLogin: async () => ({ success: true, data: { permission: "points" } }),
|
||||
authLogout: async () => ({ success: true, data: { permission: "points" } }),
|
||||
authSetPasswords: async () => ({ success: false, message: "LAN 模式不支持权限设置" }),
|
||||
authGenerateRecovery: async () => ({ success: false, message: "LAN 模式不支持权限设置" }),
|
||||
authResetByRecovery: async () => ({ success: false, message: "LAN 模式不支持权限设置" }),
|
||||
authClearAll: async () => ({ success: false, message: "LAN 模式不支持权限设置" }),
|
||||
|
||||
oauthLoadLoginState: async () => ({ success: true, data: null }),
|
||||
oauthClearLoginState: async () => ({ success: true }),
|
||||
oauthGetStorageUsage: async () => ({ success: false, message: "LAN 模式不支持云空间查询" }),
|
||||
onDeepLink: noopUnlisten,
|
||||
onNavigate: noopUnlisten,
|
||||
onSettingChanged: noopUnlisten,
|
||||
onDataUpdated: noopUnlisten,
|
||||
writeLog: async () => ({ success: true }),
|
||||
|
||||
openManagementWindow: async () => void 0,
|
||||
windowMinimize: async () => void 0,
|
||||
windowMaximize: async () => false,
|
||||
windowClose: async () => void 0,
|
||||
windowIsMaximized: async () => false,
|
||||
onWindowMaximizedChanged: noopUnlisten,
|
||||
windowResize: async () => void 0,
|
||||
windowSetResizable: async () => void 0,
|
||||
}
|
||||
|
||||
export const lanApi = new Proxy(lanApiBase, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop in target) return Reflect.get(target, prop, receiver)
|
||||
if (typeof prop !== "string") return undefined
|
||||
if (prop.startsWith("on")) return noopUnlisten
|
||||
return async () => ({ success: false, message: `LAN 模式不支持 ${prop}` })
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user