feat: 新增局域网浏览器访问

This commit is contained in:
JSR
2026-07-11 12:20:01 +08:00
parent 7971c6adbc
commit 5595062278
10 changed files with 1749 additions and 30 deletions
+123 -5
View File
@@ -814,14 +814,38 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.23.0",
"darling_macro", "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]] [[package]]
@@ -837,13 +861,24 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.23.0" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.23.0",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
] ]
@@ -880,6 +915,37 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -1532,6 +1598,17 @@ dependencies = [
"wasip3", "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]] [[package]]
name = "gio" name = "gio"
version = "0.18.4" version = "0.18.4"
@@ -2379,6 +2456,17 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 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]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2582,6 +2670,35 @@ dependencies = [
"jni-sys 0.3.1", "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]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@@ -3868,7 +3985,7 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e"
dependencies = [ dependencies = [
"darling", "darling 0.23.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_json", "serde_json",
@@ -4222,6 +4339,7 @@ dependencies = [
"dirs", "dirs",
"hex", "hex",
"libc", "libc",
"local-ip-address",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"rand 0.8.5", "rand 0.8.5",
@@ -4437,7 +4555,7 @@ version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
dependencies = [ dependencies = [
"darling", "darling 0.23.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
+1
View File
@@ -41,6 +41,7 @@ parking_lot = { version = "0.12", features = ["send_guard"] }
dirs = "6.0.0" dirs = "6.0.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
urlencoding = "2.1" urlencoding = "2.1"
local-ip-address = "0.6.13"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.52" winreg = "0.52"
File diff suppressed because it is too large Load Diff
+36
View File
@@ -148,6 +148,7 @@ pub fn run() {
fs_file_exists, fs_file_exists,
fs_open_path, fs_open_path,
http_server_start, http_server_start,
http_server_refresh_token,
http_server_stop, http_server_stop,
http_server_status, http_server_status,
mcp_server_start, 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_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(()) Ok(())
} }
+19
View File
@@ -20,6 +20,7 @@ pub struct SettingsSpec {
pub pg_connection_string: String, pub pg_connection_string: String,
pub pg_connection_status: JsonValue, pub pg_connection_status: JsonValue,
pub mobile_bottom_nav_items: JsonValue, pub mobile_bottom_nav_items: JsonValue,
pub lan_access_enabled: bool,
} }
impl Default for SettingsSpec { impl Default for SettingsSpec {
@@ -51,6 +52,7 @@ impl Default for SettingsSpec {
"reasons", "reasons",
"settings" "settings"
]), ]),
lan_access_enabled: false,
} }
} }
} }
@@ -72,6 +74,7 @@ pub enum SettingsKey {
PgConnectionString, PgConnectionString,
PgConnectionStatus, PgConnectionStatus,
MobileBottomNavItems, MobileBottomNavItems,
LanAccessEnabled,
} }
impl SettingsKey { impl SettingsKey {
@@ -92,6 +95,7 @@ impl SettingsKey {
SettingsKey::PgConnectionString => "pg_connection_string", SettingsKey::PgConnectionString => "pg_connection_string",
SettingsKey::PgConnectionStatus => "pg_connection_status", SettingsKey::PgConnectionStatus => "pg_connection_status",
SettingsKey::MobileBottomNavItems => "mobile_bottom_nav_items", 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_string" => Some(SettingsKey::PgConnectionString),
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus), "pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
"mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems), "mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems),
"lan_access_enabled" => Some(SettingsKey::LanAccessEnabled),
_ => None, _ => 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 defs
} }
@@ -636,6 +651,10 @@ impl SettingsService {
"settings" "settings"
]), ]),
}, },
lan_access_enabled: match self.get_value(SettingsKey::LanAccessEnabled) {
SettingsValue::Boolean(b) => b,
_ => false,
},
} }
} }
+296 -3
View File
@@ -1,10 +1,12 @@
import React, { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react" 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 { import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
LeftOutlined, LeftOutlined,
SettingOutlined, SettingOutlined,
LinkOutlined,
CopyOutlined,
} from "@ant-design/icons" } from "@ant-design/icons"
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom" import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -84,6 +86,13 @@ interface HeaderStorageUsage {
file_count: number file_count: number
} }
interface LanShareUrl {
ip: string
url: string
is_private?: boolean
is_192_168?: boolean
}
export function ContentArea({ export function ContentArea({
permission, permission,
oauthUserName, oauthUserName,
@@ -107,12 +116,13 @@ export function ContentArea({
bottomInset = 0, bottomInset = 0,
}: ContentAreaProps): React.JSX.Element { }: ContentAreaProps): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const isLanBrowser = Boolean((window as any).__SECSCORE_LAN__)
const isMacOS = const isMacOS =
typeof navigator !== "undefined" && typeof navigator !== "undefined" &&
/mac/i.test(navigator.userAgent) && /mac/i.test(navigator.userAgent) &&
!/iphone|ipad|ipod|android/i.test(navigator.userAgent) !/iphone|ipad|ipod|android/i.test(navigator.userAgent)
// macOS 使用原生红绿灯,侧栏隐藏(沉浸/竖屏)时顶部左侧需为红绿灯留白 // macOS 使用原生红绿灯,侧栏隐藏(沉浸/竖屏)时顶部左侧需为红绿灯留白
const macTrafficLightsInset = isMacOS && (isPortraitMode || immersiveMode) const macTrafficLightsInset = !isLanBrowser && isMacOS && (isPortraitMode || immersiveMode)
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home") const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
@@ -200,7 +210,21 @@ export function ContentArea({
}) })
const [copiedUserId, setCopiedUserId] = useState(false) const [copiedUserId, setCopiedUserId] = useState(false)
const [logoutLoading, setLogoutLoading] = 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 copyResetTimerRef = useRef<number | null>(null)
const lanCopyResetTimerRef = useRef<number | null>(null)
const hasOAuthSession = Boolean(oauthUserName && oauthUserName.trim()) const hasOAuthSession = Boolean(oauthUserName && oauthUserName.trim())
const formattedLastSyncTime = (() => { const formattedLastSyncTime = (() => {
if (!lastSyncTime) return "暂无" if (!lastSyncTime) return "暂无"
@@ -290,6 +314,10 @@ export function ContentArea({
window.clearTimeout(copyResetTimerRef.current) window.clearTimeout(copyResetTimerRef.current)
copyResetTimerRef.current = null 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 = ( const profilePopoverContent = (
<div style={{ width: "260px", display: "flex", flexDirection: "column", gap: "10px" }}> <div style={{ width: "260px", display: "flex", flexDirection: "column", gap: "10px" }}>
<div <div
@@ -408,6 +557,134 @@ export function ContentArea({
</div> </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 ( return (
<Layout <Layout
style={{ style={{
@@ -549,7 +826,23 @@ export function ContentArea({
} }
> >
<Space size="small"> <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 <Button
size="small" size="small"
icon={<SettingOutlined />} icon={<SettingOutlined />}
+60
View File
@@ -42,6 +42,7 @@ type appSettings = {
search_keyboard_layout?: "t9" | "qwerty26" search_keyboard_layout?: "t9" | "qwerty26"
disable_search_keyboard?: boolean disable_search_keyboard?: boolean
font_family?: string font_family?: string
lan_access_enabled?: boolean
} }
interface FontOption { interface FontOption {
@@ -191,6 +192,7 @@ export const Settings: React.FC<{
const [appQuitLoading, setAppQuitLoading] = useState(false) const [appQuitLoading, setAppQuitLoading] = useState(false)
const [appRestartLoading, setAppRestartLoading] = useState(false) const [appRestartLoading, setAppRestartLoading] = useState(false)
const [mcpLoading, setMcpLoading] = useState(false) const [mcpLoading, setMcpLoading] = useState(false)
const [lanAccessLoading, setLanAccessLoading] = useState(false)
const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({ const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({
host: "127.0.0.1", host: "127.0.0.1",
port: 3901, 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 currentYear = new Date().getFullYear()
const confirmQuitApp = () => { const confirmQuitApp = () => {
@@ -1938,6 +1981,23 @@ export const Settings: React.FC<{
</> </>
)} )}
<Divider /> <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" }}> <div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.mcp.title")} {t("settings.mcp.title")}
</div> </div>
+10 -1
View File
@@ -8,8 +8,17 @@ import { ClientContext } from "./ClientContext"
import { StudentService } from "./services/StudentService" import { StudentService } from "./services/StudentService"
import { ServiceProvider } from "./contexts/ServiceContext" import { ServiceProvider } from "./contexts/ServiceContext"
import { api } from "./preload/types" 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 ;(window as any).api = api
} }
+44 -3
View File
@@ -96,6 +96,7 @@ export type settingsKey =
| "pg_connection_string" | "pg_connection_string"
| "pg_connection_status" | "pg_connection_status"
| "mobile_bottom_nav_items" | "mobile_bottom_nav_items"
| "lan_access_enabled"
export interface settingsSpec { export interface settingsSpec {
is_wizard_completed: boolean is_wizard_completed: boolean
@@ -117,6 +118,7 @@ export interface settingsSpec {
error?: string error?: string
} }
mobile_bottom_nav_items: string[] mobile_bottom_nav_items: string[]
lan_access_enabled: boolean
} }
export interface pluginRuntimeModule { export interface pluginRuntimeModule {
@@ -130,6 +132,13 @@ export interface pluginRuntimeModule {
permissions: string[] permissions: string[]
} }
export interface httpServerShareUrl {
ip: string
url: string
is_private?: boolean
is_192_168?: boolean
}
const api = { const api = {
// Theme // Theme
getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"), getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"),
@@ -723,13 +732,45 @@ const api = {
httpServerStart: (config?: { httpServerStart: (config?: {
port?: number port?: number
host?: string host?: string
api_port?: number
corsOrigin?: string corsOrigin?: string
}): Promise<{ success: boolean; data: { url: string; config: any } }> => }): Promise<{
invoke("http_server_start", { config }), 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"), httpServerStop: (): Promise<{ success: boolean }> => invoke("http_server_stop"),
httpServerStatus: (): Promise<{ httpServerStatus: (): Promise<{
success: boolean 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"), }> => invoke("http_server_status"),
// MCP Server // MCP Server
+170
View File
@@ -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}` })
},
})