2026-04-26 19:35:52 +08:00
|
|
|
use super::filetree_selection::{FileTreeSelectionReducerContract, FileTreeSelectionState};
|
|
|
|
|
use super::focus_state::PageFocusKeyboardReducerContract;
|
|
|
|
|
use super::picker_state::PickerStateReducerContract;
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub enum TreeShellRendererMode {
|
|
|
|
|
Page,
|
|
|
|
|
FileTree,
|
|
|
|
|
Picker,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeShellRendererInput {
|
|
|
|
|
pub mode: TreeShellRendererMode,
|
|
|
|
|
pub projection_item_ids: Vec<String>,
|
|
|
|
|
pub expanded_ids: BTreeSet<String>,
|
|
|
|
|
pub focused_id: Option<String>,
|
|
|
|
|
pub page_focus_keyboard_reducer: Option<PageFocusKeyboardReducerContract>,
|
|
|
|
|
pub filetree_selection: FileTreeSelectionState,
|
|
|
|
|
pub filetree_selection_reducer: Option<FileTreeSelectionReducerContract>,
|
|
|
|
|
pub active_picker_item: Option<String>,
|
|
|
|
|
pub excluded_picker_ids: BTreeSet<String>,
|
|
|
|
|
pub picker_state_reducer: Option<PickerStateReducerContract>,
|
|
|
|
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
2026-04-27 10:27:15 +08:00
|
|
|
pub runtime_artifact: TreeShellRuntimeArtifactBoundary,
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeShellCommandDispatcher {
|
|
|
|
|
pub channel: String,
|
|
|
|
|
pub command_names: BTreeSet<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 10:27:15 +08:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeShellRuntimeArtifactBoundary {
|
|
|
|
|
pub contract_name: &'static str,
|
2026-04-28 16:30:51 +08:00
|
|
|
pub family: &'static str,
|
|
|
|
|
pub version: u8,
|
|
|
|
|
pub execution_strategy: &'static str,
|
|
|
|
|
pub browser_bridge: &'static str,
|
|
|
|
|
pub wasm_module_url: Option<&'static str>,
|
|
|
|
|
pub js_glue_url: Option<&'static str>,
|
2026-04-27 10:27:15 +08:00
|
|
|
pub input_fields: BTreeSet<&'static str>,
|
|
|
|
|
pub output_channels: BTreeSet<&'static str>,
|
|
|
|
|
pub event_kinds: BTreeSet<&'static str>,
|
2026-04-28 16:30:51 +08:00
|
|
|
pub runtime_api: TreeShellRuntimeApiBoundary,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct TreeShellRuntimeApiBoundary {
|
|
|
|
|
pub request_contract: &'static str,
|
|
|
|
|
pub result_contract: &'static str,
|
|
|
|
|
pub reduce_endpoint: &'static str,
|
|
|
|
|
pub state_snapshots: BTreeSet<&'static str>,
|
|
|
|
|
pub dom_patch_kinds: BTreeSet<&'static str>,
|
|
|
|
|
pub host_event_kinds: BTreeSet<&'static str>,
|
|
|
|
|
pub command_event_kinds: BTreeSet<&'static str>,
|
2026-04-27 10:27:15 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for TreeShellRuntimeArtifactBoundary {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
contract_name: "rust_tree_shell_runtime_artifact_v1",
|
2026-04-28 16:30:51 +08:00
|
|
|
family: "rust_family",
|
|
|
|
|
version: 1,
|
|
|
|
|
execution_strategy: "browser_bridge",
|
|
|
|
|
browser_bridge: "iframe_srcdoc",
|
|
|
|
|
wasm_module_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"),
|
|
|
|
|
js_glue_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js"),
|
2026-04-27 10:27:15 +08:00
|
|
|
input_fields: BTreeSet::from([
|
|
|
|
|
"rendererInput",
|
|
|
|
|
"projectionItems",
|
|
|
|
|
"expandedIds",
|
|
|
|
|
"selectedRowIds",
|
|
|
|
|
"activePickerItem",
|
|
|
|
|
"focusedId",
|
|
|
|
|
]),
|
|
|
|
|
output_channels: BTreeSet::from(["domPatch", "intentEvent", "commandDispatchEvent"]),
|
|
|
|
|
event_kinds: BTreeSet::from([
|
|
|
|
|
"focus",
|
|
|
|
|
"keyboard",
|
|
|
|
|
"expandCollapse",
|
|
|
|
|
"selection",
|
|
|
|
|
"contextMenu",
|
|
|
|
|
"dragDrop",
|
|
|
|
|
"pick",
|
|
|
|
|
]),
|
2026-04-28 16:30:51 +08:00
|
|
|
runtime_api: TreeShellRuntimeApiBoundary {
|
|
|
|
|
request_contract: "TreeShellRuntimeRequest",
|
|
|
|
|
result_contract: "TreeShellRuntimeResult",
|
|
|
|
|
reduce_endpoint: "/api/tree/runtime/reduce",
|
|
|
|
|
state_snapshots: BTreeSet::from(["page", "fileTree", "picker"]),
|
|
|
|
|
dom_patch_kinds: BTreeSet::from(["pageState", "fileTreeState", "pickerState"]),
|
|
|
|
|
host_event_kinds: BTreeSet::from([
|
|
|
|
|
"pageOpen",
|
|
|
|
|
"pageContextMenu",
|
|
|
|
|
"fileTreeOpen",
|
|
|
|
|
"fileTreeContextMenu",
|
|
|
|
|
"fileTreeInternalDrop",
|
|
|
|
|
"fileTreeExternalDrop",
|
|
|
|
|
"pickerPickRoot",
|
|
|
|
|
"pickerPickDocument",
|
|
|
|
|
]),
|
|
|
|
|
command_event_kinds: BTreeSet::from([
|
|
|
|
|
"createNode",
|
|
|
|
|
"renameNode",
|
|
|
|
|
"moveSubtree",
|
|
|
|
|
"copyResource",
|
|
|
|
|
"moveResource",
|
|
|
|
|
"uploadResource",
|
|
|
|
|
]),
|
|
|
|
|
},
|
2026-04-27 10:27:15 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
impl TreeShellRendererInput {
|
|
|
|
|
pub fn page(input: PageTreeRendererInput) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
mode: TreeShellRendererMode::Page,
|
|
|
|
|
projection_item_ids: input.projection_item_ids,
|
|
|
|
|
expanded_ids: input.expanded_ids,
|
|
|
|
|
focused_id: input.focused_id,
|
|
|
|
|
page_focus_keyboard_reducer: Some(PageFocusKeyboardReducerContract::default()),
|
|
|
|
|
filetree_selection: FileTreeSelectionState::default(),
|
|
|
|
|
filetree_selection_reducer: None,
|
|
|
|
|
active_picker_item: None,
|
|
|
|
|
excluded_picker_ids: BTreeSet::new(),
|
|
|
|
|
picker_state_reducer: None,
|
|
|
|
|
command_dispatcher: input.command_dispatcher,
|
2026-04-27 10:27:15 +08:00
|
|
|
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn filetree(input: FileTreeRendererInput) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
mode: TreeShellRendererMode::FileTree,
|
|
|
|
|
projection_item_ids: input.projection_item_ids,
|
|
|
|
|
expanded_ids: input.expanded_ids,
|
|
|
|
|
focused_id: input.filetree_selection.focused_row_id.clone(),
|
|
|
|
|
page_focus_keyboard_reducer: None,
|
|
|
|
|
filetree_selection: input.filetree_selection,
|
|
|
|
|
filetree_selection_reducer: Some(FileTreeSelectionReducerContract::default()),
|
|
|
|
|
active_picker_item: None,
|
|
|
|
|
excluded_picker_ids: BTreeSet::new(),
|
|
|
|
|
picker_state_reducer: None,
|
|
|
|
|
command_dispatcher: input.command_dispatcher,
|
2026-04-27 10:27:15 +08:00
|
|
|
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn picker(input: PickerRendererInput) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
mode: TreeShellRendererMode::Picker,
|
|
|
|
|
projection_item_ids: input.projection_item_ids,
|
|
|
|
|
expanded_ids: input.expanded_ids,
|
|
|
|
|
focused_id: input.active_picker_item.clone(),
|
|
|
|
|
page_focus_keyboard_reducer: None,
|
|
|
|
|
filetree_selection: FileTreeSelectionState::default(),
|
|
|
|
|
filetree_selection_reducer: None,
|
|
|
|
|
active_picker_item: input.active_picker_item,
|
|
|
|
|
excluded_picker_ids: input.excluded_picker_ids,
|
|
|
|
|
picker_state_reducer: Some(PickerStateReducerContract::default()),
|
|
|
|
|
command_dispatcher: input.command_dispatcher,
|
2026-04-27 10:27:15 +08:00
|
|
|
runtime_artifact: TreeShellRuntimeArtifactBoundary::default(),
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct PageTreeRendererInput {
|
|
|
|
|
pub projection_item_ids: Vec<String>,
|
|
|
|
|
pub expanded_ids: BTreeSet<String>,
|
|
|
|
|
pub focused_id: Option<String>,
|
|
|
|
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct FileTreeRendererInput {
|
|
|
|
|
pub projection_item_ids: Vec<String>,
|
|
|
|
|
pub expanded_ids: BTreeSet<String>,
|
|
|
|
|
pub filetree_selection: FileTreeSelectionState,
|
|
|
|
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct PickerRendererInput {
|
|
|
|
|
pub projection_item_ids: Vec<String>,
|
|
|
|
|
pub expanded_ids: BTreeSet<String>,
|
|
|
|
|
pub active_picker_item: Option<String>,
|
|
|
|
|
pub excluded_picker_ids: BTreeSet<String>,
|
|
|
|
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{
|
2026-04-27 10:27:15 +08:00
|
|
|
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput,
|
|
|
|
|
TreeShellCommandDispatcher, TreeShellRendererInput, TreeShellRendererMode,
|
2026-04-26 19:35:52 +08:00
|
|
|
};
|
|
|
|
|
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
|
|
|
|
|
fn set(values: &[&str]) -> BTreeSet<String> {
|
|
|
|
|
values.iter().map(|value| (*value).to_string()).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn dispatcher() -> TreeShellCommandDispatcher {
|
|
|
|
|
TreeShellCommandDispatcher {
|
|
|
|
|
channel: "mnote.tree.shell".into(),
|
|
|
|
|
command_names: set(&["tree.node.create", "tree.resource.move"]),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tree_shell_renderer_input_contract_covers_page_filetree_and_picker_state() {
|
|
|
|
|
let page = TreeShellRendererInput::page(PageTreeRendererInput {
|
|
|
|
|
projection_item_ids: vec!["doc:root".into()],
|
|
|
|
|
expanded_ids: set(&["doc:root"]),
|
|
|
|
|
focused_id: Some("doc:root".into()),
|
|
|
|
|
command_dispatcher: dispatcher(),
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(page.mode, TreeShellRendererMode::Page);
|
|
|
|
|
assert_eq!(page.focused_id.as_deref(), Some("doc:root"));
|
|
|
|
|
assert_eq!(page.command_dispatcher.channel, "mnote.tree.shell");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
page.page_focus_keyboard_reducer
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|contract| contract.contract_name),
|
|
|
|
|
Some("rust_page_focus_keyboard_reducer_v1")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut selection = FileTreeSelectionState::from_selected(&["asset:a".into()]);
|
|
|
|
|
selection.focused_row_id = Some("asset:a".into());
|
|
|
|
|
let filetree = TreeShellRendererInput::filetree(FileTreeRendererInput {
|
|
|
|
|
projection_item_ids: vec!["doc:root".into(), "asset:a".into()],
|
|
|
|
|
expanded_ids: set(&["doc:root"]),
|
|
|
|
|
filetree_selection: selection,
|
|
|
|
|
command_dispatcher: dispatcher(),
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
|
|
|
|
|
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
|
2026-04-29 12:24:44 +08:00
|
|
|
assert!(filetree
|
|
|
|
|
.filetree_selection
|
|
|
|
|
.selected_row_ids
|
|
|
|
|
.contains("asset:a"));
|
2026-04-26 19:35:52 +08:00
|
|
|
assert!(filetree.page_focus_keyboard_reducer.is_none());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
filetree
|
|
|
|
|
.filetree_selection_reducer
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|contract| contract.contract_name),
|
|
|
|
|
Some("rust_filetree_selection_reducer_v1")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let picker = TreeShellRendererInput::picker(PickerRendererInput {
|
|
|
|
|
projection_item_ids: vec!["doc:root".into(), "doc:child".into()],
|
|
|
|
|
expanded_ids: set(&["doc:root"]),
|
|
|
|
|
active_picker_item: Some("doc:child".into()),
|
|
|
|
|
excluded_picker_ids: set(&["doc:archived"]),
|
|
|
|
|
command_dispatcher: dispatcher(),
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(picker.mode, TreeShellRendererMode::Picker);
|
|
|
|
|
assert_eq!(picker.focused_id.as_deref(), Some("doc:child"));
|
|
|
|
|
assert!(picker.excluded_picker_ids.contains("doc:archived"));
|
|
|
|
|
assert!(picker.page_focus_keyboard_reducer.is_none());
|
|
|
|
|
assert!(picker.filetree_selection_reducer.is_none());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
picker
|
|
|
|
|
.picker_state_reducer
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|contract| contract.contract_name),
|
|
|
|
|
Some("rust_picker_state_reducer_v1")
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-27 10:27:15 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tree_shell_renderer_input_exposes_runtime_artifact_boundary() {
|
|
|
|
|
let page = TreeShellRendererInput::page(PageTreeRendererInput {
|
|
|
|
|
projection_item_ids: vec!["doc:root".into()],
|
|
|
|
|
expanded_ids: set(&["doc:root"]),
|
|
|
|
|
focused_id: Some("doc:root".into()),
|
|
|
|
|
command_dispatcher: dispatcher(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let artifact = page.runtime_artifact;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
artifact.contract_name,
|
|
|
|
|
"rust_tree_shell_runtime_artifact_v1"
|
|
|
|
|
);
|
2026-04-28 16:30:51 +08:00
|
|
|
assert_eq!(artifact.family, "rust_family");
|
|
|
|
|
assert_eq!(artifact.version, 1);
|
|
|
|
|
assert_eq!(artifact.execution_strategy, "browser_bridge");
|
|
|
|
|
assert_eq!(artifact.browser_bridge, "iframe_srcdoc");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
artifact.wasm_module_url,
|
|
|
|
|
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
artifact.js_glue_url,
|
|
|
|
|
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js")
|
|
|
|
|
);
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(artifact.input_fields.contains("rendererInput"));
|
|
|
|
|
assert!(artifact.input_fields.contains("projectionItems"));
|
|
|
|
|
assert!(artifact.input_fields.contains("expandedIds"));
|
|
|
|
|
assert!(artifact.input_fields.contains("selectedRowIds"));
|
|
|
|
|
assert!(artifact.input_fields.contains("activePickerItem"));
|
|
|
|
|
assert!(artifact.input_fields.contains("focusedId"));
|
|
|
|
|
assert!(artifact.output_channels.contains("domPatch"));
|
|
|
|
|
assert!(artifact.output_channels.contains("intentEvent"));
|
|
|
|
|
assert!(artifact.output_channels.contains("commandDispatchEvent"));
|
2026-04-28 16:30:51 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
artifact.runtime_api.request_contract,
|
|
|
|
|
"TreeShellRuntimeRequest"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
artifact.runtime_api.result_contract,
|
|
|
|
|
"TreeShellRuntimeResult"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
artifact.runtime_api.reduce_endpoint,
|
|
|
|
|
"/api/tree/runtime/reduce"
|
|
|
|
|
);
|
|
|
|
|
assert!(artifact.runtime_api.state_snapshots.contains("page"));
|
|
|
|
|
assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
|
|
|
|
|
assert!(artifact.runtime_api.state_snapshots.contains("picker"));
|
|
|
|
|
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
|
2026-04-29 12:24:44 +08:00
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.dom_patch_kinds
|
|
|
|
|
.contains("fileTreeState"));
|
2026-04-28 16:30:51 +08:00
|
|
|
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
|
|
|
|
|
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
|
2026-04-29 12:24:44 +08:00
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.host_event_kinds
|
|
|
|
|
.contains("fileTreeOpen"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.host_event_kinds
|
|
|
|
|
.contains("fileTreeInternalDrop"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.host_event_kinds
|
|
|
|
|
.contains("fileTreeExternalDrop"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.host_event_kinds
|
|
|
|
|
.contains("pickerPickDocument"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.command_event_kinds
|
|
|
|
|
.contains("createNode"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.command_event_kinds
|
|
|
|
|
.contains("renameNode"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.command_event_kinds
|
|
|
|
|
.contains("moveSubtree"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.command_event_kinds
|
|
|
|
|
.contains("copyResource"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.command_event_kinds
|
|
|
|
|
.contains("moveResource"));
|
|
|
|
|
assert!(artifact
|
|
|
|
|
.runtime_api
|
|
|
|
|
.command_event_kinds
|
|
|
|
|
.contains("uploadResource"));
|
2026-04-27 10:27:15 +08:00
|
|
|
assert!(artifact.event_kinds.contains("focus"));
|
|
|
|
|
assert!(artifact.event_kinds.contains("keyboard"));
|
|
|
|
|
assert!(artifact.event_kinds.contains("expandCollapse"));
|
|
|
|
|
assert!(artifact.event_kinds.contains("selection"));
|
|
|
|
|
assert!(artifact.event_kinds.contains("contextMenu"));
|
|
|
|
|
assert!(artifact.event_kinds.contains("dragDrop"));
|
|
|
|
|
assert!(artifact.event_kinds.contains("pick"));
|
|
|
|
|
}
|
2026-04-26 19:35:52 +08:00
|
|
|
}
|