feat(tree): checkpoint resource lifecycle work
提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。 不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
@@ -2,6 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
|
||||
@@ -19,8 +20,9 @@ use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use leptos::prelude::InnerHtmlAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
|
||||
@@ -276,6 +278,7 @@ pub async fn root_entry(
|
||||
&context,
|
||||
&workspace_id,
|
||||
selected_active_page_id.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -399,6 +402,426 @@ pub async fn root_entry(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn trash_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<RootEntryQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !has_real_auth_context(&context) {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/auth")
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
let workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
&workspace_id,
|
||||
None,
|
||||
&default_workspace_name,
|
||||
)
|
||||
.await;
|
||||
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, &workspace_id, None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html = load_file_tree_html(state.config(), &context, &workspace_id, None, None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": workspace_projection.workspace_name }],
|
||||
"documents": [],
|
||||
"trashed_documents": [],
|
||||
"trashed_media_assets": [],
|
||||
"trashed_mindmap_assets": [],
|
||||
"trashed_table_assets": [],
|
||||
"degraded": true
|
||||
})
|
||||
});
|
||||
let trash_workbench_html = render_trash_workbench_html(&workspace_id, &dataset);
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::layout::PageLayout
|
||||
current_nav="trash"
|
||||
sidebar_tree_html={sidebar_tree_html.clone()}
|
||||
workspace_name={workspace_name.clone()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
topbar_title={"垃圾箱".to_string()}
|
||||
>
|
||||
<div inner_html={trash_workbench_html}></div>
|
||||
</crate::ssr::pages::layout::PageLayout>
|
||||
});
|
||||
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>垃圾箱</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
content,
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
|
||||
let documents = json_array(dataset, "trashed_documents");
|
||||
let media_assets = json_array(dataset, "trashed_media_assets");
|
||||
let mindmap_assets = json_array(dataset, "trashed_mindmap_assets");
|
||||
let table_assets = json_array(dataset, "trashed_table_assets");
|
||||
let resource_count = media_assets.len() + mindmap_assets.len() + table_assets.len();
|
||||
let document_rows = render_trashed_document_rows(workspace_id, documents);
|
||||
let mut resource_rows = String::new();
|
||||
resource_rows.push_str(&render_trashed_resource_rows("media", "附件", media_assets));
|
||||
resource_rows.push_str(&render_trashed_resource_rows(
|
||||
"mindmap",
|
||||
"思维导图",
|
||||
mindmap_assets,
|
||||
));
|
||||
resource_rows.push_str(&render_trashed_resource_rows("table", "表格", table_assets));
|
||||
let resource_body = if resource_count == 0 {
|
||||
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
|
||||
} else {
|
||||
resource_rows
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-workspace-id="{workspace_id}" data-trash-fetch-path="/trash">
|
||||
<header class="mnote-trash-header">
|
||||
<h1>垃圾箱</h1>
|
||||
<p>默认删除的页面会先进入这里;彻底删除会永久移除。</p>
|
||||
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
|
||||
</header>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
|
||||
<button type="button" data-trash-action="empty-documents"{empty_disabled}>清空页面垃圾箱</button>
|
||||
</div>
|
||||
{document_rows}
|
||||
</section>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
|
||||
<button type="button" data-trash-action="empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
|
||||
</div>
|
||||
{resource_body}
|
||||
<p class="mnote-trash-note">资源恢复、彻底删除和清空当前走 Rust 兼容入口;正式 tree.resource.* 命令仍在后续阶段收口。</p>
|
||||
</section>
|
||||
</section>
|
||||
<script>
|
||||
(function() {{
|
||||
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!root) return;
|
||||
var workspaceId = root.getAttribute('data-workspace-id') || '';
|
||||
function setStatus(message, failed) {{
|
||||
var status = root.querySelector('[data-trash-status]');
|
||||
if (!status) return;
|
||||
status.textContent = message || '';
|
||||
status.setAttribute('data-type', failed ? 'error' : 'success');
|
||||
}}
|
||||
function refreshTrashWorkbenchFromServer(reason) {{
|
||||
if (!workspaceId) return Promise.resolve(false);
|
||||
var url = new URL(root.getAttribute('data-trash-fetch-path') || '/trash', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('liveRefreshReason', reason || 'tree-event');
|
||||
return fetch(url.toString(), {{
|
||||
method: 'GET',
|
||||
headers: {{ 'x-mnote-trash-live-refresh': '1' }}
|
||||
}}).then(function(response) {{
|
||||
return response.text().then(function(html) {{
|
||||
if (!response.ok) throw new Error('trash_live_refresh_failed_' + response.status);
|
||||
var parsed = new DOMParser().parseFromString(html, 'text/html');
|
||||
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
|
||||
root.innerHTML = nextRoot.innerHTML;
|
||||
root.setAttribute('data-live-refresh-reason', reason || 'tree-event');
|
||||
root.setAttribute('data-live-refresh-at', String(Date.now()));
|
||||
return true;
|
||||
}});
|
||||
}}).catch(function(error) {{
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
return false;
|
||||
}});
|
||||
}}
|
||||
function startTrashWorkbenchLiveRefresh() {{
|
||||
if (!workspaceId || !('EventSource' in window)) return;
|
||||
var url = new URL('/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('pollMs', '1000');
|
||||
var source = new EventSource(url.toString());
|
||||
root.__mnoteTrashEventSource = source;
|
||||
['snapshot', 'delta', 'resync'].forEach(function(kind) {{
|
||||
source.addEventListener(kind, function() {{
|
||||
refreshTrashWorkbenchFromServer(kind);
|
||||
}});
|
||||
}});
|
||||
source.onerror = function() {{
|
||||
root.setAttribute('data-live-refresh-error', 'eventsource_error');
|
||||
}};
|
||||
}}
|
||||
function readJson(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
|
||||
return payload;
|
||||
}});
|
||||
}}
|
||||
function decrement(selector) {{
|
||||
var count = root.querySelector(selector);
|
||||
if (!count) return;
|
||||
var nextCount = Math.max(0, Number(count.textContent || '0') - 1);
|
||||
count.textContent = String(nextCount);
|
||||
}}
|
||||
function postJson(url, body) {{
|
||||
return fetch(url, {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify(body || {{}})
|
||||
}}).then(readJson);
|
||||
}}
|
||||
function patchJson(url, body) {{
|
||||
return fetch(url, {{
|
||||
method: 'PATCH',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify(body || {{}})
|
||||
}}).then(readJson);
|
||||
}}
|
||||
function runResourceAction(kind, action, resourceId, documentId) {{
|
||||
if (kind === 'media') {{
|
||||
return action === 'restore'
|
||||
? postJson('/api/media/batch', {{ action: 'restore', assetIds: [resourceId] }})
|
||||
: postJson('/api/media/purge', {{ assetId: resourceId }});
|
||||
}}
|
||||
if (kind === 'mindmap') {{
|
||||
if (!documentId) return Promise.reject(new Error('mindmap_document_id_required'));
|
||||
return patchJson('/api/mindmap/' + encodeURIComponent(documentId) + '/' + encodeURIComponent(resourceId), {{ action: action }});
|
||||
}}
|
||||
if (kind === 'table') {{
|
||||
return postJson(action === 'restore' ? '/api/tables/restore' : '/api/tables/purge', {{ tableId: resourceId }});
|
||||
}}
|
||||
return Promise.reject(new Error('resource_kind_unsupported'));
|
||||
}}
|
||||
root.addEventListener('click', function(event) {{
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
|
||||
if (!button || button.disabled) return;
|
||||
var action = button.getAttribute('data-trash-action');
|
||||
var documentId = button.getAttribute('data-document-id') || '';
|
||||
if (action === 'empty-documents') {{
|
||||
if (!window.confirm('清空页面垃圾箱后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
fetch('/api/documents/empty-trash', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify({{ workspaceId: workspaceId }})
|
||||
}}).then(function(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_empty_failed_' + response.status);
|
||||
root.querySelectorAll('[data-trash-row="document"]').forEach(function(row) {{ row.remove(); }});
|
||||
var count = root.querySelector('[data-trash-document-count]');
|
||||
if (count) count.textContent = '0';
|
||||
setStatus('已清空页面垃圾箱', false);
|
||||
}});
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
return;
|
||||
}}
|
||||
if (action === 'empty-resources') {{
|
||||
if (!window.confirm('清空资源垃圾箱后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
Promise.all([
|
||||
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
|
||||
postJson('/api/mindmap-trash/empty', {{ workspaceId: workspaceId }}),
|
||||
postJson('/api/tables/empty-trash', {{ workspaceId: workspaceId }})
|
||||
]).then(function() {{
|
||||
root.querySelectorAll('[data-trash-row="resource"]').forEach(function(row) {{ row.remove(); }});
|
||||
var count = root.querySelector('[data-trash-resource-count]');
|
||||
if (count) count.textContent = '0';
|
||||
setStatus('已清空资源垃圾箱', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
return;
|
||||
}}
|
||||
if (action === 'resource-restore' || action === 'resource-purge') {{
|
||||
var resourceId = button.getAttribute('data-resource-id') || '';
|
||||
var kind = button.getAttribute('data-resource-kind') || '';
|
||||
var resourceDocumentId = button.getAttribute('data-document-id') || '';
|
||||
if (!resourceId) return;
|
||||
if (action === 'resource-purge' && !window.confirm('彻底删除资源后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
|
||||
var row = button.closest('[data-trash-row]');
|
||||
if (row) row.remove();
|
||||
decrement('[data-trash-resource-count]');
|
||||
setStatus(action === 'resource-restore' ? '已恢复资源' : '已彻底删除资源', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
return;
|
||||
}}
|
||||
if (!documentId) return;
|
||||
if (action === 'purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
fetch('/api/tree/commands', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify({{
|
||||
action: action,
|
||||
workspaceId: workspaceId,
|
||||
documentId: documentId
|
||||
}})
|
||||
}}).then(function(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_action_failed_' + response.status);
|
||||
var row = button.closest('[data-trash-row]');
|
||||
if (row) row.remove();
|
||||
decrement('[data-trash-document-count]');
|
||||
setStatus(action === 'restore' ? '已恢复页面' : '已彻底删除页面', false);
|
||||
}});
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
}});
|
||||
startTrashWorkbenchLiveRefresh();
|
||||
}})();
|
||||
</script>"#,
|
||||
workspace_id = escape_html(workspace_id),
|
||||
document_count = documents.len(),
|
||||
empty_disabled = if documents.is_empty() {
|
||||
" disabled"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
resource_empty_disabled = if resource_count == 0 { " disabled" } else { "" },
|
||||
resource_count = resource_count,
|
||||
document_rows = document_rows,
|
||||
resource_body = resource_body,
|
||||
)
|
||||
}
|
||||
|
||||
fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] {
|
||||
dataset
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn render_trashed_document_rows(workspace_id: &str, documents: &[Value]) -> String {
|
||||
if documents.is_empty() {
|
||||
return r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string();
|
||||
}
|
||||
|
||||
documents
|
||||
.iter()
|
||||
.filter_map(|document| {
|
||||
let id = document.get("id").and_then(Value::as_str)?;
|
||||
let title = document
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("无标题");
|
||||
let deleted_at = document
|
||||
.get("deleted_at")
|
||||
.or_else(|| document.get("deletedAt"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
Some(format!(
|
||||
r#"<article class="mnote-trash-row" data-trash-row="document" data-document-id="{id}">
|
||||
<div class="mnote-trash-row-main">
|
||||
<a href="/documents/{id}?workspaceId={workspace_id}" class="mnote-trash-title">{title}</a>
|
||||
<span class="mnote-trash-meta">{deleted_at}</span>
|
||||
</div>
|
||||
<div class="mnote-trash-actions">
|
||||
<button type="button" data-trash-action="restore" data-document-id="{id}">恢复</button>
|
||||
<button type="button" data-trash-action="purge" data-document-id="{id}">彻底删除</button>
|
||||
</div>
|
||||
</article>"#,
|
||||
id = escape_html(id),
|
||||
workspace_id = escape_html(workspace_id),
|
||||
title = escape_html(title),
|
||||
deleted_at = escape_html(deleted_at),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn render_trashed_resource_rows(kind: &str, label: &str, resources: &[Value]) -> String {
|
||||
resources
|
||||
.iter()
|
||||
.filter_map(|resource| {
|
||||
let id = resource.get("id").and_then(Value::as_str)?;
|
||||
let document_id = resource
|
||||
.get("document_id")
|
||||
.or_else(|| resource.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let title = resource
|
||||
.get("file_name")
|
||||
.or_else(|| resource.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("未命名资源");
|
||||
let deleted_at = resource
|
||||
.get("deleted_at")
|
||||
.or_else(|| resource.get("deletedAt"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
Some(format!(
|
||||
r#"<article class="mnote-trash-row" data-trash-row="resource" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">
|
||||
<div class="mnote-trash-row-main">
|
||||
<span class="mnote-trash-kind">{label}</span>
|
||||
<span class="mnote-trash-title">{title}</span>
|
||||
<span class="mnote-trash-meta">{deleted_at}</span>
|
||||
</div>
|
||||
<div class="mnote-trash-actions">
|
||||
<button type="button" data-trash-action="resource-restore" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">恢复</button>
|
||||
<button type="button" data-trash-action="resource-purge" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">彻底删除</button>
|
||||
</div>
|
||||
</article>"#,
|
||||
kind = escape_html(kind),
|
||||
id = escape_html(id),
|
||||
document_id = escape_html(document_id),
|
||||
label = escape_html(label),
|
||||
title = escape_html(title),
|
||||
deleted_at = escape_html(deleted_at),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
pub async fn legacy_next_proxy(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1185,6 +1608,53 @@ mod tests {
|
||||
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trash_entry_renders_real_workspace_trash_workbench() {
|
||||
let response = app_with_query_fixtures(
|
||||
"http://127.0.0.1:3100".into(),
|
||||
false,
|
||||
None,
|
||||
Some(
|
||||
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
|
||||
),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/trash?workspaceId=ws_demo")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-trash-workbench""#));
|
||||
assert!(html.contains("已删页面"));
|
||||
assert!(html.contains(r#"data-trash-action="restore""#));
|
||||
assert!(html.contains(r#"data-trash-action="purge""#));
|
||||
assert!(html.contains(r#"data-trash-action="empty-documents""#));
|
||||
assert!(html.contains(r#"data-document-id="page_trash""#));
|
||||
assert!(html.contains(r#"data-trash-action="empty-resources""#));
|
||||
assert!(html.contains(r#"data-trash-action="resource-restore""#));
|
||||
assert!(html.contains(r#"data-trash-action="resource-purge""#));
|
||||
assert!(html.contains(r#"data-resource-kind="media""#));
|
||||
assert!(html.contains(r#"data-resource-kind="mindmap""#));
|
||||
assert!(html.contains(r#"data-resource-kind="table""#));
|
||||
assert!(html.contains("已删附件.png"));
|
||||
assert!(html.contains("已删思维导图.json"));
|
||||
assert!(html.contains("已删表格.luckysheet"));
|
||||
assert!(html.contains("new EventSource"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("refreshTrashWorkbenchFromServer"));
|
||||
assert!(!html.contains("window.location.reload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
|
||||
let selected = super::choose_root_entry_active_page_id(
|
||||
|
||||
Reference in New Issue
Block a user