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:
@@ -82,6 +82,12 @@ pub struct DocumentPurgeRequest {
|
||||
pub document_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentEmptyTrashRequest {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
|
||||
@@ -651,6 +657,87 @@ pub async fn purge(
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn empty_trash(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentEmptyTrashRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let workspace_id = body.workspace_id.trim();
|
||||
if workspace_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("workspace_id_required", "缺少有效 workspaceId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "documents.emptyTrashByWorkspace".into(),
|
||||
command_id: format!("documents_empty_trash_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
page_id: None,
|
||||
block_id: None,
|
||||
}),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web documents empty trash".into()),
|
||||
refs: vec!["mnote-web-documents-trash".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
state.config(),
|
||||
&context,
|
||||
Some(workspace_id),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let mut result = execution.result;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert(
|
||||
"canonicalCommand".into(),
|
||||
json!("documents.emptyTrashByWorkspace"),
|
||||
);
|
||||
map.insert("compatRoute".into(), json!("/api/documents/empty-trash"));
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"meta": {
|
||||
"commandName": "documents.emptyTrashByWorkspace",
|
||||
"canonicalCommand": "documents.emptyTrashByWorkspace",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
"result": result,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn title(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -926,6 +1013,10 @@ mod tests {
|
||||
"revision": 8,
|
||||
"conflict_detection_key": "doc_1:8"
|
||||
},
|
||||
"documents:emptyTrashByWorkspace": {
|
||||
"ok": true,
|
||||
"deletedCount": 2
|
||||
},
|
||||
"bridgeLogs:recordCommandLog": {
|
||||
"ok": true,
|
||||
"id": "clog_fixture"
|
||||
@@ -1063,6 +1154,50 @@ mod tests {
|
||||
assert_eq!(payload["result"]["canonicalCommand"], "page.body.save");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_empty_trash_route_executes_workspace_trash_purge() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/empty-trash")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"workspaceId": "ws_demo"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["deletedCount"], 2);
|
||||
assert_eq!(
|
||||
payload["result"]["canonicalCommand"],
|
||||
"documents.emptyTrashByWorkspace"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
||||
"documents.emptyTrashByWorkspace"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
|
||||
"tree.trash.documents.emptied"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
||||
"resync_required"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn documents_title_route_executes_page_head_update_title() {
|
||||
let response = app()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -318,7 +318,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_tree_projection_includes_index_and_asset_rows() {
|
||||
async fn file_tree_projection_includes_page_markdown_and_asset_rows() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -345,16 +345,14 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(row_kinds.contains(&"document"));
|
||||
assert!(row_kinds.contains(&"index"));
|
||||
assert!(row_kinds.contains(&"asset"));
|
||||
assert!(row_kinds.contains(&"asset_folder"));
|
||||
assert!(resource_kinds.contains(&"document"));
|
||||
assert!(resource_kinds.contains(&"index"));
|
||||
assert!(resource_kinds.contains(&"asset"));
|
||||
assert!(resource_kinds.contains(&"mindmap"));
|
||||
assert!(resource_kinds.contains(&"table"));
|
||||
assert_eq!(items[0]["nodeId"], "page_root");
|
||||
assert_eq!(items[1]["nodeId"], "index:page_root");
|
||||
assert_eq!(items[0]["title"], "工作区首页.md");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -38,10 +38,28 @@ struct LocalFolderMetadata {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalTrashEntry {
|
||||
#[serde(default, alias = "documentId")]
|
||||
document_id: String,
|
||||
#[serde(default, alias = "resourceKind")]
|
||||
resource_kind: String,
|
||||
#[serde(default, alias = "resourceScope")]
|
||||
resource_scope: String,
|
||||
#[serde(default, alias = "originalFilePath")]
|
||||
original_file_path: String,
|
||||
#[serde(default, alias = "trashedFilePath")]
|
||||
trashed_file_path: String,
|
||||
#[serde(default, alias = "trashEntryId")]
|
||||
trash_entry_id: String,
|
||||
#[serde(default, alias = "originalRelativePath")]
|
||||
original_relative_path: String,
|
||||
#[serde(default, alias = "trashRelativePath")]
|
||||
trash_relative_path: String,
|
||||
#[serde(default, alias = "deletedAtMs")]
|
||||
deleted_at_ms: u128,
|
||||
#[serde(default, alias = "archivedAt")]
|
||||
archived_at: u128,
|
||||
#[serde(default, alias = "purgedAt")]
|
||||
purged_at: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -514,9 +532,9 @@ pub fn execute_local_tree_command(
|
||||
title.unwrap_or("外部文件"),
|
||||
),
|
||||
"move" => move_local_entry(&canonical_root, document_id, parent_id),
|
||||
"delete" | "trash" => trash_local_markdown_page(&canonical_root, document_id),
|
||||
"restore" => restore_local_markdown_page(&canonical_root, document_id),
|
||||
"purge" => purge_local_markdown_page(&canonical_root, document_id),
|
||||
"delete" | "trash" => trash_local_entry(&canonical_root, document_id),
|
||||
"restore" => restore_local_entry(&canonical_root, document_id),
|
||||
"purge" => purge_local_entry(&canonical_root, document_id),
|
||||
other => Err(WebError::bad_request_code(
|
||||
"local_tree_command_unsupported",
|
||||
format!("local_folder 暂不支持 tree action: {other}"),
|
||||
@@ -917,6 +935,35 @@ fn move_local_directory(
|
||||
}))
|
||||
}
|
||||
|
||||
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
if let Some(file) = resolve_local_raw_file_id(root, entry_id)? {
|
||||
return trash_local_raw_file(root, entry_id, &file);
|
||||
}
|
||||
trash_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
fn restore_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
|
||||
return restore_local_raw_file(root, entry_id);
|
||||
}
|
||||
restore_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
fn purge_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let metadata = load_local_folder_metadata(root)?;
|
||||
if find_local_file_trash_entry_key(&metadata, entry_id).is_some() {
|
||||
return purge_local_raw_file(root, entry_id);
|
||||
}
|
||||
if entry_id.starts_with("local:asset:") || entry_id.starts_with("local:node:") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"本地资源必须先进入回收站后才能永久删除",
|
||||
));
|
||||
}
|
||||
purge_local_markdown_page(root, entry_id)
|
||||
}
|
||||
|
||||
fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let markdown_file =
|
||||
@@ -954,9 +1001,16 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
document_id.to_string(),
|
||||
LocalTrashEntry {
|
||||
document_id: document_id.to_string(),
|
||||
resource_kind: "markdown".to_string(),
|
||||
resource_scope: "local_folder".to_string(),
|
||||
original_file_path: markdown_file.relative_path.clone(),
|
||||
trashed_file_path: trash_relative_path.clone(),
|
||||
trash_entry_id: document_id.to_string(),
|
||||
original_relative_path: markdown_file.relative_path,
|
||||
trash_relative_path: trash_relative_path.clone(),
|
||||
deleted_at_ms: now_ms(),
|
||||
archived_at: now_ms(),
|
||||
purged_at: None,
|
||||
},
|
||||
);
|
||||
write_page_ids_metadata(root, &metadata.page_ids)?;
|
||||
@@ -971,6 +1025,68 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let relative_path = normalize_relative_path(root, file)?;
|
||||
if is_markdown_file(&relative_path) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_resource_not_supported",
|
||||
"Markdown 文件必须走页面生命周期,不能走 local_file 资源回收站",
|
||||
));
|
||||
}
|
||||
let trash_dir = root.join(".mnote").join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法创建本地回收站 {}: {error}", trash_dir.display()),
|
||||
)
|
||||
})?;
|
||||
let file_name = file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("local-file");
|
||||
let target = next_available_raw_path(&trash_dir, file_name);
|
||||
fs::rename(file, &target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法移动本地资源到回收站 {}: {error}", file.display()),
|
||||
)
|
||||
})?;
|
||||
let trash_relative_path = normalize_relative_path(root, &target)?;
|
||||
let trash_entry_id = local_file_trash_entry_id(&relative_path);
|
||||
let now = now_ms();
|
||||
metadata.trash_entries.insert(
|
||||
trash_entry_id.clone(),
|
||||
LocalTrashEntry {
|
||||
document_id: entry_id.to_string(),
|
||||
resource_kind: "local_file".to_string(),
|
||||
resource_scope: "local_folder".to_string(),
|
||||
original_file_path: relative_path.clone(),
|
||||
trashed_file_path: trash_relative_path.clone(),
|
||||
trash_entry_id: trash_entry_id.clone(),
|
||||
original_relative_path: relative_path.clone(),
|
||||
trash_relative_path: trash_relative_path.clone(),
|
||||
deleted_at_ms: now,
|
||||
archived_at: now,
|
||||
purged_at: None,
|
||||
},
|
||||
);
|
||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalFilePath": relative_path,
|
||||
"trashEntryId": trash_entry_id,
|
||||
"trashPath": trash_relative_path,
|
||||
"action": "delete",
|
||||
"canonicalCommand": "tree.resource.archive",
|
||||
"sourceKind": "local_folder",
|
||||
}))
|
||||
}
|
||||
|
||||
fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_entry = metadata
|
||||
@@ -1033,6 +1149,73 @@ fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result<Value,
|
||||
}))
|
||||
}
|
||||
|
||||
fn restore_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要恢复的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let trash_entry = metadata
|
||||
.trash_entries
|
||||
.get(&trash_key)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要恢复的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let original_relative_path = local_trash_original_path(&trash_entry);
|
||||
let trash_relative_path = local_trash_file_path(&trash_entry);
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||
if !trash_path.is_file() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"本地资源回收站文件不存在,无法恢复",
|
||||
));
|
||||
}
|
||||
let original_path = resolve_metadata_relative_path(root, &original_relative_path)?;
|
||||
if original_path.exists() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_file_restore_conflict",
|
||||
"本地资源原路径已存在,恢复会覆盖用户文件",
|
||||
));
|
||||
}
|
||||
let parent = original_path.parent().ok_or_else(|| {
|
||||
WebError::bad_request_code("local_tree_command_failed", "无法解析资源恢复目标目录")
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法创建资源恢复目标目录 {}: {error}", parent.display()),
|
||||
)
|
||||
})?;
|
||||
fs::rename(&trash_path, &original_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!("无法从本地回收站恢复资源 {}: {error}", trash_path.display()),
|
||||
)
|
||||
})?;
|
||||
metadata.trash_entries.remove(&trash_key);
|
||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"originalFilePath": original_relative_path,
|
||||
"relativePath": normalize_relative_path(root, &original_path)?,
|
||||
"trashEntryId": trash_key,
|
||||
"action": "restore",
|
||||
"canonicalCommand": "tree.resource.restore",
|
||||
"sourceKind": "local_folder",
|
||||
}))
|
||||
}
|
||||
|
||||
fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? {
|
||||
@@ -1083,6 +1266,47 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
let mut metadata = load_local_folder_metadata(root)?;
|
||||
let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要永久删除的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let trash_entry = metadata.trash_entries.remove(&trash_key).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_trash_entry_not_found",
|
||||
"找不到要永久删除的本地资源回收站记录",
|
||||
)
|
||||
})?;
|
||||
let trash_relative_path = local_trash_file_path(&trash_entry);
|
||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||
if trash_path.exists() {
|
||||
fs::remove_file(&trash_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_tree_command_failed",
|
||||
format!(
|
||||
"无法永久删除本地资源回收站文件 {}: {error}",
|
||||
trash_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"id": entry_id,
|
||||
"documentId": entry_id,
|
||||
"resourceKind": "local_file",
|
||||
"resourceScope": "local_folder",
|
||||
"trashEntryId": trash_key,
|
||||
"action": "purge",
|
||||
"canonicalCommand": "tree.resource.purge",
|
||||
"sourceKind": "local_folder",
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_local_parent_directory(
|
||||
root: &Path,
|
||||
metadata: &LocalFolderMetadata,
|
||||
@@ -1222,6 +1446,65 @@ fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result<Option<PathB
|
||||
}
|
||||
}
|
||||
|
||||
fn local_file_trash_entry_id(relative_path: &str) -> String {
|
||||
format!("local-file:{relative_path}")
|
||||
}
|
||||
|
||||
fn is_local_file_trash_entry(entry: &LocalTrashEntry) -> bool {
|
||||
entry.resource_kind == "local_file" || entry.trash_entry_id.starts_with("local-file:")
|
||||
}
|
||||
|
||||
fn local_trash_original_path(entry: &LocalTrashEntry) -> String {
|
||||
if !entry.original_file_path.trim().is_empty() {
|
||||
entry.original_file_path.clone()
|
||||
} else {
|
||||
entry.original_relative_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn local_trash_file_path(entry: &LocalTrashEntry) -> String {
|
||||
if !entry.trashed_file_path.trim().is_empty() {
|
||||
entry.trashed_file_path.clone()
|
||||
} else {
|
||||
entry.trash_relative_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn find_local_file_trash_entry_key(
|
||||
metadata: &LocalFolderMetadata,
|
||||
entry_id: &str,
|
||||
) -> Option<String> {
|
||||
let trimmed = entry_id.trim();
|
||||
if metadata
|
||||
.trash_entries
|
||||
.get(trimmed)
|
||||
.map(is_local_file_trash_entry)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
let relative_path = trimmed
|
||||
.strip_prefix("local:asset:")
|
||||
.or_else(|| trimmed.strip_prefix("local:node:"))
|
||||
.unwrap_or(trimmed);
|
||||
let expected_key = local_file_trash_entry_id(relative_path);
|
||||
if metadata
|
||||
.trash_entries
|
||||
.get(&expected_key)
|
||||
.map(is_local_file_trash_entry)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(expected_key);
|
||||
}
|
||||
metadata
|
||||
.trash_entries
|
||||
.iter()
|
||||
.find(|(_, entry)| {
|
||||
is_local_file_trash_entry(entry) && local_trash_original_path(entry) == relative_path
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
}
|
||||
|
||||
pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result<String, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name};
|
||||
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
|
||||
use crate::transport::convex::{
|
||||
execute_convex_mutation_by_name, execute_convex_query_by_name,
|
||||
persist_runtime_command_artifacts,
|
||||
};
|
||||
use axum::extract::{Multipart, Query, State};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
@@ -110,6 +120,12 @@ fn new_asset_id() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn now_iso_like() -> String {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
|
||||
}
|
||||
|
||||
fn asset_type(mime: &str) -> &'static str {
|
||||
if mime.starts_with("image/") {
|
||||
"image"
|
||||
@@ -122,6 +138,73 @@ fn asset_type(mime: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_upload_artifacts(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
document_id: &str,
|
||||
asset_id: &str,
|
||||
file: &UploadFile,
|
||||
asset_kind: &str,
|
||||
target_sub_path: Option<&str>,
|
||||
created: &Value,
|
||||
) -> Result<(), WebError> {
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "tree.resource.upload".into(),
|
||||
command_id: format!("resource_upload_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: user_id.to_string(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
page_id: Some(document_id.to_string()),
|
||||
block_id: Some(asset_id.to_string()),
|
||||
}),
|
||||
payload: json!({
|
||||
"assetId": asset_id,
|
||||
"workspaceId": workspace_id,
|
||||
"targetDocumentId": document_id,
|
||||
"targetSubPath": target_sub_path,
|
||||
"fileName": file.name,
|
||||
"fileSize": file.bytes.len(),
|
||||
"mimeType": file.content_type,
|
||||
"assetType": asset_kind,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web media upload tree.resource.upload".into()),
|
||||
refs: vec!["file-tree-resource-upload".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let runtime_context = runtime_context(context, Some(workspace_id));
|
||||
let plan = build_runtime_command_plan(context, Some(workspace_id), command.clone())?;
|
||||
let artifact_result = json!({
|
||||
"items": [created.clone()],
|
||||
});
|
||||
if let Some(artifacts) = build_runtime_command_artifact_plan(
|
||||
&runtime_context,
|
||||
&command,
|
||||
&plan,
|
||||
&artifact_result,
|
||||
&now_iso_like(),
|
||||
) {
|
||||
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_upload_multipart(
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
|
||||
@@ -367,6 +450,26 @@ pub async fn upload(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if let Err(error) = record_upload_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
&user_id,
|
||||
&workspace_id,
|
||||
&document_id,
|
||||
&asset_id,
|
||||
&file,
|
||||
&kind,
|
||||
target_sub_path.as_deref(),
|
||||
&created,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %error.message(),
|
||||
asset_id = %asset_id,
|
||||
"media upload tree.resource.upload artifacts 记录失败,主上传结果继续返回"
|
||||
);
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"asset": created,
|
||||
"mindmapUrl": format!("asset:{asset_id}"),
|
||||
|
||||
@@ -47,10 +47,16 @@ pub async fn mindmap_object_shell(
|
||||
load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html =
|
||||
load_file_tree_html(state.config(), &context, workspace_id, Some(&doc_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let active_filetree_row_id = format!("asset:{mindmap_id}");
|
||||
let file_tree_html = load_file_tree_html(
|
||||
state.config(),
|
||||
&context,
|
||||
workspace_id,
|
||||
Some(&doc_id),
|
||||
Some(active_filetree_row_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
@@ -282,7 +288,52 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
query_fixtures_json: Some(
|
||||
serde_json::json!({
|
||||
"sidebar:datasetList": {
|
||||
"active_workspace_id": "ws_demo",
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0
|
||||
}
|
||||
],
|
||||
"media_assets": [],
|
||||
"mindmap_assets": [
|
||||
{
|
||||
"id": "mind_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"document_id": "doc_1",
|
||||
"asset_type": "mindmap",
|
||||
"file_name": "思维导图.json",
|
||||
"mime_type": "application/json"
|
||||
}
|
||||
],
|
||||
"table_assets": [],
|
||||
"trashed_documents": [],
|
||||
"trashed_media_assets": [],
|
||||
"trashed_mindmap_assets": [],
|
||||
"trashed_table_assets": [],
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
},
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "页面"
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [],
|
||||
"revision": 1,
|
||||
"conflict_detection_key": "doc_1:1",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -374,6 +425,9 @@ mod tests {
|
||||
assert!(html.contains("\"standaloneObject\""));
|
||||
assert!(html.contains("\"documentId\":\"doc_1\""));
|
||||
assert!(html.contains("\"mindmapId\":\"mind_1\""));
|
||||
assert!(html.contains("data-row-id=\"asset:mind_1\""));
|
||||
assert!(html.contains("data-selected=\"true\""));
|
||||
assert!(!html.contains("data-row-id=\"doc:doc_1\" data-row-kind=\"document\" data-node-id=\"doc_1\" data-document-id=\"doc_1\" data-doc-id=\"doc_1\" data-asset-id=\"\" data-object-identity=\"{"objectKind":"page","documentId":"doc_1","blockId":null,"assetId":null}\" data-shell-mode=\"filetree\" data-selected=\"true\""));
|
||||
assert!(html.contains("runtimeModule.mount(mountTarget, bootstrap)"));
|
||||
assert!(html.contains("/api/leptos-tiptap-runtime/manifest.json"));
|
||||
assert!(!html.contains("react_mindmap_runtime"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -781,6 +781,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_preserves_resync_required_delta_contract() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "documents.emptyTrashByWorkspace",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "resync_required",
|
||||
"reason": "documents_empty_trash",
|
||||
"documentId": null,
|
||||
"blockId": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": [
|
||||
{
|
||||
"id": "evt_2",
|
||||
"event_id": "evt_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "resync_required",
|
||||
"reason": "documents_empty_trash",
|
||||
"documentId": null,
|
||||
"blockId": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "evt_1",
|
||||
"event_id": "evt_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.cursor,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into())
|
||||
);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "resync_required",
|
||||
"reason": "documents_empty_trash",
|
||||
"documentId": null,
|
||||
"blockId": null
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_preserves_move_document_delta_fields() {
|
||||
let overview = json!({
|
||||
|
||||
@@ -1871,6 +1871,219 @@ body {
|
||||
background: var(--atelier-document);
|
||||
}
|
||||
|
||||
.mnote-trash-workbench {
|
||||
width: min(860px, calc(100vw - 64px));
|
||||
margin: 52px auto;
|
||||
color: var(--atelier-text);
|
||||
}
|
||||
|
||||
.mnote-trash-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 48px 32px;
|
||||
background: rgba(25, 24, 22, 0.34);
|
||||
}
|
||||
|
||||
.mnote-trash-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.mnote-trash-modal__panel {
|
||||
position: relative;
|
||||
width: min(944px, calc(100vw - 96px));
|
||||
height: min(870px, calc(100vh - 112px));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(27, 28, 28, 0.14);
|
||||
border-radius: 6px;
|
||||
background: var(--atelier-document);
|
||||
box-shadow: 0 18px 52px rgba(27, 28, 28, 0.22);
|
||||
}
|
||||
|
||||
.mnote-trash-modal__content {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mnote-trash-modal__content .mnote-trash-workbench {
|
||||
width: auto;
|
||||
margin: 28px 32px 40px;
|
||||
}
|
||||
|
||||
.mnote-trash-modal__close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
z-index: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #FFFFFF;
|
||||
color: #37352F;
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-trash-modal__close:hover {
|
||||
background: #F7F7F6;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mnote-trash-modal {
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.mnote-trash-modal__panel {
|
||||
width: calc(100vw - 24px);
|
||||
height: min(780px, calc(100vh - 48px));
|
||||
}
|
||||
|
||||
.mnote-trash-modal__content .mnote-trash-workbench {
|
||||
margin: 22px 18px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.mnote-trash-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.mnote-trash-header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 650;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
.mnote-trash-header p {
|
||||
margin: 0;
|
||||
color: #6D6A65;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.mnote-trash-status[data-type="error"] {
|
||||
color: #C93A32;
|
||||
}
|
||||
|
||||
.mnote-trash-status[data-type="success"] {
|
||||
color: #23834D;
|
||||
}
|
||||
|
||||
.mnote-trash-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.mnote-trash-section-title {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mnote-trash-section h2 {
|
||||
margin: 0;
|
||||
color: #5A5A5A;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mnote-trash-section h2 span {
|
||||
color: #9B9A97;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mnote-trash-row {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.mnote-trash-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mnote-trash-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--atelier-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-trash-meta,
|
||||
.mnote-trash-kind,
|
||||
.mnote-trash-note,
|
||||
.mnote-trash-empty {
|
||||
color: #8B8782;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-trash-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-trash-actions button {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #FFFFFF;
|
||||
color: #37352F;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-trash-section-title button {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #FFFFFF;
|
||||
color: #37352F;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-trash-actions button:hover:not(:disabled) {
|
||||
background: #F7F7F6;
|
||||
}
|
||||
|
||||
.mnote-trash-section-title button:hover:not(:disabled) {
|
||||
background: #F7F7F6;
|
||||
}
|
||||
|
||||
.mnote-trash-actions button:disabled,
|
||||
.mnote-trash-section-title button:disabled {
|
||||
color: #9B9A97;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wolai-topbar {
|
||||
height: 40px;
|
||||
padding: 0 20px 0 22px;
|
||||
@@ -3056,40 +3269,70 @@ body {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #FFF;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-row:hover {
|
||||
background: #F7F7F6;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #5A5A5A;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-source {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: #F0EFED;
|
||||
color: #8B8782;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-skill-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 22px;
|
||||
flex: 0 0 36px;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex: 0 0 32px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #D8D6D1;
|
||||
@@ -3098,10 +3341,10 @@ body {
|
||||
|
||||
.wolai-page-ai-skill-switch span {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #FFF;
|
||||
box-shadow: 0 1px 3px rgba(27, 28, 28, 0.16);
|
||||
|
||||
@@ -391,6 +391,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
) {
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
"tree.resource.archive"
|
||||
| "tree.resource.restore"
|
||||
| "tree.resource.rename"
|
||||
| "tree.resource.purge"
|
||||
) {
|
||||
args = convex_resource_lifecycle_args_for_plan(plan, &args);
|
||||
}
|
||||
if matches!(plan.command_name.as_str(), "mindmaps.put")
|
||||
|| matches!(plan.function_name.as_str(), "mindmaps:put")
|
||||
{
|
||||
@@ -433,6 +442,69 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
args
|
||||
}
|
||||
|
||||
fn convex_resource_lifecycle_args_for_plan(
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
args: &Value,
|
||||
) -> Value {
|
||||
let lifecycle = args.get("resourceLifecyclePlan").and_then(Value::as_object);
|
||||
let action = lifecycle
|
||||
.and_then(|value| value.get("action"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let resource_kind = lifecycle
|
||||
.and_then(|value| value.get("resourceKind"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("file");
|
||||
if resource_kind != "file" {
|
||||
return args.clone();
|
||||
}
|
||||
let id = args
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let user_id = args
|
||||
.get("userId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(plan.actor_id.as_str())
|
||||
.to_string();
|
||||
match action {
|
||||
"archive" => json!({
|
||||
"userId": user_id,
|
||||
"id": id,
|
||||
"patch": {
|
||||
"deleted_at": now_iso_like(),
|
||||
"deleted_by": user_id,
|
||||
"purged_at": null,
|
||||
},
|
||||
}),
|
||||
"restore" => json!({
|
||||
"userId": user_id,
|
||||
"id": id,
|
||||
"patch": {
|
||||
"deleted_at": null,
|
||||
"deleted_by": null,
|
||||
"purged_at": null,
|
||||
},
|
||||
}),
|
||||
"rename" => json!({
|
||||
"userId": user_id,
|
||||
"id": id,
|
||||
"patch": {
|
||||
"file_name": args.get("newName").and_then(Value::as_str).unwrap_or_default(),
|
||||
},
|
||||
}),
|
||||
"purge" => json!({
|
||||
"userId": user_id,
|
||||
"id": id,
|
||||
"expiredDeletedAt": "2126-01-01T00:00:00Z",
|
||||
}),
|
||||
_ => args.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_tree_artifact_fields(args: &mut Value) {
|
||||
if let Value::Object(map) = args {
|
||||
map.remove("streamDeltaHint");
|
||||
@@ -996,6 +1068,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_resource_lifecycle_args_keep_effective_user_id() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "tree.resource.archive".into(),
|
||||
command_id: "cmd_resource_archive_1".into(),
|
||||
function_name: "mediaAssets:patchById".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "anonymous".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "asset_1",
|
||||
"userId": "convex_user_1",
|
||||
"resourceKind": "file",
|
||||
"resourceLifecyclePlan": {
|
||||
"resourceKind": "file",
|
||||
"action": "archive",
|
||||
"assetId": "asset_1"
|
||||
},
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_asset"},
|
||||
"domainEventHint": {"eventType": "tree.resource.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.resource.archived"},
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(args["userId"], "convex_user_1");
|
||||
assert_eq!(args["id"], "asset_1");
|
||||
assert!(args.get("resourceLifecyclePlan").is_none());
|
||||
assert!(args.get("streamDeltaHint").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
|
||||
@@ -179,6 +179,11 @@ pub enum TreeShellCommandEvent {
|
||||
target_document_id: String,
|
||||
file_count: u32,
|
||||
},
|
||||
ResourceLifecycle {
|
||||
command_name: String,
|
||||
resource_kind: String,
|
||||
asset_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellRuntimeResult {
|
||||
@@ -849,6 +854,26 @@ mod tests {
|
||||
target_document_id: "doc:target".into(),
|
||||
file_count: 2,
|
||||
},
|
||||
TreeShellCommandEvent::ResourceLifecycle {
|
||||
command_name: "tree.resource.archive".into(),
|
||||
resource_kind: "file".into(),
|
||||
asset_id: "asset:a".into(),
|
||||
},
|
||||
TreeShellCommandEvent::ResourceLifecycle {
|
||||
command_name: "tree.resource.restore".into(),
|
||||
resource_kind: "file".into(),
|
||||
asset_id: "asset:a".into(),
|
||||
},
|
||||
TreeShellCommandEvent::ResourceLifecycle {
|
||||
command_name: "tree.resource.purge".into(),
|
||||
resource_kind: "file".into(),
|
||||
asset_id: "asset:a".into(),
|
||||
},
|
||||
TreeShellCommandEvent::ResourceLifecycle {
|
||||
command_name: "tree.resource.rename".into(),
|
||||
resource_kind: "file".into(),
|
||||
asset_id: "asset:a".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let encoded = serde_json::to_value(&events).expect("command events should serialize");
|
||||
@@ -892,6 +917,30 @@ mod tests {
|
||||
"commandName": "tree.resource.upload",
|
||||
"targetDocumentId": "doc:target",
|
||||
"fileCount": 2
|
||||
},
|
||||
{
|
||||
"kind": "resourceLifecycle",
|
||||
"commandName": "tree.resource.archive",
|
||||
"resourceKind": "file",
|
||||
"assetId": "asset:a"
|
||||
},
|
||||
{
|
||||
"kind": "resourceLifecycle",
|
||||
"commandName": "tree.resource.restore",
|
||||
"resourceKind": "file",
|
||||
"assetId": "asset:a"
|
||||
},
|
||||
{
|
||||
"kind": "resourceLifecycle",
|
||||
"commandName": "tree.resource.purge",
|
||||
"resourceKind": "file",
|
||||
"assetId": "asset:a"
|
||||
},
|
||||
{
|
||||
"kind": "resourceLifecycle",
|
||||
"commandName": "tree.resource.rename",
|
||||
"resourceKind": "file",
|
||||
"assetId": "asset:a"
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
@@ -412,9 +412,8 @@ mod tests {
|
||||
|
||||
assert!(degraded_projection.degraded);
|
||||
assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\""));
|
||||
assert!(degraded_html.contains(
|
||||
"data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\""
|
||||
));
|
||||
assert!(degraded_html
|
||||
.contains("data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\""));
|
||||
|
||||
let dev_dataset = json!({
|
||||
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
|
||||
@@ -486,9 +485,18 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
.bottom_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let extra_attrs = if entry.id == "trash" {
|
||||
format!(
|
||||
r#" data-testid="mnote-sidebar-trash-entry" data-mnote-action="open-trash-modal" data-workspace-id="{}""#,
|
||||
escape_html(&projection.workspace_id),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
r#"<a href="{}" class="wolai-footer-entry">{}{}</a>"#,
|
||||
r#"<a href="{}" class="wolai-footer-entry"{}>{}{}</a>"#,
|
||||
escape_html(&entry.href),
|
||||
extra_attrs,
|
||||
render_symbol(&entry.icon, "wolai-footer-icon"),
|
||||
escape_html(&entry.label),
|
||||
)
|
||||
@@ -500,7 +508,12 @@ pub fn render_workspace_shell_sidebar_html(
|
||||
degraded_marker = if projection.degraded {
|
||||
format!(
|
||||
r#"<span hidden data-mnote-workspace-shell-degraded="true" data-mnote-workspace-shell-degraded-reason="{}"></span>"#,
|
||||
escape_html(projection.degraded_reason.as_deref().unwrap_or("projection_unavailable")),
|
||||
escape_html(
|
||||
projection
|
||||
.degraded_reason
|
||||
.as_deref()
|
||||
.unwrap_or("projection_unavailable")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
|
||||
Reference in New Issue
Block a user