收口 Rust Web 入口与 AI 写入链
- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。 - 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。 - 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。 - 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
@@ -1,21 +1,220 @@
|
||||
//! MNOTE 登录页面组件
|
||||
|
||||
use super::layout::PageLayout;
|
||||
use leptos::prelude::*;
|
||||
|
||||
const TEST_ACCOUNT_EMAIL: &str = "mnote.e2e@example.com";
|
||||
const TEST_ACCOUNT_PASSWORD: &str = "MnoteE2E123!";
|
||||
const TEST_ACCOUNT_NAME: &str = "mnote-e2e";
|
||||
|
||||
/// MNOTE 登录页面
|
||||
///
|
||||
/// 当 legacy compat 关闭时由 `auth_entry` 路由使用。
|
||||
#[component]
|
||||
pub fn AuthPage() -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout current_nav="home">
|
||||
<section class="mnote-auth">
|
||||
<h1>MNOTE 登录</h1>
|
||||
<p>
|
||||
"此页面由 mnote-web gateway 提供。"
|
||||
</p>
|
||||
<main class="mnote-auth" data-testid="mnote-auth-page">
|
||||
<a class="mnote-auth-brand" href="/auth" aria-label="MNOTE">
|
||||
<span class="mnote-auth-brand-mark" aria-hidden="true">"M"</span>
|
||||
<span>"MNOTE"</span>
|
||||
</a>
|
||||
<section class="mnote-auth-panel" aria-labelledby="mnote-auth-title">
|
||||
<div class="mnote-auth-heading">
|
||||
<h1 id="mnote-auth-title">"邮箱登录"</h1>
|
||||
<p>"登录后进入你的工作区"</p>
|
||||
</div>
|
||||
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="convex-password">
|
||||
<input type="hidden" name="action" value="auth:signIn" />
|
||||
<input type="hidden" name="provider" value="password" />
|
||||
<input type="hidden" name="flow" value="signIn" data-auth-flow />
|
||||
<label class="mnote-auth-field">
|
||||
<span>"邮箱"</span>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="mnote-auth-field mnote-auth-username" hidden>
|
||||
<span>"用户名"</span>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="用于显示的用户名"
|
||||
/>
|
||||
</label>
|
||||
<label class="mnote-auth-field">
|
||||
<span>"密码"</span>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p class="mnote-auth-message" role="status" aria-live="polite" data-auth-message></p>
|
||||
<button class="mnote-auth-submit" type="submit" data-auth-submit>"登录"</button>
|
||||
<button
|
||||
class="mnote-auth-quick-login"
|
||||
type="button"
|
||||
data-auth-test-login
|
||||
data-test-email=TEST_ACCOUNT_EMAIL
|
||||
data-test-password=TEST_ACCOUNT_PASSWORD
|
||||
data-test-name=TEST_ACCOUNT_NAME
|
||||
>
|
||||
"测试账号快速登录"
|
||||
</button>
|
||||
</form>
|
||||
<button class="mnote-auth-switch" type="button" data-auth-switch data-flow="signIn">
|
||||
"没有账号?注册"
|
||||
</button>
|
||||
</section>
|
||||
</PageLayout>
|
||||
<script>{AUTH_SCRIPT}</script>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var root = document.querySelector('[data-testid="mnote-auth-page"]');
|
||||
if (!root) return;
|
||||
var form = root.querySelector('form[data-auth-mode="convex-password"]');
|
||||
var flowInput = root.querySelector('[data-auth-flow]');
|
||||
var usernameField = root.querySelector('.mnote-auth-username');
|
||||
var usernameInput = root.querySelector('#username');
|
||||
var title = root.querySelector('#mnote-auth-title');
|
||||
var subtitle = root.querySelector('.mnote-auth-heading p');
|
||||
var submit = root.querySelector('[data-auth-submit]');
|
||||
var quickLogin = root.querySelector('[data-auth-test-login]');
|
||||
var switcher = root.querySelector('[data-auth-switch]');
|
||||
var message = root.querySelector('[data-auth-message]');
|
||||
if (!form || !flowInput || !submit || !quickLogin || !switcher || !message) return;
|
||||
|
||||
function setMessage(text, type) {
|
||||
message.textContent = text || '';
|
||||
message.dataset.type = type || '';
|
||||
}
|
||||
|
||||
function setBusy(isBusy) {
|
||||
submit.disabled = !!isBusy;
|
||||
quickLogin.disabled = !!isBusy;
|
||||
switcher.disabled = !!isBusy;
|
||||
}
|
||||
|
||||
function setFlow(nextFlow) {
|
||||
var isSignUp = nextFlow === 'signUp';
|
||||
flowInput.value = nextFlow;
|
||||
switcher.dataset.flow = nextFlow;
|
||||
if (title) title.textContent = isSignUp ? '创建账号' : '邮箱登录';
|
||||
if (subtitle) subtitle.textContent = isSignUp ? '创建账号后进入你的工作区' : '登录后进入你的工作区';
|
||||
submit.textContent = isSignUp ? '注册并登录' : '登录';
|
||||
switcher.textContent = isSignUp ? '已有账号?登录' : '没有账号?注册';
|
||||
if (usernameField) usernameField.hidden = !isSignUp;
|
||||
if (usernameInput) usernameInput.required = isSignUp;
|
||||
quickLogin.hidden = isSignUp;
|
||||
var passwordInput = root.querySelector('#password');
|
||||
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
|
||||
setMessage('', '');
|
||||
}
|
||||
|
||||
function buildPayload(flow, email, password, username) {
|
||||
var payload = {
|
||||
action: 'auth:signIn',
|
||||
args: {
|
||||
provider: 'password',
|
||||
params: {
|
||||
email: email,
|
||||
password: password,
|
||||
flow: flow
|
||||
}
|
||||
}
|
||||
};
|
||||
if (flow === 'signUp' && username) {
|
||||
payload.args.params.name = username;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAuth(flow, email, password, username) {
|
||||
var response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(buildPayload(flow, email, password, username))
|
||||
});
|
||||
var data = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(data.error || '登录失败,请重试');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
switcher.addEventListener('click', function () {
|
||||
setFlow(flowInput.value === 'signIn' ? 'signUp' : 'signIn');
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
var email = String((root.querySelector('#email') || {}).value || '').trim();
|
||||
var password = String((root.querySelector('#password') || {}).value || '');
|
||||
var username = String((usernameInput || {}).value || '').trim();
|
||||
var flow = flowInput.value === 'signUp' ? 'signUp' : 'signIn';
|
||||
if (!email || !password || (flow === 'signUp' && !username)) {
|
||||
setMessage('请完整填写信息', 'error');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage(flow === 'signUp' ? '正在创建账号...' : '正在登录...', 'info');
|
||||
try {
|
||||
await requestAuth(flow, email, password, username);
|
||||
setMessage('登录成功,正在进入工作区...', 'success');
|
||||
window.location.assign('/');
|
||||
} catch (error) {
|
||||
setMessage(error && error.message ? error.message : '登录失败,请重试', 'error');
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
quickLogin.addEventListener('click', async function () {
|
||||
var email = String(quickLogin.dataset.testEmail || '').trim();
|
||||
var password = String(quickLogin.dataset.testPassword || '');
|
||||
var username = String(quickLogin.dataset.testName || '').trim();
|
||||
if (!email || !password || !username) {
|
||||
setMessage('测试账号配置缺失,请联系开发者', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var emailInput = root.querySelector('#email');
|
||||
var passwordInput = root.querySelector('#password');
|
||||
if (emailInput) emailInput.value = email;
|
||||
if (passwordInput) passwordInput.value = password;
|
||||
|
||||
setBusy(true);
|
||||
setMessage('正在登录测试账号...', 'info');
|
||||
try {
|
||||
await requestAuth('signIn', email, password, username);
|
||||
setMessage('登录成功,正在进入工作区...', 'success');
|
||||
window.location.assign('/');
|
||||
return;
|
||||
} catch (_signInError) {
|
||||
setMessage('测试账号不存在,正在自动创建...', 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
await requestAuth('signUp', email, password, username);
|
||||
setMessage('测试账号已创建,正在进入工作区...', 'success');
|
||||
window.location.assign('/');
|
||||
} catch (error) {
|
||||
setMessage(error && error.message ? error.message : '测试账号登录失败,请重试', 'error');
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
setFlow('signIn');
|
||||
})();
|
||||
"#;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use crate::ssr::pages::layout::PageLayout;
|
||||
use leptos::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
/// MNOTE 文档页面
|
||||
///
|
||||
@@ -33,6 +34,9 @@ pub fn DocumentPage(
|
||||
/// Page Aggregate 子树 JSON(可选)
|
||||
#[prop(optional)]
|
||||
page_subtree_json: Option<String>,
|
||||
/// Page Aggregate 页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
page_options_json: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let has_page_subtree = page_subtree_json
|
||||
.as_deref()
|
||||
@@ -44,9 +48,45 @@ pub fn DocumentPage(
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "个人空间".to_string());
|
||||
let page_options = page_options_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let page_wide_layout = page_options
|
||||
.get("wideLayout")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let page_small_text = page_options
|
||||
.get("smallText")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let page_layout_density = page_options
|
||||
.get("layoutDensity")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normal")
|
||||
.to_string();
|
||||
let page_font = page_options
|
||||
.get("pageFont")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let page_show_heading_numbers = page_options
|
||||
.get("showHeadingNumbers")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
|
||||
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()} data-workspace-id={workspace_id.clone()}>
|
||||
<main
|
||||
class="document-shell"
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-document-id={document_id.clone()}
|
||||
data-workspace-id={workspace_id.clone()}
|
||||
data-page-wide-layout={page_wide_layout.to_string()}
|
||||
data-page-small-text={page_small_text.to_string()}
|
||||
data-layout-density={page_layout_density.clone()}
|
||||
data-page-font={page_font.clone()}
|
||||
data-page-show-heading-numbers={page_show_heading_numbers.to_string()}
|
||||
>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
|
||||
|
||||
@@ -17,6 +17,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var activeFileTreeDropRow = null;
|
||||
var projectionRefreshTimer = 0;
|
||||
var activeTreeContextMenu = null;
|
||||
var pageUiState = {
|
||||
pageOptions: null,
|
||||
historySnapshots: [],
|
||||
pageSettingsOpen: false,
|
||||
pageAiOpen: false,
|
||||
pageAiBusy: false,
|
||||
pageAiMessages: [],
|
||||
pageAiSuggestionIndex: 0
|
||||
};
|
||||
|
||||
function closestAction(target, selector) {
|
||||
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
||||
@@ -36,11 +45,178 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseJsonScript(id) {
|
||||
var node = document.getElementById(id);
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || 'null');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentDocumentId() {
|
||||
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function currentPageAggregate() {
|
||||
return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {};
|
||||
}
|
||||
|
||||
function defaultPageOptions() {
|
||||
return {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
layoutDensity: 'normal',
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: 'default',
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageOptions() {
|
||||
if (pageUiState.pageOptions) return pageUiState.pageOptions;
|
||||
var aggregate = currentPageAggregate();
|
||||
var current = aggregate && aggregate.layout && aggregate.layout.pageOptions && typeof aggregate.layout.pageOptions === 'object'
|
||||
? aggregate.layout.pageOptions
|
||||
: null;
|
||||
if (!current) {
|
||||
return defaultPageOptions();
|
||||
}
|
||||
pageUiState.pageOptions = Object.assign(defaultPageOptions(), current);
|
||||
return pageUiState.pageOptions;
|
||||
}
|
||||
|
||||
function pageOptionIsSupported(name) {
|
||||
return name === 'wideLayout'
|
||||
|| name === 'smallText'
|
||||
|| name === 'layoutDensity'
|
||||
|| name === 'pageFont'
|
||||
|| name === 'showHeadingNumbers';
|
||||
}
|
||||
|
||||
function pageOptionDescription(name) {
|
||||
if (name === 'wideLayout') return '自适应宽度';
|
||||
if (name === 'smallText') return '小字体';
|
||||
if (name === 'showToc') return '标题目录';
|
||||
if (name === 'showHeadingNumbers') return '标题自动编号';
|
||||
if (name === 'protectEditing') return '编辑保护';
|
||||
if (name === 'collapseBacklinks') return '折叠反向引用';
|
||||
if (name === 'hideChildPages') return '隐藏子页面';
|
||||
if (name === 'showBlockRefCount') return '显示块引用数字';
|
||||
return name;
|
||||
}
|
||||
|
||||
function pageOptionHint(name) {
|
||||
if (name === 'wideLayout') return '已接通:主内容列宽度立即变化';
|
||||
if (name === 'smallText') return '已接通:正文排版会更紧凑';
|
||||
if (name === 'showHeadingNumbers') return '已接通:标题前显示顺序编号';
|
||||
if (name === 'layoutDensity') return '已接通:段落与列表间距会变化';
|
||||
if (name === 'pageFont') return '已接通:当前页面字体会切换';
|
||||
if (name === 'showToc') return '待接线:当前 Rust 壳还没有正式目录面板';
|
||||
if (name === 'protectEditing') return '待接线:当前主编辑器只显示降级说明';
|
||||
if (name === 'collapseBacklinks') return '待接线:当前 Rust 壳未挂回链面板';
|
||||
if (name === 'hideChildPages') return '待接线:当前页面壳还没有子页面块显隐';
|
||||
if (name === 'showBlockRefCount') return '待接线:当前页面壳未显示块引用计数';
|
||||
return '待接线';
|
||||
}
|
||||
|
||||
function ensureHistorySnapshotsSeeded() {
|
||||
if (pageUiState.historySnapshots.length > 0) return;
|
||||
var aggregate = currentPageAggregate();
|
||||
var stats = aggregate && aggregate.stats && typeof aggregate.stats === 'object' ? aggregate.stats : {};
|
||||
pageUiState.historySnapshots = [{
|
||||
id: 'snapshot-initial',
|
||||
timestamp: Date.now(),
|
||||
stats: {
|
||||
wordCount: Number(stats.wordCount || 0),
|
||||
characterCount: Number(stats.characterCount || 0),
|
||||
blockCount: Number(stats.blockCount || 0),
|
||||
todoTotal: Number(stats.todoTotal || 0),
|
||||
todoDone: Number(stats.todoDone || 0)
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
function computeLivePageStats() {
|
||||
var aggregate = currentPageAggregate();
|
||||
var fallbackStats = aggregate && aggregate.stats && typeof aggregate.stats === 'object' ? aggregate.stats : {};
|
||||
var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) {
|
||||
return {
|
||||
wordCount: Number(fallbackStats.wordCount || 0),
|
||||
characterCount: Number(fallbackStats.characterCount || 0),
|
||||
blockCount: Number(fallbackStats.blockCount || 0),
|
||||
todoTotal: Number(fallbackStats.todoTotal || 0),
|
||||
todoDone: Number(fallbackStats.todoDone || 0)
|
||||
};
|
||||
}
|
||||
var text = (editor.textContent || '').trim();
|
||||
var compact = text.replace(/\s+/g, ' ').trim();
|
||||
var wordCount = compact ? compact.split(' ').filter(Boolean).length : 0;
|
||||
var characterCount = text.replace(/\s/g, '').length;
|
||||
var blockCount = editor.querySelectorAll(':scope > *').length;
|
||||
var todos = Array.from(editor.querySelectorAll('input[type="checkbox"]'));
|
||||
return {
|
||||
wordCount: wordCount || Number(fallbackStats.wordCount || 0),
|
||||
characterCount: characterCount || Number(fallbackStats.characterCount || 0),
|
||||
blockCount: blockCount || Number(fallbackStats.blockCount || 0),
|
||||
todoTotal: todos.length || Number(fallbackStats.todoTotal || 0),
|
||||
todoDone: todos.filter(function(node){ return node.checked; }).length || Number(fallbackStats.todoDone || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function recordPageHistorySnapshot(reason, stats) {
|
||||
ensureHistorySnapshotsSeeded();
|
||||
pageUiState.historySnapshots = [{
|
||||
id: 'snapshot-' + Date.now(),
|
||||
timestamp: Date.now(),
|
||||
reason: reason || 'save',
|
||||
stats: stats || computeLivePageStats()
|
||||
}].concat(pageUiState.historySnapshots).slice(0, 15);
|
||||
renderPageHistoryDrawer();
|
||||
}
|
||||
|
||||
function applyPageOptionsToShell() {
|
||||
var options = currentPageOptions();
|
||||
var shell = document.querySelector('.document-shell');
|
||||
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
|
||||
if (shell instanceof HTMLElement) {
|
||||
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
shell.style.width = '100%';
|
||||
shell.style.maxWidth = options.wideLayout ? '980px' : '760px';
|
||||
}
|
||||
if (editorRoot instanceof HTMLElement) {
|
||||
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
editorRoot.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
|
||||
}
|
||||
if (editorSurface instanceof HTMLElement) {
|
||||
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
editorSurface.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
editorSurface.setAttribute('data-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
}
|
||||
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
document.documentElement.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
document.documentElement.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
|
||||
}
|
||||
|
||||
function normalizeSidebarTreeMode(value) {
|
||||
var mode = String(value || '').trim();
|
||||
return mode === 'filetree' ? 'filetree' : 'page';
|
||||
@@ -131,8 +307,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
document.querySelectorAll('.tree-row[data-node-id="' + cssEscape(nodeId) + '"], .tree-row[data-document-id="' + cssEscape(nodeId) + '"], .tree-row[data-doc-id="' + cssEscape(nodeId) + '"]').forEach(function(row) {
|
||||
if (row instanceof HTMLElement) {
|
||||
if (row.getAttribute('data-shell-mode') === 'filetree') row.setAttribute('data-selected', 'true');
|
||||
else row.setAttribute('data-active', 'true');
|
||||
if (row.getAttribute('data-shell-mode') === 'filetree') {
|
||||
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === 'index:' + nodeId));
|
||||
} else {
|
||||
row.setAttribute('data-active', 'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -195,10 +374,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function updateTitleEverywhere(documentId, title) {
|
||||
if (!documentId) return;
|
||||
var escaped = cssEscape(documentId);
|
||||
var escapedDocRowId = cssEscape('doc:' + documentId);
|
||||
var selectors = [
|
||||
'.tree-row[data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
|
||||
'.tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title',
|
||||
'.tree-row[data-doc-id="' + escaped + '"] > .tree-link > .tree-link-title',
|
||||
'.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
|
||||
'.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title',
|
||||
'.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title',
|
||||
'a[href="/documents/' + escaped + '"] > .wolai-row-title',
|
||||
'a[href^="/documents/' + escaped + '?"] > .wolai-row-title'
|
||||
@@ -322,7 +501,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var children = grouped.get(nodeId) || [];
|
||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
||||
var expanded = expandable && item.expandedByDefault !== false;
|
||||
var selected = rowId === 'doc:' + activeId || rowId === 'index:' + activeId || documentId === activeId;
|
||||
var selected = rowId === 'index:' + activeId;
|
||||
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
|
||||
var toggle = expandable
|
||||
? '<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="' + escapeHtml(rowId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
|
||||
@@ -846,10 +1025,624 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
else openSearchModal();
|
||||
}
|
||||
|
||||
function updatePageSettingsTriggerState() {
|
||||
var trigger = document.querySelector('[data-testid="wolai-page-settings-trigger"]');
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-state', pageUiState.pageSettingsOpen ? 'open' : 'closed');
|
||||
trigger.setAttribute('aria-expanded', pageUiState.pageSettingsOpen ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function updatePageAiTriggerState() {
|
||||
var trigger = document.querySelector('[data-testid="wolai-floating-ai"]');
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-state', pageUiState.pageAiOpen ? 'open' : 'closed');
|
||||
trigger.setAttribute('aria-expanded', pageUiState.pageAiOpen ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function createPageOptionRow(key, type) {
|
||||
var inputType = type || 'checkbox';
|
||||
var supported = pageOptionIsSupported(key);
|
||||
if (inputType === 'checkbox') {
|
||||
return '' +
|
||||
'<label class="wolai-page-setting-row' + (supported ? '' : ' is-pending') + '" data-page-setting-row="' + key + '">' +
|
||||
'<span class="wolai-page-setting-copy">' +
|
||||
'<span class="wolai-page-setting-label">' + escapeHtml(pageOptionDescription(key)) + '</span>' +
|
||||
'<span class="wolai-page-setting-hint">' + escapeHtml(pageOptionHint(key)) + '</span>' +
|
||||
'</span>' +
|
||||
'<input type="checkbox" class="wolai-page-setting-checkbox" data-page-option-checkbox="' + key + '"' + (supported ? '' : ' data-setting-pending="true"') + ' />' +
|
||||
'</label>';
|
||||
}
|
||||
return '' +
|
||||
'<label class="wolai-page-setting-row" data-page-setting-row="' + key + '">' +
|
||||
'<span class="wolai-page-setting-copy">' +
|
||||
'<span class="wolai-page-setting-label">' + escapeHtml(pageOptionDescription(key)) + '</span>' +
|
||||
'<span class="wolai-page-setting-hint">' + escapeHtml(pageOptionHint(key)) + '</span>' +
|
||||
'</span>' +
|
||||
'<select class="wolai-page-setting-select" data-page-option-select="' + key + '">' +
|
||||
'<option value="compact">紧凑</option>' +
|
||||
'<option value="normal">默认</option>' +
|
||||
'<option value="spacious">宽松</option>' +
|
||||
'</select>' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function createPageFontRow() {
|
||||
return '' +
|
||||
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
|
||||
'<span class="wolai-page-setting-copy">' +
|
||||
'<span class="wolai-page-setting-label">页面字体</span>' +
|
||||
'<span class="wolai-page-setting-hint">已接通:仅对当前页面生效</span>' +
|
||||
'</span>' +
|
||||
'<select class="wolai-page-setting-select" data-page-option-select="pageFont">' +
|
||||
'<option value="default">默认</option>' +
|
||||
'<option value="song">宋体</option>' +
|
||||
'<option value="kai">楷体</option>' +
|
||||
'</select>' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function ensurePageHistoryDrawer() {
|
||||
var existing = document.querySelector('[data-testid="wolai-page-history-drawer"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
var drawer = document.createElement('aside');
|
||||
drawer.className = 'wolai-page-history-drawer';
|
||||
drawer.setAttribute('data-testid', 'wolai-page-history-drawer');
|
||||
drawer.setAttribute('data-mnote-surface', 'page-history');
|
||||
drawer.hidden = true;
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-history-panel">' +
|
||||
'<div class="wolai-page-history-header">' +
|
||||
'<div><div class="wolai-page-history-title">页面历史</div><div class="wolai-page-history-subtitle">当前会话内最近保存的 15 个快照。</div></div>' +
|
||||
'<button type="button" class="wolai-surface-close" data-page-history-action="close" aria-label="关闭页面历史">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-history-list" data-page-history-list></div>' +
|
||||
'</div>';
|
||||
drawer.addEventListener('click', function(event) {
|
||||
if (event.target === drawer) closePageHistoryDrawer();
|
||||
});
|
||||
document.body.appendChild(drawer);
|
||||
return drawer;
|
||||
}
|
||||
|
||||
function renderPageHistoryDrawer() {
|
||||
var drawer = ensurePageHistoryDrawer();
|
||||
var list = drawer.querySelector('[data-page-history-list]');
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
ensureHistorySnapshotsSeeded();
|
||||
list.innerHTML = pageUiState.historySnapshots.length
|
||||
? pageUiState.historySnapshots.map(function(snapshot) {
|
||||
var stats = snapshot.stats || {};
|
||||
var label = new Date(snapshot.timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
return '' +
|
||||
'<div class="wolai-page-history-item">' +
|
||||
'<div class="wolai-page-history-item-copy">' +
|
||||
'<div class="wolai-page-history-item-title">' + escapeHtml(label) + '</div>' +
|
||||
'<div class="wolai-page-history-item-meta">字数 ' + Number(stats.wordCount || 0) + ' · 字符 ' + Number(stats.characterCount || 0) + ' · 块数 ' + Number(stats.blockCount || 0) + '</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-page-history-item-ghost" data-page-history-action="noop">仅查看</button>' +
|
||||
'</div>';
|
||||
}).join('')
|
||||
: '<div class="wolai-page-history-empty">尚未产生历史快照,编辑后会自动生成。</div>';
|
||||
}
|
||||
|
||||
function openPageHistoryDrawer() {
|
||||
renderPageHistoryDrawer();
|
||||
var drawer = ensurePageHistoryDrawer();
|
||||
drawer.hidden = false;
|
||||
document.documentElement.setAttribute('data-mnote-page-history-open', 'true');
|
||||
}
|
||||
|
||||
function closePageHistoryDrawer() {
|
||||
var drawer = document.querySelector('[data-testid="wolai-page-history-drawer"]');
|
||||
if (drawer instanceof HTMLElement) drawer.hidden = true;
|
||||
document.documentElement.removeAttribute('data-mnote-page-history-open');
|
||||
}
|
||||
|
||||
function ensurePageShareDialog() {
|
||||
var existing = document.querySelector('[data-testid="wolai-page-share-dialog"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
var dialog = document.createElement('div');
|
||||
dialog.className = 'wolai-page-share-dialog';
|
||||
dialog.setAttribute('data-testid', 'wolai-page-share-dialog');
|
||||
dialog.setAttribute('data-mnote-surface', 'page-share');
|
||||
dialog.hidden = true;
|
||||
dialog.innerHTML = '' +
|
||||
'<div class="wolai-page-share-card" role="dialog" aria-modal="true">' +
|
||||
'<div class="wolai-page-share-header">' +
|
||||
'<div><div class="wolai-page-share-title">公开分享页面</div><div class="wolai-page-share-subtitle">当前 3000 公开入口由 mnote-web 持有。</div></div>' +
|
||||
'<button type="button" class="wolai-surface-close" data-page-share-action="close" aria-label="关闭公开分享页面">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-share-state">' +
|
||||
'<span class="wolai-public-pill wolai-public-pill--inline">全网公开</span>' +
|
||||
'<span class="wolai-page-share-copy">任何拥有链接的人都可以访问当前页面。</span>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-share-url-row">' +
|
||||
'<input class="wolai-page-share-url" type="text" readonly data-page-share-url value="" />' +
|
||||
'<button type="button" class="wolai-page-share-copy-button" data-page-share-action="copy-link">复制链接</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-share-footer">更多共享者、群组公开和权限策略仍待接线。</div>' +
|
||||
'</div>';
|
||||
dialog.addEventListener('click', function(event) {
|
||||
if (event.target === dialog) closePageShareDialog();
|
||||
});
|
||||
document.body.appendChild(dialog);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function openPageShareDialog() {
|
||||
var dialog = ensurePageShareDialog();
|
||||
var input = dialog.querySelector('[data-page-share-url]');
|
||||
if (input instanceof HTMLInputElement) input.value = window.location.href;
|
||||
dialog.hidden = false;
|
||||
document.documentElement.setAttribute('data-mnote-page-share-open', 'true');
|
||||
}
|
||||
|
||||
function closePageShareDialog() {
|
||||
var dialog = document.querySelector('[data-testid="wolai-page-share-dialog"]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
document.documentElement.removeAttribute('data-mnote-page-share-open');
|
||||
}
|
||||
|
||||
function pageAiSuggestions() {
|
||||
var title = searchText(document.querySelector('[data-page-title-current="true"]')?.textContent) || '当前页面';
|
||||
return [
|
||||
'帮我总结《' + title + '》当前内容',
|
||||
'把当前页面改写得更简洁一些',
|
||||
'提炼当前页的关键待办和行动项',
|
||||
'基于当前页内容生成一个三段式摘要'
|
||||
];
|
||||
}
|
||||
|
||||
function ensurePageAiDrawer() {
|
||||
var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
var drawer = document.createElement('aside');
|
||||
drawer.className = 'wolai-page-ai-drawer';
|
||||
drawer.setAttribute('data-testid', 'wolai-page-ai-drawer');
|
||||
drawer.setAttribute('data-mnote-surface', 'page-ai');
|
||||
drawer.hidden = true;
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-header">' +
|
||||
'<div class="wolai-page-ai-header-copy">' +
|
||||
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">智能问答</h2>' +
|
||||
'<div class="wolai-page-ai-subtitle">当前页面上下文优先 · mnote-cli</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-body">' +
|
||||
'<div class="wolai-page-ai-suggestions">' +
|
||||
'<div class="wolai-page-ai-suggestions-header">' +
|
||||
'<span>推荐问题</span>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-footer">' +
|
||||
'<div class="wolai-page-ai-toolbar">' +
|
||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
|
||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
|
||||
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-action="model">mnote-cli</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-ai-input-row">' +
|
||||
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
|
||||
'<button type="button" class="wolai-page-ai-send" data-page-ai-action="send" aria-label="发送">↑</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(drawer);
|
||||
return drawer;
|
||||
}
|
||||
|
||||
function renderPageAiSuggestions() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var list = drawer.querySelector('[data-page-ai-suggestion-list]');
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
var suggestions = pageAiSuggestions();
|
||||
var offset = pageUiState.pageAiSuggestionIndex % suggestions.length;
|
||||
var ordered = suggestions.slice(offset).concat(suggestions.slice(0, offset)).slice(0, 3);
|
||||
list.innerHTML = ordered.map(function(text) {
|
||||
return '<button type="button" class="wolai-page-ai-suggestion" data-page-ai-suggestion="' + escapeHtml(text) + '">' + escapeHtml(text) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderPageAiConversation() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
var conversation = drawer.querySelector('[data-page-ai-conversation]');
|
||||
if (!(conversation instanceof HTMLElement)) return;
|
||||
if (!pageUiState.pageAiMessages.length) {
|
||||
conversation.innerHTML = '<div class="wolai-page-ai-empty">围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。</div>';
|
||||
return;
|
||||
}
|
||||
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '">' +
|
||||
'<div class="wolai-page-ai-message-role">' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '</div>' +
|
||||
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
conversation.scrollTop = conversation.scrollHeight;
|
||||
}
|
||||
|
||||
function openPageAiDrawer() {
|
||||
renderPageAiSuggestions();
|
||||
renderPageAiConversation();
|
||||
var drawer = ensurePageAiDrawer();
|
||||
drawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
updatePageAiTriggerState();
|
||||
}
|
||||
|
||||
function closePageAiDrawer() {
|
||||
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
if (drawer instanceof HTMLElement) drawer.hidden = true;
|
||||
pageUiState.pageAiOpen = false;
|
||||
updatePageAiTriggerState();
|
||||
}
|
||||
|
||||
function isPageAiDrawerOpen() {
|
||||
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
return drawer instanceof HTMLElement && !drawer.hidden;
|
||||
}
|
||||
|
||||
async function streamPageAiResponse(response, onEvent) {
|
||||
if (!response.body || typeof response.body.getReader !== 'function') return;
|
||||
var reader = response.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
while (true) {
|
||||
var chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
var frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() || '';
|
||||
frames.forEach(function(frame) {
|
||||
var eventName = '';
|
||||
var dataLines = [];
|
||||
frame.split('\n').forEach(function(line) {
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
});
|
||||
if (eventName) onEvent(eventName, dataLines.join('\n'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPageAiMessage(text) {
|
||||
if (pageUiState.pageAiBusy) return;
|
||||
var prompt = searchText(text);
|
||||
if (!prompt) return;
|
||||
var aggregate = currentPageAggregate();
|
||||
var body = aggregate.body || {};
|
||||
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
pageUiState.pageAiBusy = true;
|
||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||
renderPageAiConversation();
|
||||
try {
|
||||
var response = await fetch('/api/ai-agent/run', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
maxSteps: 8,
|
||||
scope: 'document',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
toolChoice: {
|
||||
mode: 'auto',
|
||||
toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
|
||||
},
|
||||
context: {
|
||||
documentId: currentDocumentId(),
|
||||
documentBlocks: body.content || null,
|
||||
node: {
|
||||
documentId: currentDocumentId(),
|
||||
title: aggregate.head && aggregate.head.title ? aggregate.head.title : ''
|
||||
},
|
||||
subtree: subtree,
|
||||
outline: outline,
|
||||
evidence: null,
|
||||
pageOptions: currentPageOptions()
|
||||
},
|
||||
options: {
|
||||
searxng: true,
|
||||
ai: { provider: 'hermes' }
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('page_ai_failed_' + response.status);
|
||||
}
|
||||
var assistantText = '';
|
||||
await streamPageAiResponse(response, function(eventName, payloadText) {
|
||||
if (eventName === 'assistant_message') {
|
||||
try {
|
||||
var payload = JSON.parse(payloadText || 'null');
|
||||
assistantText = searchText(payload && payload.text);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantText || '当前页面 AI 已接通 `mnote-cli`,但这次没有返回文本结果。'
|
||||
});
|
||||
} catch (error) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: '当前页面 AI 抽屉已接到 `mnote-cli` 主链,但本次请求失败:' + (error instanceof Error ? error.message : String(error))
|
||||
});
|
||||
} finally {
|
||||
pageUiState.pageAiBusy = false;
|
||||
renderPageAiConversation();
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePageSettingsPopover() {
|
||||
var existing = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
var popover = document.createElement('div');
|
||||
popover.className = 'wolai-page-settings-popover';
|
||||
popover.setAttribute('data-testid', 'wolai-page-settings-popover');
|
||||
popover.setAttribute('data-mnote-surface', 'page-settings');
|
||||
popover.hidden = true;
|
||||
popover.innerHTML = '' +
|
||||
'<div class="wolai-page-settings-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
|
||||
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page">页面选项</button>' +
|
||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom">自定义页面</button>' +
|
||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global">全局选项</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
|
||||
createPageOptionRow('wideLayout', 'checkbox') +
|
||||
createPageOptionRow('smallText', 'checkbox') +
|
||||
createPageOptionRow('showToc', 'checkbox') +
|
||||
createPageOptionRow('showHeadingNumbers', 'checkbox') +
|
||||
createPageOptionRow('protectEditing', 'checkbox') +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-section" data-page-settings-panel="custom" hidden>' +
|
||||
createPageFontRow() +
|
||||
createPageOptionRow('layoutDensity', 'select') +
|
||||
createPageOptionRow('collapseBacklinks', 'checkbox') +
|
||||
createPageOptionRow('hideChildPages', 'checkbox') +
|
||||
createPageOptionRow('showBlockRefCount', 'checkbox') +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
||||
'<div class="wolai-page-settings-global-note">当前 Rust Web 壳仅保留最小全局设置入口,真正的全局偏好仍待后续统一收口。</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-actions">' +
|
||||
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">页面历史...</button>' +
|
||||
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="share">公开分享页面...</button>' +
|
||||
'<button type="button" class="wolai-page-settings-action is-disabled" data-page-settings-action="move" disabled>移动到...</button>' +
|
||||
'<button type="button" class="wolai-page-settings-action is-disabled" data-page-settings-action="embed" disabled>嵌入到...</button>' +
|
||||
'<button type="button" class="wolai-page-settings-action is-danger is-disabled" data-page-settings-action="delete" disabled>删除页面</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-stats" data-testid="wolai-page-settings-stats"></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(popover);
|
||||
return popover;
|
||||
}
|
||||
|
||||
function renderPageSettingsPopover() {
|
||||
var popover = ensurePageSettingsPopover();
|
||||
var options = currentPageOptions();
|
||||
popover.querySelectorAll('[data-page-option-checkbox]').forEach(function(input) {
|
||||
var key = input.getAttribute('data-page-option-checkbox');
|
||||
input.checked = Boolean(options[key]);
|
||||
input.disabled = !pageOptionIsSupported(key);
|
||||
});
|
||||
popover.querySelectorAll('[data-page-option-select]').forEach(function(select) {
|
||||
var key = select.getAttribute('data-page-option-select');
|
||||
var value = key === 'layoutDensity' ? String(options.layoutDensity || 'normal') : String(options.pageFont || 'default');
|
||||
select.value = value;
|
||||
});
|
||||
var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]');
|
||||
if (statsNode instanceof HTMLElement) {
|
||||
var stats = computeLivePageStats();
|
||||
statsNode.innerHTML = '' +
|
||||
'<span>字数 ' + Number(stats.wordCount || 0) + '</span>' +
|
||||
'<span>字符 ' + Number(stats.characterCount || 0) + '</span>' +
|
||||
'<span>块数 ' + Number(stats.blockCount || 0) + '</span>' +
|
||||
'<span>待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function setActivePageSettingsTab(tabName) {
|
||||
var popover = ensurePageSettingsPopover();
|
||||
popover.querySelectorAll('[data-page-settings-tab]').forEach(function(tab) {
|
||||
var active = tab.getAttribute('data-page-settings-tab') === tabName;
|
||||
tab.classList.toggle('is-active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
|
||||
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
|
||||
});
|
||||
}
|
||||
|
||||
async function persistPageOptionsPatch(patch) {
|
||||
var previous = Object.assign({}, currentPageOptions());
|
||||
pageUiState.pageOptions = Object.assign({}, previous, patch);
|
||||
var nextOptions = Object.assign({}, pageUiState.pageOptions);
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
try {
|
||||
var response = await fetch('/api/documents/options', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
options: nextOptions,
|
||||
commandName: 'page.layout.updateOptions'
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_settings_save_failed_' + response.status);
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-page-options-saved', 'true');
|
||||
} catch (error) {
|
||||
pageUiState.pageOptions = previous;
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
document.documentElement.setAttribute('data-mnote-page-options-error', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function isPageSettingsOpen() {
|
||||
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
||||
return popover instanceof HTMLElement && !popover.hidden;
|
||||
}
|
||||
|
||||
function openPageSettingsPopover() {
|
||||
if (!currentDocumentId()) return;
|
||||
var popover = ensurePageSettingsPopover();
|
||||
renderPageSettingsPopover();
|
||||
setActivePageSettingsTab('page');
|
||||
popover.hidden = false;
|
||||
pageUiState.pageSettingsOpen = true;
|
||||
updatePageSettingsTriggerState();
|
||||
}
|
||||
|
||||
function closePageSettingsPopover() {
|
||||
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
||||
if (popover instanceof HTMLElement) popover.hidden = true;
|
||||
pageUiState.pageSettingsOpen = false;
|
||||
updatePageSettingsTriggerState();
|
||||
}
|
||||
|
||||
function togglePageSettingsPopover() {
|
||||
if (isPageSettingsOpen()) closePageSettingsPopover();
|
||||
else openPageSettingsPopover();
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
||||
if (activeTreeContextMenu) closeTreeContextMenu();
|
||||
|
||||
var historyClose = closestAction(e.target, '[data-page-history-action="close"]');
|
||||
if (historyClose) {
|
||||
e.preventDefault();
|
||||
closePageHistoryDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
var shareClose = closestAction(e.target, '[data-page-share-action="close"]');
|
||||
if (shareClose) {
|
||||
e.preventDefault();
|
||||
closePageShareDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
var shareCopy = closestAction(e.target, '[data-page-share-action="copy-link"]');
|
||||
if (shareCopy) {
|
||||
e.preventDefault();
|
||||
void copyTreeContextValue(window.location.href, 'page-share-link');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageHistoryTrigger = closestAction(e.target, '[data-mnote-action="open-page-history"]');
|
||||
if (pageHistoryTrigger) {
|
||||
e.preventDefault();
|
||||
openPageHistoryDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-page-settings"]');
|
||||
if (pageSettingsTrigger) {
|
||||
e.preventDefault();
|
||||
togglePageSettingsPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageSettingsPanel = closestAction(e.target, '.wolai-page-settings-panel');
|
||||
if (isPageSettingsOpen() && !pageSettingsPanel) {
|
||||
closePageSettingsPopover();
|
||||
}
|
||||
|
||||
var pageSettingsTab = closestAction(e.target, '[data-page-settings-tab]');
|
||||
if (pageSettingsTab) {
|
||||
e.preventDefault();
|
||||
setActivePageSettingsTab(pageSettingsTab.getAttribute('data-page-settings-tab') || 'page');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
|
||||
if (pageSettingsAction) {
|
||||
e.preventDefault();
|
||||
var actionName = pageSettingsAction.getAttribute('data-page-settings-action') || '';
|
||||
if (actionName === 'history') {
|
||||
openPageHistoryDrawer();
|
||||
return;
|
||||
}
|
||||
if (actionName === 'share') {
|
||||
openPageShareDialog();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]');
|
||||
if (pageAiTrigger) {
|
||||
e.preventDefault();
|
||||
openPageAiDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiClose = closestAction(e.target, '[data-page-ai-action="close"]');
|
||||
if (pageAiClose) {
|
||||
e.preventDefault();
|
||||
closePageAiDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
|
||||
if (pageAiRotate) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiSuggestionIndex += 1;
|
||||
renderPageAiSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
|
||||
if (pageAiSuggestion) {
|
||||
e.preventDefault();
|
||||
var text = pageAiSuggestion.getAttribute('data-page-ai-suggestion') || '';
|
||||
var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
|
||||
if (inputNode instanceof HTMLTextAreaElement) {
|
||||
inputNode.value = text;
|
||||
inputNode.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
|
||||
if (pageAiNewSession) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiMessages = [];
|
||||
renderPageAiConversation();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSend = closestAction(e.target, '[data-page-ai-action="send"]');
|
||||
if (pageAiSend) {
|
||||
e.preventDefault();
|
||||
var input = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
|
||||
if (input instanceof HTMLTextAreaElement) {
|
||||
var message = input.value;
|
||||
input.value = '';
|
||||
void sendPageAiMessage(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
|
||||
if (searchTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -973,6 +1766,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape' && isPageSettingsOpen()) {
|
||||
event.preventDefault();
|
||||
closePageSettingsPopover();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
|
||||
event.preventDefault();
|
||||
toggleSearchModal();
|
||||
@@ -982,8 +1780,51 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
closeSearchModal();
|
||||
closeTreeContextMenu();
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
var aiInput = closestAction(event.target, '[data-page-ai-input]');
|
||||
if (aiInput instanceof HTMLTextAreaElement) {
|
||||
event.preventDefault();
|
||||
var text = aiInput.value;
|
||||
aiInput.value = '';
|
||||
void sendPageAiMessage(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
|
||||
if (checkbox instanceof HTMLInputElement) {
|
||||
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
|
||||
if (!pageOptionIsSupported(key)) return;
|
||||
var patch = {};
|
||||
patch[key] = checkbox.checked;
|
||||
void persistPageOptionsPatch(patch);
|
||||
return;
|
||||
}
|
||||
var select = closestAction(event.target, '[data-page-option-select]');
|
||||
if (select instanceof HTMLSelectElement) {
|
||||
var selectKey = select.getAttribute('data-page-option-select') || '';
|
||||
var next = {};
|
||||
if (selectKey === 'layoutDensity') next.layoutDensity = select.value;
|
||||
if (selectKey === 'pageFont') next.pageFont = select.value;
|
||||
if (Object.keys(next).length) {
|
||||
void persistPageOptionsPatch(next);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function initializePageUiSurfaces() {
|
||||
pageUiState.pageOptions = null;
|
||||
applyPageOptionsToShell();
|
||||
updatePageSettingsTriggerState();
|
||||
updatePageAiTriggerState();
|
||||
ensureHistorySnapshotsSeeded();
|
||||
}
|
||||
|
||||
window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot;
|
||||
window.__mnoteApplyPageOptionsToShell = applyPageOptionsToShell;
|
||||
setTimeout(initializePageUiSurfaces, 0);
|
||||
|
||||
function readPageDragNodeId(event) {
|
||||
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
|
||||
return (fromTransfer || draggingPageNodeId || '').trim();
|
||||
@@ -1403,8 +2244,8 @@ pub fn PageLayout(
|
||||
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="历史" aria-label="历史"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="更多" aria-label="更多"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="页面历史" aria-label="历史" data-mnote-action="open-page-history"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="页面选项和全局选项" aria-label="更多" data-testid="wolai-page-settings-trigger" data-mnote-action="open-page-settings"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</header>
|
||||
<article class="mnote-content">
|
||||
@@ -1412,7 +2253,7 @@ pub fn PageLayout(
|
||||
</article>
|
||||
<div class="wolai-floating-actions" aria-label="浮动操作">
|
||||
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button wolai-floating-button--help" aria-label="帮助"><span class="material-symbols-outlined" data-icon="help" aria-hidden="true"></span></button>
|
||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1446,13 +2287,19 @@ mod tests {
|
||||
fn sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("openTreeContextMenu"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("复制访问链接(带标题)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("复制访问链接"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶"));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
".tree-row[data-node-id=\"' + escaped + '\"] > .tree-link > .tree-link-title"
|
||||
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(!SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(!SIDEBAR_TREE_JS
|
||||
.contains("[data-node-id=\"' + cssEscape(documentId) + '\"] .tree-link-title"));
|
||||
.contains(r#"[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user