推进本地优先迁移与资源闭环
- 补齐共享只读页面 AI 浏览器 smoke,并回填当前优先级 checklist 的 P4/P5/P6 证据。
- 为 OnlyOffice 资源增加 /office/{documentId}/{assetId} 对象壳,固定 resource identity 与侧边栏打开路径。
- 扩展 Convex 导出脚本,支持 dry-run、manifest、冲突报告、索引刷新与 rollback,并补充 smoke。
- 补充资源 AI 工具合同与 Convex 导出 Web 入口设计。
This commit is contained in:
@@ -257,6 +257,19 @@ fn json_string(value: &str) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into())
|
||||
}
|
||||
|
||||
fn encode_query_component(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.as_bytes() {
|
||||
let ch = *byte as char;
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
|
||||
encoded.push(ch);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
@@ -630,6 +643,67 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
pub async fn object_shell(
|
||||
Path((document_id, asset_id)): Path<(String, String)>,
|
||||
Query(query): Query<OnlyOfficePageQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let document_id = document_id.trim();
|
||||
let asset_id = asset_id.trim();
|
||||
if document_id.is_empty() || asset_id.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"onlyoffice_resource_identity_required",
|
||||
"缺少有效 documentId 或 assetId",
|
||||
));
|
||||
}
|
||||
let file_name = query.file_name.unwrap_or_else(|| "附件".into());
|
||||
let file_type = query.file_type.unwrap_or_else(|| "docx".into());
|
||||
let mode = query.mode.unwrap_or_else(|| "edit".into());
|
||||
let user_id = query.user_id.unwrap_or_default();
|
||||
let file_url = query.file_url.unwrap_or_default();
|
||||
let onlyoffice_url = {
|
||||
let mut params = Vec::new();
|
||||
params.push(("fileUrl", file_url.as_str()));
|
||||
params.push(("fileName", file_name.as_str()));
|
||||
params.push(("fileType", file_type.as_str()));
|
||||
params.push(("assetId", asset_id));
|
||||
params.push(("documentId", document_id));
|
||||
params.push(("userId", user_id.as_str()));
|
||||
params.push(("mode", mode.as_str()));
|
||||
let query = params
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={}", encode_query_component(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
format!("/onlyoffice?{query}")
|
||||
};
|
||||
let object_identity = format!("resource:onlyoffice:{document_id}:{asset_id}");
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
html, body, .mnote-office-object-shell, iframe {{ width: 100%; height: 100%; margin: 0; border: 0; }}
|
||||
body {{ overflow: hidden; background: #f7f7f5; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="mnote-office-object-shell" data-mnote-object-editor="onlyoffice" data-mnote-object-identity="{object_identity}" data-document-id="{document_id}" data-asset-id="{asset_id}">
|
||||
<iframe src="{onlyoffice_url}" title="{title}" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(&file_name),
|
||||
object_identity = escape_html(&object_identity),
|
||||
document_id = escape_html(document_id),
|
||||
asset_id = escape_html(asset_id),
|
||||
onlyoffice_url = escape_html(&onlyoffice_url),
|
||||
);
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
pub async fn sign(Json(payload): Json<OnlyOfficeSignPayload>) -> Result<Response, WebError> {
|
||||
let config = payload.config.ok_or_else(|| {
|
||||
WebError::bad_request_code("onlyoffice_sign_config_missing", "缺少 config")
|
||||
@@ -1117,6 +1191,37 @@ mod tests {
|
||||
assert!(key.starts_with("asset_1_"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_object_shell_exposes_resource_identity() {
|
||||
let response = object_shell(
|
||||
Path(("doc_1".into(), "asset_docx".into())),
|
||||
Query(OnlyOfficePageQuery {
|
||||
file_url: Some("/api/media/sign?assetId=asset_docx".into()),
|
||||
file_name: Some("方案.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
asset_id: None,
|
||||
document_id: None,
|
||||
user_id: Some("user_1".into()),
|
||||
mode: Some("edit".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("object shell");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("data-mnote-object-editor=\"onlyoffice\""));
|
||||
assert!(
|
||||
html.contains("data-mnote-object-identity=\"resource:onlyoffice:doc_1:asset_docx\"")
|
||||
);
|
||||
assert!(html.contains("/onlyoffice?"));
|
||||
assert!(html.contains("assetId=asset_docx"));
|
||||
assert!(html.contains("documentId=doc_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onlyoffice_internal_candidates_keep_default_first_after_env() {
|
||||
let candidates = onlyoffice_internal_candidates();
|
||||
|
||||
Reference in New Issue
Block a user