2026-07-21 05:13:05 +08:00
|
|
|
|
// Password vault workbench runtime — dedicated /vault CRUD UI.
|
|
|
|
|
|
// Refresh model: command-result re-fetch only; no setInterval.
|
|
|
|
|
|
(function initVaultWorkbenchRuntime() {
|
|
|
|
|
|
if (window.__mnoteVaultWorkbenchStarted) return;
|
|
|
|
|
|
window.__mnoteVaultWorkbenchStarted = true;
|
|
|
|
|
|
|
|
|
|
|
|
function qs(sel, root) {
|
|
|
|
|
|
return (root || document).querySelector(sel);
|
|
|
|
|
|
}
|
|
|
|
|
|
function qsa(sel, root) {
|
|
|
|
|
|
return Array.prototype.slice.call((root || document).querySelectorAll(sel));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toast(message, kind) {
|
|
|
|
|
|
if (window.mnote && typeof window.mnote.toast === 'function') {
|
|
|
|
|
|
window.mnote.toast(message, { kind: kind || 'info' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log('[vault]', kind || 'info', message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readRootUri(root) {
|
|
|
|
|
|
var fromData = root.getAttribute('data-root-uri') || '';
|
|
|
|
|
|
if (fromData) return fromData;
|
|
|
|
|
|
var body = document.body && document.body.getAttribute('data-mnote-root-uri');
|
|
|
|
|
|
return body || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function apiUrl(path, rootUri, extra) {
|
|
|
|
|
|
var url = new URL(path, window.location.origin);
|
|
|
|
|
|
if (rootUri) url.searchParams.set('rootUri', rootUri);
|
|
|
|
|
|
url.searchParams.set('sourceKind', 'local_folder');
|
|
|
|
|
|
if (extra) {
|
|
|
|
|
|
Object.keys(extra).forEach(function (key) {
|
|
|
|
|
|
if (extra[key] != null && extra[key] !== '') {
|
|
|
|
|
|
url.searchParams.set(key, extra[key]);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return url.toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function apiJson(method, path, rootUri, body, queryExtra) {
|
|
|
|
|
|
var opts = {
|
|
|
|
|
|
method: method,
|
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
|
};
|
|
|
|
|
|
var url = path;
|
|
|
|
|
|
if (method === 'GET' || method === 'DELETE') {
|
|
|
|
|
|
url = apiUrl(path, rootUri, queryExtra);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
|
|
|
|
var payload = Object.assign({ rootUri: rootUri, sourceKind: 'local_folder' }, body || {});
|
|
|
|
|
|
opts.body = JSON.stringify(payload);
|
|
|
|
|
|
if (queryExtra) {
|
|
|
|
|
|
url = apiUrl(path, rootUri, queryExtra);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
var res = await fetch(url, opts);
|
|
|
|
|
|
var data = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
data = await res.json();
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
data = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok || (data && data.ok === false)) {
|
|
|
|
|
|
var code = (data && (data.code || (data.error && data.error.code))) || res.status;
|
|
|
|
|
|
var msg =
|
|
|
|
|
|
(data && (data.message || data.error || (data.error && data.error.message))) ||
|
|
|
|
|
|
('请求失败 ' + res.status);
|
|
|
|
|
|
if (typeof msg === 'object') msg = JSON.stringify(msg);
|
|
|
|
|
|
var err = new Error(String(msg));
|
|
|
|
|
|
err.code = code;
|
|
|
|
|
|
err.status = res.status;
|
|
|
|
|
|
err.payload = data;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return data && data.result != null ? data.result : data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function maskLabel(state) {
|
|
|
|
|
|
if (!state || state.state === 'absent') return '(无)';
|
|
|
|
|
|
if (state.state === 'masked') return '••••••••';
|
|
|
|
|
|
if (state.state === 'revealed') return state.value || '';
|
|
|
|
|
|
return '••••••••';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
|
|
return String(text == null ? '' : text)
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Reusable helper for rendering a single secret row (password/apikey/token).
|
|
|
|
|
|
* Used heavily by account secrets, nested secrets, and the second-secret bug fix.
|
|
|
|
|
|
* Reduces duplication in render blocks.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) {
|
|
|
|
|
|
var key = opts.secretId || '';
|
|
|
|
|
|
var has = secretState && secretState.state === 'masked';
|
|
|
|
|
|
var canReveal = has || (secretState && secretState.state === 'revealed');
|
|
|
|
|
|
if (!canReveal && !showEmpty) return '';
|
|
|
|
|
|
|
|
|
|
|
|
var shown = state.revealed[key]
|
|
|
|
|
|
? state._revealedValues && state._revealedValues[key]
|
|
|
|
|
|
? escapeHtml(state._revealedValues[key])
|
|
|
|
|
|
: maskLabel(secretState)
|
|
|
|
|
|
: maskLabel(secretState);
|
|
|
|
|
|
|
|
|
|
|
|
var revealBtn = state.revealed[key] ? '隐藏' : '显示';
|
|
|
|
|
|
var copyBtn = '<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' + escapeHtml(key) + '">复制</button>';
|
|
|
|
|
|
|
|
|
|
|
|
var actions = state.revealed[key]
|
|
|
|
|
|
? '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn
|
|
|
|
|
|
: '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn;
|
|
|
|
|
|
|
|
|
|
|
|
return '<div class="mnote-vault-field-row" data-vault-secret-row="' + escapeHtml(key) + '">'
|
|
|
|
|
|
+ '<div class="mnote-vault-secret-value" data-testid="vault-secret-' + escapeHtml(key) + '">' + shown + '</div>'
|
|
|
|
|
|
+ '<div class="mnote-vault-secret-actions">' + actions + '</div>'
|
|
|
|
|
|
+ '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function bindWorkbench(root) {
|
|
|
|
|
|
if (!(root instanceof HTMLElement) || root.getAttribute('data-vault-bound') === '1') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
root.setAttribute('data-vault-bound', '1');
|
|
|
|
|
|
|
|
|
|
|
|
var state = {
|
|
|
|
|
|
rootUri: readRootUri(root),
|
|
|
|
|
|
status: root.getAttribute('data-status') || 'active',
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
selectedId: root.getAttribute('data-selected-id') || '',
|
|
|
|
|
|
item: null,
|
|
|
|
|
|
dirty: false,
|
|
|
|
|
|
revealed: { password: false, apikey: false, token: false },
|
|
|
|
|
|
secretDraft: { password: null, apikey: null, token: null },
|
|
|
|
|
|
mode: 'view', // view | create | edit | cipher
|
|
|
|
|
|
/** @type {Record<string, boolean>} collapsed folder paths (true = collapsed) */
|
|
|
|
|
|
collapsedFolders: {},
|
|
|
|
|
|
cipherKeys: [],
|
|
|
|
|
|
cipherRevealed: {},
|
2026-07-24 11:36:06 +08:00
|
|
|
|
/** @type {HTMLInputElement|HTMLTextAreaElement|null} 编辑表单中最近一次可插入密文的输入框 */
|
|
|
|
|
|
lastCipherInsertTarget: null,
|
|
|
|
|
|
/** @type {{ start: number, end: number }|null} 失焦前光标,避免点下拉后丢失 */
|
|
|
|
|
|
lastCipherInsertRange: null,
|
2026-07-21 05:13:05 +08:00
|
|
|
|
isAiVault: false,
|
|
|
|
|
|
aiVaultActorId: 'mnote-e2e',
|
|
|
|
|
|
vaultRole: 'user',
|
|
|
|
|
|
showEmptyFields: false,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Edit/create form UI chrome that must survive reRenderFormKeepingSlots
|
|
|
|
|
|
* and view-mode renderDetail (password/appendix reveal re-renders).
|
|
|
|
|
|
* Without this, appendix-secret panels always re-mount collapsed.
|
|
|
|
|
|
* @type {{ openAccountIds: Record<string, boolean>, expandedSecretPanels: Record<string, boolean> }}
|
|
|
|
|
|
*/
|
|
|
|
|
|
_formUi: { openAccountIds: {}, expandedSecretPanels: {} },
|
2026-07-21 05:13:05 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
function ensureFormUi() {
|
|
|
|
|
|
if (!state._formUi) {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
state._formUi = {
|
|
|
|
|
|
openAccountIds: {},
|
|
|
|
|
|
expandedSecretPanels: {},
|
|
|
|
|
|
folderPickerOpen: {},
|
|
|
|
|
|
};
|
2026-07-24 11:36:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
|
|
|
|
|
|
if (!state._formUi.expandedSecretPanels) {
|
|
|
|
|
|
state._formUi.expandedSecretPanels = {};
|
|
|
|
|
|
}
|
2026-07-25 14:25:37 +08:00
|
|
|
|
if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {};
|
2026-07-24 11:36:06 +08:00
|
|
|
|
return state._formUi;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Account key for UI chrome (open group / appendix panel). */
|
|
|
|
|
|
function accountUiKey(accId, idx) {
|
|
|
|
|
|
if (accId != null && String(accId) !== '') return String(accId);
|
|
|
|
|
|
if (idx != null) return 'idx:' + String(idx);
|
|
|
|
|
|
return 'default';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isSecretsPanelExpanded(accKey) {
|
|
|
|
|
|
var ui = ensureFormUi();
|
|
|
|
|
|
return !!ui.expandedSecretPanels[accKey];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setSecretsPanelExpanded(accKey, expanded) {
|
|
|
|
|
|
if (!accKey) return;
|
|
|
|
|
|
ensureFormUi().expandedSecretPanels[accKey] = !!expanded;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Build appendix-secrets panel markup; honors expanded state so reveal/hide
|
|
|
|
|
|
* re-renders do not collapse an already-open panel.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderSecretsPanelHtml(opts) {
|
|
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
var accKey = opts.accKey || 'default';
|
|
|
|
|
|
var title = opts.title || '附录密钥';
|
|
|
|
|
|
var count = opts.count || 0;
|
|
|
|
|
|
var bodyHtml = opts.bodyHtml || '';
|
|
|
|
|
|
var extraAttrs = opts.extraAttrs || '';
|
|
|
|
|
|
var headExtra = opts.headExtra || '';
|
|
|
|
|
|
var expanded = isSecretsPanelExpanded(accKey);
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-account-secrets' +
|
|
|
|
|
|
(expanded ? '' : ' is-collapsed') +
|
|
|
|
|
|
'" data-vault-secrets-panel data-vault-secrets-acc="' +
|
|
|
|
|
|
escapeHtml(accKey) +
|
|
|
|
|
|
'"' +
|
|
|
|
|
|
extraAttrs +
|
|
|
|
|
|
'>' +
|
|
|
|
|
|
'<div class="mnote-vault-account-secrets-head">' +
|
|
|
|
|
|
'<button type="button" class="mnote-vault-secrets-toggle" data-vault-toggle-secrets aria-expanded="' +
|
|
|
|
|
|
(expanded ? 'true' : 'false') +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<span class="mnote-vault-secrets-chevron" aria-hidden="true">' +
|
|
|
|
|
|
(expanded ? '▼' : '▶') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<span>' +
|
|
|
|
|
|
escapeHtml(title) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
(count
|
|
|
|
|
|
? '<span class="mnote-vault-secrets-count">(' + count + ')</span>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</button>' +
|
|
|
|
|
|
headExtra +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
'<div class="mnote-vault-account-secrets-body"' +
|
|
|
|
|
|
(expanded ? '' : ' hidden') +
|
|
|
|
|
|
'>' +
|
|
|
|
|
|
bodyHtml +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var listEl = qs('[data-vault-list]', root);
|
|
|
|
|
|
var detailEl = qs('[data-vault-detail]', root);
|
|
|
|
|
|
var statusEl = qs('[data-vault-status]', root);
|
|
|
|
|
|
var searchEl = qs('[data-vault-search]', root);
|
|
|
|
|
|
var tabActive = qs('[data-vault-tab="active"]', root);
|
|
|
|
|
|
var tabDeleted = qs('[data-vault-tab="deleted"]', root);
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 折叠态:localStorage(同 origin 多窗 / 未来桌面多窗共享)+ BroadcastChannel 即时同步。
|
|
|
|
|
|
// 与页面树 view-state 同思路的「本机 UI 状态」层;暂不写 control-plane。
|
|
|
|
|
|
var VAULT_COLLAPSE_LEGACY_SESSION_KEY = 'mnote.vault.collapsedFolders';
|
|
|
|
|
|
var VAULT_COLLAPSE_SCHEMA = 1;
|
|
|
|
|
|
var collapseUpdatedAtMs = 0;
|
|
|
|
|
|
var collapseBroadcast = null;
|
|
|
|
|
|
var collapseStorageKey = vaultCollapseStorageKey(state.rootUri);
|
|
|
|
|
|
|
|
|
|
|
|
function vaultCollapseStorageKey(rootUri) {
|
|
|
|
|
|
var raw = String(rootUri || '').trim() || 'default';
|
|
|
|
|
|
var hash = 0;
|
|
|
|
|
|
for (var i = 0; i < raw.length; i++) {
|
|
|
|
|
|
hash = (hash * 31 + raw.charCodeAt(i)) | 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
'mnote.vault.collapsedFolders.v1:' +
|
|
|
|
|
|
(hash >>> 0).toString(16) +
|
|
|
|
|
|
':' +
|
|
|
|
|
|
encodeURIComponent(raw.slice(0, 120))
|
|
|
|
|
|
);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
function normalizeCollapseMap(raw) {
|
|
|
|
|
|
var out = {};
|
|
|
|
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
|
|
|
|
Object.keys(raw).forEach(function (k) {
|
|
|
|
|
|
out[k] = !!raw[k];
|
|
|
|
|
|
});
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseCollapsePayload(raw) {
|
|
|
|
|
|
if (!raw) return null;
|
2026-07-21 05:13:05 +08:00
|
|
|
|
try {
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
|
|
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
|
|
|
|
// v1 envelope: { schemaVersion, updatedAtMs, collapsed }
|
|
|
|
|
|
if (parsed.collapsed && typeof parsed.collapsed === 'object') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
schemaVersion: Number(parsed.schemaVersion) || VAULT_COLLAPSE_SCHEMA,
|
|
|
|
|
|
updatedAtMs: Number(parsed.updatedAtMs) || 0,
|
|
|
|
|
|
collapsed: normalizeCollapseMap(parsed.collapsed),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
// legacy flat map { path: boolean }
|
|
|
|
|
|
return {
|
|
|
|
|
|
schemaVersion: VAULT_COLLAPSE_SCHEMA,
|
|
|
|
|
|
updatedAtMs: 0,
|
|
|
|
|
|
collapsed: normalizeCollapseMap(parsed),
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function serializeCollapsePayload() {
|
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
|
schemaVersion: VAULT_COLLAPSE_SCHEMA,
|
|
|
|
|
|
updatedAtMs: collapseUpdatedAtMs || Date.now(),
|
|
|
|
|
|
collapsed: state.collapsedFolders || {},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadCollapseState() {
|
|
|
|
|
|
var fromLocal = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.localStorage) {
|
|
|
|
|
|
fromLocal = parseCollapsePayload(window.localStorage.getItem(collapseStorageKey));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
fromLocal = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 一次性迁移:同 tab 旧 sessionStorage → localStorage
|
|
|
|
|
|
var fromSession = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.sessionStorage) {
|
|
|
|
|
|
fromSession = parseCollapsePayload(
|
|
|
|
|
|
window.sessionStorage.getItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
fromSession = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
var chosen = fromLocal;
|
|
|
|
|
|
if (fromSession && (!chosen || (fromSession.updatedAtMs || 0) >= (chosen.updatedAtMs || 0))) {
|
|
|
|
|
|
// 无 local 或 session 更新时间不落后时:优先有数据的一份;二者都无时间戳时若 local 空则用 session
|
|
|
|
|
|
if (!chosen || !Object.keys(chosen.collapsed || {}).length) {
|
|
|
|
|
|
chosen = fromSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (chosen) {
|
|
|
|
|
|
state.collapsedFolders = chosen.collapsed || {};
|
|
|
|
|
|
collapseUpdatedAtMs = chosen.updatedAtMs || Date.now();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.collapsedFolders = {};
|
|
|
|
|
|
collapseUpdatedAtMs = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 若只从 session 迁过来,立刻落到 localStorage(不广播,避免冷启动风暴)
|
|
|
|
|
|
if (fromSession && Object.keys(fromSession.collapsed || {}).length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.sessionStorage) {
|
|
|
|
|
|
window.sessionStorage.removeItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e2) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!fromLocal || !Object.keys(fromLocal.collapsed || {}).length) {
|
|
|
|
|
|
collapseUpdatedAtMs = collapseUpdatedAtMs || Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.localStorage) {
|
|
|
|
|
|
window.localStorage.setItem(collapseStorageKey, serializeCollapsePayload());
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e3) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyRemoteCollapsePayload(payload, source) {
|
|
|
|
|
|
if (!payload || !payload.collapsed) return false;
|
|
|
|
|
|
var remoteTs = Number(payload.updatedAtMs) || 0;
|
|
|
|
|
|
// LWW:远程更旧则忽略(含本窗 storage 回声时用 updatedAt 防抖)
|
|
|
|
|
|
if (remoteTs && collapseUpdatedAtMs && remoteTs < collapseUpdatedAtMs) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
var next = normalizeCollapseMap(payload.collapsed);
|
|
|
|
|
|
var prev = state.collapsedFolders || {};
|
|
|
|
|
|
var same =
|
|
|
|
|
|
Object.keys(next).length === Object.keys(prev).length &&
|
|
|
|
|
|
Object.keys(next).every(function (k) {
|
|
|
|
|
|
return prev[k] === next[k];
|
|
|
|
|
|
});
|
|
|
|
|
|
if (same && remoteTs <= collapseUpdatedAtMs) return false;
|
|
|
|
|
|
state.collapsedFolders = next;
|
|
|
|
|
|
collapseUpdatedAtMs = remoteTs || Date.now();
|
|
|
|
|
|
if (source !== 'localStorage-echo') {
|
|
|
|
|
|
// storage 事件来自其他窗已写好 localStorage,无需再写
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function persistCollapse(options) {
|
|
|
|
|
|
options = options || {};
|
|
|
|
|
|
collapseUpdatedAtMs = Date.now();
|
|
|
|
|
|
var payload = {
|
|
|
|
|
|
schemaVersion: VAULT_COLLAPSE_SCHEMA,
|
|
|
|
|
|
updatedAtMs: collapseUpdatedAtMs,
|
|
|
|
|
|
collapsed: state.collapsedFolders || {},
|
|
|
|
|
|
};
|
|
|
|
|
|
var raw = JSON.stringify(payload);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.localStorage) {
|
|
|
|
|
|
window.localStorage.setItem(collapseStorageKey, raw);
|
|
|
|
|
|
}
|
2026-07-21 05:13:05 +08:00
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 同 origin 其他窗:BroadcastChannel 即时;无 BC 时靠 storage 事件
|
|
|
|
|
|
if (options.broadcast !== false && collapseBroadcast) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
collapseBroadcast.postMessage({
|
|
|
|
|
|
type: 'vault.collapse.changed',
|
|
|
|
|
|
storageKey: collapseStorageKey,
|
|
|
|
|
|
payload: payload,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (_e2) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onCollapseSyncMessage(payload, source) {
|
|
|
|
|
|
if (applyRemoteCollapsePayload(payload, source)) {
|
|
|
|
|
|
// 列表已渲染时跟着重画;boot 前 renderList 可能尚不可用
|
|
|
|
|
|
if (typeof renderList === 'function') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function installCollapseSync() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (typeof BroadcastChannel !== 'undefined') {
|
|
|
|
|
|
collapseBroadcast = new BroadcastChannel('mnote.vault.collapse.v1');
|
|
|
|
|
|
collapseBroadcast.onmessage = function (ev) {
|
|
|
|
|
|
var data = ev && ev.data;
|
|
|
|
|
|
if (!data || data.type !== 'vault.collapse.changed') return;
|
|
|
|
|
|
if (data.storageKey && data.storageKey !== collapseStorageKey) return;
|
|
|
|
|
|
onCollapseSyncMessage(data.payload, 'broadcast');
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
collapseBroadcast = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 跨窗 localStorage 变更(BroadcastChannel 不可用或 Electron 分区差异时的托底)
|
|
|
|
|
|
window.addEventListener('storage', function (ev) {
|
|
|
|
|
|
if (!ev || ev.key !== collapseStorageKey || ev.storageArea !== window.localStorage) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var remote = parseCollapsePayload(ev.newValue);
|
|
|
|
|
|
if (remote) onCollapseSyncMessage(remote, 'storage');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadCollapseState();
|
|
|
|
|
|
installCollapseSync();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Collapse state: only keys present in collapsedFolders are explicit.
|
|
|
|
|
|
* 默认全部折叠(文件夹 + 同 URL 多账号组);用户点开后写入 localStorage 并跨窗同步。
|
|
|
|
|
|
* 旧版「未记录 = 展开」会让大分组(如「倍特」14 条)一进来铺满左栏。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function isCollapsedKey(key) {
|
|
|
|
|
|
if (Object.prototype.hasOwnProperty.call(state.collapsedFolders, key)) {
|
|
|
|
|
|
return !!state.collapsedFolders[key];
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleCollapseKey(key) {
|
|
|
|
|
|
state.collapsedFolders[key] = !isCollapsedKey(key);
|
|
|
|
|
|
persistCollapse({ broadcast: true });
|
2026-07-21 05:13:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Normalize folderPath for save; groups appear in the list only after an entry uses them. */
|
|
|
|
|
|
function normalizeFolderPath(raw) {
|
|
|
|
|
|
var fp = String(raw || '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(/\\/g, '/')
|
|
|
|
|
|
.replace(/\/+/g, '/')
|
|
|
|
|
|
.replace(/^\/+|\/+$/g, '');
|
|
|
|
|
|
if (!fp) return '';
|
|
|
|
|
|
var parts = fp.split('/').filter(function (p) {
|
|
|
|
|
|
return p && p !== '.' && p !== '..';
|
|
|
|
|
|
});
|
|
|
|
|
|
return parts.join('/');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isNotesEmpty(notes) {
|
|
|
|
|
|
var t = String(notes || '')
|
|
|
|
|
|
.replace(/^#+\s*Notes\s*/i, '')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
return !t;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function itemIsSharedToAi(item) {
|
|
|
|
|
|
return !!(item && (item.isSharedToAi || item.sharedToAi));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function itemIsAiCopy(item) {
|
|
|
|
|
|
return !!(item && (item.isAiSharedCopy || item.sharedFrom ||
|
|
|
|
|
|
((item.tags || []).indexOf('ai-shared') >= 0)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateHeaderChrome() {
|
|
|
|
|
|
var titleEl = qs('.mnote-vault-header h1', root);
|
|
|
|
|
|
var badge = qs('[data-vault-role-badge]', root);
|
|
|
|
|
|
if (titleEl) {
|
|
|
|
|
|
titleEl.textContent = state.isAiVault ? 'AI 密码本' : '密码箱';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!badge) {
|
|
|
|
|
|
var main = qs('.mnote-vault-header-main', root);
|
|
|
|
|
|
if (main) {
|
|
|
|
|
|
badge = document.createElement('span');
|
|
|
|
|
|
badge.setAttribute('data-vault-role-badge', '1');
|
|
|
|
|
|
badge.className = 'mnote-vault-role-badge';
|
|
|
|
|
|
main.appendChild(badge);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (badge) {
|
|
|
|
|
|
if (state.isAiVault) {
|
|
|
|
|
|
badge.hidden = false;
|
|
|
|
|
|
badge.textContent = 'AI 专用 · ' + (state.aiVaultActorId || 'mnote-e2e');
|
|
|
|
|
|
badge.classList.add('is-ai');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
badge.hidden = true;
|
|
|
|
|
|
badge.classList.remove('is-ai');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setStatus(message, type) {
|
|
|
|
|
|
if (!statusEl) return;
|
|
|
|
|
|
statusEl.textContent = message || '';
|
|
|
|
|
|
statusEl.setAttribute('data-type', type || 'info');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function confirmDiscard() {
|
|
|
|
|
|
if (!state.dirty) return true;
|
|
|
|
|
|
return window.confirm('有未保存的修改,确定放弃?');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function filteredItems() {
|
|
|
|
|
|
var q = searchEl && searchEl.value ? String(searchEl.value).trim().toLowerCase() : '';
|
|
|
|
|
|
if (!q) return state.items.slice();
|
|
|
|
|
|
return state.items.filter(function (item) {
|
|
|
|
|
|
var hay = [
|
|
|
|
|
|
item.title,
|
|
|
|
|
|
item.url,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
(item.urls || []).join(' '),
|
2026-07-21 05:13:05 +08:00
|
|
|
|
item.username,
|
|
|
|
|
|
item.email,
|
|
|
|
|
|
item.passwordHint,
|
|
|
|
|
|
item.folderPath,
|
|
|
|
|
|
(item.tags || []).join(' '),
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(' ')
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
return hay.indexOf(q) >= 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Build nested folder tree from folderPath (a/b/c). Only paths that have entries. */
|
|
|
|
|
|
function buildFolderTree(items) {
|
|
|
|
|
|
var treeRoot = { name: '', path: '', children: {}, items: [] };
|
|
|
|
|
|
items.forEach(function (item) {
|
|
|
|
|
|
var fp = (item.folderPath || '').trim();
|
|
|
|
|
|
if (!fp) {
|
|
|
|
|
|
treeRoot.items.push(item);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var parts = fp.split('/').filter(Boolean);
|
|
|
|
|
|
var node = treeRoot;
|
|
|
|
|
|
var acc = [];
|
|
|
|
|
|
parts.forEach(function (part) {
|
|
|
|
|
|
acc.push(part);
|
|
|
|
|
|
var p = acc.join('/');
|
|
|
|
|
|
if (!node.children[part]) {
|
|
|
|
|
|
node.children[part] = { name: part, path: p, children: {}, items: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
node = node.children[part];
|
|
|
|
|
|
});
|
|
|
|
|
|
node.items.push(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
return treeRoot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderItemButton(item) {
|
|
|
|
|
|
var active = item.id === state.selectedId ? ' is-active' : '';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var accN = item.accountCount || 0;
|
|
|
|
|
|
var secN = item.secretCount || 0;
|
|
|
|
|
|
var metaParts = [item.username || item.email || ''];
|
|
|
|
|
|
if (accN > 1) metaParts.push(accN + ' 账号');
|
|
|
|
|
|
if (secN > 0) metaParts.push(secN + ' 密钥');
|
|
|
|
|
|
var meta = metaParts.filter(Boolean).join(' · ');
|
2026-07-21 05:13:05 +08:00
|
|
|
|
// Single-row compact: title · meta | status glyphs only (no badge rows).
|
|
|
|
|
|
var icons = '';
|
|
|
|
|
|
if (itemIsSharedToAi(item) && !state.isAiVault) {
|
|
|
|
|
|
icons +=
|
|
|
|
|
|
'<span class="mnote-vault-list-icon is-shared" title="已共享到 AI 密码本" aria-label="已共享">↗</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (itemIsAiCopy(item)) {
|
|
|
|
|
|
icons +=
|
|
|
|
|
|
'<span class="mnote-vault-list-icon is-ai" title="AI 密码本副本" aria-label="AI 副本">◆</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<button type="button" class="mnote-vault-list-item' +
|
|
|
|
|
|
active +
|
|
|
|
|
|
(itemIsSharedToAi(item) ? ' is-shared-ai' : '') +
|
|
|
|
|
|
(itemIsAiCopy(item) ? ' is-ai-copy' : '') +
|
|
|
|
|
|
'" data-vault-item-id="' +
|
|
|
|
|
|
escapeHtml(item.id) +
|
|
|
|
|
|
'" data-testid="vault-list-item">' +
|
|
|
|
|
|
'<span class="mnote-vault-list-main">' +
|
|
|
|
|
|
'<span class="mnote-vault-list-title">' +
|
|
|
|
|
|
escapeHtml(item.title || '(无标题)') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
(meta
|
|
|
|
|
|
? '<span class="mnote-vault-list-meta">' + escapeHtml(meta) + '</span>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
(icons ? '<span class="mnote-vault-list-icons">' + icons + '</span>' : '') +
|
|
|
|
|
|
'</button>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function countNodeItems(node) {
|
|
|
|
|
|
var n = node.items.length;
|
|
|
|
|
|
Object.keys(node.children).forEach(function (k) {
|
|
|
|
|
|
n += countNodeItems(node.children[k]);
|
|
|
|
|
|
});
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Group items sharing the same non-empty URL into a collapsible site node. */
|
|
|
|
|
|
function renderItemsWithUrlGroups(items, depth, folderPath) {
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
var byUrl = {};
|
|
|
|
|
|
var noUrl = [];
|
|
|
|
|
|
items.forEach(function (item) {
|
|
|
|
|
|
var u = (item.url || '').trim();
|
|
|
|
|
|
if (!u) {
|
|
|
|
|
|
noUrl.push(item);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!byUrl[u]) byUrl[u] = [];
|
|
|
|
|
|
byUrl[u].push(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
// Collapsible site group only when 2+ accounts share the URL.
|
|
|
|
|
|
Object.keys(byUrl)
|
|
|
|
|
|
.sort(function (a, b) {
|
|
|
|
|
|
return a.localeCompare(b, 'zh');
|
|
|
|
|
|
})
|
|
|
|
|
|
.forEach(function (url) {
|
|
|
|
|
|
var group = byUrl[url];
|
|
|
|
|
|
if (group.length < 2) {
|
|
|
|
|
|
noUrl.push(group[0]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var siteKey = 'url:' + (folderPath || '') + '|' + url;
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var collapsed = isCollapsedKey(siteKey);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var label = shortenUrlLabel(url);
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-folder mnote-vault-site-group" style="--vault-depth:' +
|
|
|
|
|
|
depth +
|
|
|
|
|
|
'" data-vault-folder-path="' +
|
|
|
|
|
|
escapeHtml(siteKey) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<button type="button" class="mnote-vault-folder-toggle mnote-vault-site-toggle' +
|
|
|
|
|
|
(collapsed ? ' is-collapsed' : '') +
|
|
|
|
|
|
'" data-vault-toggle-folder="' +
|
|
|
|
|
|
escapeHtml(siteKey) +
|
|
|
|
|
|
'" aria-expanded="' +
|
|
|
|
|
|
(collapsed ? 'false' : 'true') +
|
|
|
|
|
|
'" title="' +
|
|
|
|
|
|
escapeHtml(url) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<span class="mnote-vault-folder-chevron" aria-hidden="true">' +
|
|
|
|
|
|
(collapsed ? '▸' : '▾') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<span class="mnote-vault-folder-name">🔗 ' +
|
|
|
|
|
|
escapeHtml(label) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<span class="mnote-vault-folder-count">' +
|
|
|
|
|
|
group.length +
|
|
|
|
|
|
' 账号</span>' +
|
|
|
|
|
|
'</button>';
|
|
|
|
|
|
if (!collapsed) {
|
|
|
|
|
|
html += '<div class="mnote-vault-folder-body">';
|
|
|
|
|
|
group
|
|
|
|
|
|
.slice()
|
|
|
|
|
|
.sort(function (a, b) {
|
|
|
|
|
|
var ua = String(a.username || a.title || '');
|
|
|
|
|
|
var ub = String(b.username || b.title || '');
|
|
|
|
|
|
return ua.localeCompare(ub, 'zh');
|
|
|
|
|
|
})
|
|
|
|
|
|
.forEach(function (item) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-list-item-wrap" style="--vault-depth:' +
|
|
|
|
|
|
(depth + 1) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
renderItemButton(item) +
|
|
|
|
|
|
'</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
noUrl
|
|
|
|
|
|
.slice()
|
|
|
|
|
|
.sort(function (a, b) {
|
|
|
|
|
|
return String(a.title || '').localeCompare(String(b.title || ''), 'zh');
|
|
|
|
|
|
})
|
|
|
|
|
|
.forEach(function (item) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-list-item-wrap" style="--vault-depth:' +
|
|
|
|
|
|
depth +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
renderItemButton(item) +
|
|
|
|
|
|
'</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shortenUrlLabel(url) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var u = new URL(url.indexOf('://') >= 0 ? url : 'https://' + url);
|
|
|
|
|
|
return u.host + (u.pathname && u.pathname !== '/' ? u.pathname : '');
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
return url.length > 40 ? url.slice(0, 37) + '…' : url;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderTreeNode(node, depth) {
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
var childKeys = Object.keys(node.children).sort(function (a, b) {
|
|
|
|
|
|
return a.localeCompare(b, 'zh');
|
|
|
|
|
|
});
|
|
|
|
|
|
childKeys.forEach(function (key) {
|
|
|
|
|
|
var child = node.children[key];
|
|
|
|
|
|
// 只有一个直连凭证的叶子分组会在左栏重复同名标签,直接展平。
|
|
|
|
|
|
if (child.items.length === 1 && Object.keys(child.children).length === 0) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-list-item-wrap" style="--vault-depth:' +
|
|
|
|
|
|
depth +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
renderItemButton(child.items[0]) +
|
|
|
|
|
|
'</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var collapsed = isCollapsedKey(child.path);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var count = countNodeItems(child);
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-folder" style="--vault-depth:' +
|
|
|
|
|
|
depth +
|
|
|
|
|
|
'" data-vault-folder-path="' +
|
|
|
|
|
|
escapeHtml(child.path) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<button type="button" class="mnote-vault-folder-toggle' +
|
|
|
|
|
|
(collapsed ? ' is-collapsed' : '') +
|
|
|
|
|
|
'" data-vault-toggle-folder="' +
|
|
|
|
|
|
escapeHtml(child.path) +
|
|
|
|
|
|
'" aria-expanded="' +
|
|
|
|
|
|
(collapsed ? 'false' : 'true') +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<span class="mnote-vault-folder-chevron" aria-hidden="true">' +
|
|
|
|
|
|
(collapsed ? '▸' : '▾') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<span class="mnote-vault-folder-name">' +
|
|
|
|
|
|
escapeHtml(child.name) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<span class="mnote-vault-folder-count">' +
|
|
|
|
|
|
count +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'</button>';
|
|
|
|
|
|
if (!collapsed) {
|
|
|
|
|
|
html += '<div class="mnote-vault-folder-body">';
|
|
|
|
|
|
html += renderTreeNode(child, depth + 1);
|
|
|
|
|
|
html += renderItemsWithUrlGroups(child.items, depth + 1, child.path);
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderList() {
|
|
|
|
|
|
if (!listEl) return;
|
|
|
|
|
|
var items = filteredItems();
|
|
|
|
|
|
if (!items.length) {
|
|
|
|
|
|
listEl.innerHTML =
|
|
|
|
|
|
'<div class="mnote-vault-empty" data-testid="vault-list-empty">暂无条目。点击「新建」添加。</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var tree = buildFolderTree(items);
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
// Root (ungrouped) items first — also group by same URL
|
|
|
|
|
|
if (tree.items.length) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-folder mnote-vault-folder--root" data-vault-folder-path="">' +
|
|
|
|
|
|
'<div class="mnote-vault-folder-label">未分组</div>' +
|
|
|
|
|
|
'<div class="mnote-vault-folder-body">' +
|
|
|
|
|
|
renderItemsWithUrlGroups(tree.items, 0, '') +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += renderTreeNode(tree, 0);
|
|
|
|
|
|
listEl.innerHTML = html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
/** Ordered unique URLs from item (primary first); supports legacy single `url`. */
|
|
|
|
|
|
function normalizeUrlsFromItem(item) {
|
|
|
|
|
|
var out = [];
|
|
|
|
|
|
function push(u) {
|
|
|
|
|
|
var t = String(u || '').trim();
|
|
|
|
|
|
if (!t) return;
|
|
|
|
|
|
if (out.indexOf(t) >= 0) return;
|
|
|
|
|
|
out.push(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item && Array.isArray(item.urls) && item.urls.length) {
|
|
|
|
|
|
item.urls.forEach(push);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item && item.url) push(item.url);
|
|
|
|
|
|
// Prefer primary url first when both present and order disagrees.
|
|
|
|
|
|
if (item && item.url && out.length) {
|
|
|
|
|
|
var primary = String(item.url).trim();
|
|
|
|
|
|
if (primary && out[0] !== primary && out.indexOf(primary) >= 0) {
|
|
|
|
|
|
out = [primary].concat(
|
|
|
|
|
|
out.filter(function (u) {
|
|
|
|
|
|
return u !== primary;
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function emptyForm(defaults) {
|
|
|
|
|
|
defaults = defaults || {};
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var urls = normalizeUrlsFromItem(defaults);
|
|
|
|
|
|
if (!urls.length && defaults.url) urls = [String(defaults.url)];
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return {
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
title: defaults.title || '',
|
2026-07-24 11:36:06 +08:00
|
|
|
|
url: urls[0] || defaults.url || '',
|
|
|
|
|
|
urls: urls,
|
2026-07-21 05:13:05 +08:00
|
|
|
|
username: defaults.username || '',
|
|
|
|
|
|
email: defaults.email || '',
|
|
|
|
|
|
passwordHint: defaults.passwordHint || '',
|
|
|
|
|
|
folderPath: defaults.folderPath || '',
|
|
|
|
|
|
password: { state: 'absent' },
|
|
|
|
|
|
apikey: { state: 'absent' },
|
|
|
|
|
|
token: { state: 'absent' },
|
2026-07-24 11:36:06 +08:00
|
|
|
|
accounts: defaults.accounts || [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
username: defaults.username || '',
|
|
|
|
|
|
email: defaults.email || '',
|
|
|
|
|
|
passwordHint: defaults.passwordHint || '',
|
|
|
|
|
|
password: { state: 'absent' },
|
|
|
|
|
|
secrets: defaults.secrets || [],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
// Top-level secrets kept for API flatten only; UI nests under accounts.
|
|
|
|
|
|
secrets: defaults.secrets || [],
|
2026-07-21 05:13:05 +08:00
|
|
|
|
tags: defaults.tags || [],
|
|
|
|
|
|
notesMarkdown: defaults.notesMarkdown || '## Notes\n\n',
|
|
|
|
|
|
revision: 0,
|
|
|
|
|
|
status: 'active',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
function newClientId(prefix) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
prefix +
|
|
|
|
|
|
'_' +
|
|
|
|
|
|
Math.random().toString(16).slice(2) +
|
|
|
|
|
|
Date.now().toString(16)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** View shows expanded value; edit prefers raw [Key] template when present. */
|
|
|
|
|
|
function cipherFieldValue(displayValue, templateValue) {
|
|
|
|
|
|
if (templateValue) return String(templateValue);
|
|
|
|
|
|
return displayValue || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mapSecretSlot(s) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: (s && s.id) || '',
|
|
|
|
|
|
kind: (s && s.kind) || 'apikey',
|
|
|
|
|
|
label: (s && s.label) || '',
|
|
|
|
|
|
value: (s && s.value) || { state: 'absent' },
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
|
|
|
|
|
|
valueClear: !!(s && s.valueClear),
|
2026-07-24 11:36:06 +08:00
|
|
|
|
accountId: (s && (s.accountId || s.account_id)) || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Flatten top-level secrets for legacy items; prefer nested account.secrets. */
|
|
|
|
|
|
function normalizeSecretsFromItem(item) {
|
|
|
|
|
|
var secrets = (item && item.secrets) || [];
|
|
|
|
|
|
if (secrets.length) {
|
|
|
|
|
|
return secrets.map(mapSecretSlot);
|
|
|
|
|
|
}
|
|
|
|
|
|
var out = [];
|
|
|
|
|
|
if (item && item.apikey && item.apikey.state && item.apikey.state !== 'absent') {
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
kind: 'apikey',
|
|
|
|
|
|
label: 'API Key',
|
|
|
|
|
|
value: item.apikey,
|
|
|
|
|
|
accountId: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item && item.token && item.token.state && item.token.state !== 'absent') {
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
kind: 'token',
|
|
|
|
|
|
label: 'Token',
|
|
|
|
|
|
value: item.token,
|
|
|
|
|
|
accountId: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 14:25:37 +08:00
|
|
|
|
/** 将 ISO 时间格式化为本地可读短串;无效则原样返回。 */
|
|
|
|
|
|
function formatVaultDateTime(raw) {
|
|
|
|
|
|
var s = String(raw || '').trim();
|
|
|
|
|
|
if (!s) return '';
|
|
|
|
|
|
var d = new Date(s);
|
|
|
|
|
|
if (isNaN(d.getTime())) return s;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return d.toLocaleString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
hour12: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 账号级登录态展示(扩展 Cookie session)。
|
|
|
|
|
|
* 优先账号槽位字段;合成单账号时回退条目级 hasLoginSession / sessionUpdatedAt。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function accountSessionView(acc, item, isSyntheticSingle) {
|
|
|
|
|
|
var has =
|
|
|
|
|
|
acc && typeof acc.hasLoginSession === 'boolean'
|
|
|
|
|
|
? acc.hasLoginSession
|
|
|
|
|
|
: isSyntheticSingle && item
|
|
|
|
|
|
? !!item.hasLoginSession
|
|
|
|
|
|
: false;
|
|
|
|
|
|
var savedAt =
|
|
|
|
|
|
(acc && (acc.lastLoginAt || acc.sessionUpdatedAt)) ||
|
|
|
|
|
|
(isSyntheticSingle && item
|
|
|
|
|
|
? item.lastLoginAt || item.sessionUpdatedAt || ''
|
|
|
|
|
|
: '') ||
|
|
|
|
|
|
'';
|
|
|
|
|
|
var expiresAt =
|
|
|
|
|
|
(acc && acc.sessionExpiresAt) ||
|
|
|
|
|
|
(isSyntheticSingle && item ? item.sessionExpiresAt || '' : '') ||
|
|
|
|
|
|
'';
|
|
|
|
|
|
return {
|
|
|
|
|
|
hasLoginSession: !!has,
|
|
|
|
|
|
savedAt: String(savedAt || '').trim(),
|
|
|
|
|
|
expiresAt: String(expiresAt || '').trim(),
|
|
|
|
|
|
source: (acc && acc.sessionSource) || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 详情页:账号下「是否保存登录态 + 保存日期」行。 */
|
|
|
|
|
|
function renderAccountSessionRows(sess, showEmpty) {
|
|
|
|
|
|
if (!sess.hasLoginSession && !showEmpty) {
|
|
|
|
|
|
// 未保存时也简短展示一行,避免用户以为功能缺失
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
|
|
|
|
|
|
'<label>登录态</label>' +
|
|
|
|
|
|
'<div><span class="mnote-vault-session-badge is-absent">未保存</span></div>' +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
var badge = sess.hasLoginSession
|
|
|
|
|
|
? '<span class="mnote-vault-session-badge is-saved" data-testid="vault-session-saved">已保存</span>'
|
|
|
|
|
|
: '<span class="mnote-vault-session-badge is-absent" data-testid="vault-session-absent">未保存</span>';
|
|
|
|
|
|
var dateText = sess.hasLoginSession
|
|
|
|
|
|
? formatVaultDateTime(sess.savedAt) || '—'
|
|
|
|
|
|
: '—';
|
|
|
|
|
|
var html =
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
|
|
|
|
|
|
'<label>登录态</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-session-meta">' +
|
|
|
|
|
|
badge +
|
|
|
|
|
|
'</div></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-date">' +
|
|
|
|
|
|
'<label>保存日期</label>' +
|
|
|
|
|
|
'<div>' +
|
|
|
|
|
|
(sess.hasLoginSession
|
|
|
|
|
|
? escapeHtml(dateText)
|
|
|
|
|
|
: '<span class="mnote-vault-muted">—</span>') +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
if (sess.hasLoginSession && sess.expiresAt) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-expires">' +
|
|
|
|
|
|
'<label>过期时间</label>' +
|
|
|
|
|
|
'<div>' +
|
|
|
|
|
|
escapeHtml(formatVaultDateTime(sess.expiresAt) || sess.expiresAt) +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
function normalizeAccountsFromItem(item, forEdit) {
|
|
|
|
|
|
var topSecrets = normalizeSecretsFromItem(item);
|
|
|
|
|
|
var accounts = (item && item.accounts) || [];
|
|
|
|
|
|
if (accounts.length) {
|
|
|
|
|
|
var anyNested = accounts.some(function (a) {
|
|
|
|
|
|
return a.secrets && a.secrets.length;
|
|
|
|
|
|
});
|
|
|
|
|
|
return accounts.map(function (a, aidx) {
|
|
|
|
|
|
var nested = (a.secrets || []).map(mapSecretSlot);
|
|
|
|
|
|
// Legacy: top-level secrets attach to first account when no nested.
|
|
|
|
|
|
if (!anyNested && aidx === 0 && topSecrets.length) {
|
|
|
|
|
|
nested = topSecrets.slice();
|
|
|
|
|
|
} else if (!anyNested && topSecrets.length) {
|
|
|
|
|
|
nested = topSecrets.filter(function (s) {
|
|
|
|
|
|
return s.accountId && s.accountId === a.id;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// 多账号时:若槽位未挂 session 元数据,首账号可回退条目级(兼容旧投影)
|
|
|
|
|
|
var hasSess =
|
|
|
|
|
|
typeof a.hasLoginSession === 'boolean'
|
|
|
|
|
|
? a.hasLoginSession
|
|
|
|
|
|
: aidx === 0
|
|
|
|
|
|
? !!(item && item.hasLoginSession)
|
|
|
|
|
|
: false;
|
|
|
|
|
|
var lastLogin =
|
|
|
|
|
|
a.lastLoginAt ||
|
|
|
|
|
|
(aidx === 0 && item ? item.lastLoginAt : null) ||
|
|
|
|
|
|
null;
|
|
|
|
|
|
var sessUpdated =
|
|
|
|
|
|
a.sessionUpdatedAt ||
|
|
|
|
|
|
(aidx === 0 && item ? item.sessionUpdatedAt : null) ||
|
|
|
|
|
|
null;
|
|
|
|
|
|
var sessExpires =
|
|
|
|
|
|
a.sessionExpiresAt ||
|
|
|
|
|
|
(aidx === 0 && item ? item.sessionExpiresAt : null) ||
|
|
|
|
|
|
null;
|
2026-07-24 11:36:06 +08:00
|
|
|
|
return {
|
|
|
|
|
|
id: a.id || '',
|
|
|
|
|
|
label: a.label || '',
|
|
|
|
|
|
username: forEdit
|
|
|
|
|
|
? cipherFieldValue(a.username, a.usernameTemplate)
|
|
|
|
|
|
: a.username || '',
|
|
|
|
|
|
email: forEdit
|
|
|
|
|
|
? cipherFieldValue(a.email, a.emailTemplate)
|
|
|
|
|
|
: a.email || '',
|
|
|
|
|
|
passwordHint: a.passwordHint || '',
|
|
|
|
|
|
password: a.password || { state: 'absent' },
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
|
|
|
|
|
|
passwordClear: !!a.passwordClear,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
secrets: nested,
|
2026-07-25 14:25:37 +08:00
|
|
|
|
hasLoginSession: hasSess,
|
|
|
|
|
|
lastLoginAt: lastLogin,
|
|
|
|
|
|
sessionUpdatedAt: sessUpdated,
|
|
|
|
|
|
sessionExpiresAt: sessExpires,
|
|
|
|
|
|
sessionSource: a.sessionSource || '',
|
2026-07-24 11:36:06 +08:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
(item && (item.username || item.email || item.passwordHint || item.usernameTemplate || item.emailTemplate)) ||
|
|
|
|
|
|
(item && item.password && item.password.state && item.password.state !== 'absent') ||
|
|
|
|
|
|
topSecrets.length
|
|
|
|
|
|
) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
username: forEdit
|
|
|
|
|
|
? cipherFieldValue(item && item.username, item && item.usernameTemplate)
|
|
|
|
|
|
: (item && item.username) || '',
|
|
|
|
|
|
email: forEdit
|
|
|
|
|
|
? cipherFieldValue(item && item.email, item && item.emailTemplate)
|
|
|
|
|
|
: (item && item.email) || '',
|
|
|
|
|
|
passwordHint: (item && item.passwordHint) || '',
|
|
|
|
|
|
password: (item && item.password) || { state: 'absent' },
|
|
|
|
|
|
secrets: topSecrets,
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// 合成单账号:登录态取条目级
|
|
|
|
|
|
hasLoginSession: !!(item && item.hasLoginSession),
|
|
|
|
|
|
lastLoginAt: (item && item.lastLoginAt) || null,
|
|
|
|
|
|
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
|
|
|
|
|
|
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
|
|
|
|
|
|
sessionSource: '',
|
|
|
|
|
|
_syntheticFromItem: true,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
passwordHint: '',
|
|
|
|
|
|
password: { state: 'absent' },
|
|
|
|
|
|
secrets: [],
|
2026-07-25 14:25:37 +08:00
|
|
|
|
hasLoginSession: !!(item && item.hasLoginSession),
|
|
|
|
|
|
lastLoginAt: (item && item.lastLoginAt) || null,
|
|
|
|
|
|
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
|
|
|
|
|
|
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
|
|
|
|
|
|
sessionSource: '',
|
|
|
|
|
|
_syntheticFromItem: true,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function renderDetail() {
|
|
|
|
|
|
if (!detailEl) return;
|
|
|
|
|
|
if (!state.rootUri) {
|
|
|
|
|
|
detailEl.innerHTML =
|
|
|
|
|
|
'<div class="mnote-vault-empty">请先打开本地文件夹工作区,再使用密码箱。</div>';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state.mode === 'cipher') {
|
|
|
|
|
|
renderCipherBook();
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state.mode === 'create') {
|
|
|
|
|
|
renderForm(emptyForm(state._createDefaults || {}), true);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.item && !state.selectedId) {
|
|
|
|
|
|
detailEl.innerHTML =
|
|
|
|
|
|
'<div class="mnote-vault-empty" data-testid="vault-detail-empty">从左侧选择条目,或点击「新建」。</div>';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.item) {
|
|
|
|
|
|
detailEl.innerHTML = '<div class="mnote-vault-empty">加载中…</div>';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state.mode === 'edit') {
|
|
|
|
|
|
renderForm(state.item, false);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
renderView(state.item);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
syncHeaderCipherInsertVisibility();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
function revealKey(field, accountId, secretId) {
|
|
|
|
|
|
if (secretId) return 'secret:' + secretId;
|
|
|
|
|
|
if (accountId && field === 'password') return 'account:' + accountId + ':password';
|
|
|
|
|
|
return field;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function secretRow(label, field, secretState, showEmpty, opts) {
|
|
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
var accountId = opts.accountId || '';
|
|
|
|
|
|
var secretId = opts.secretId || '';
|
|
|
|
|
|
var key = revealKey(field, accountId, secretId);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var has = secretState && secretState.state === 'masked';
|
|
|
|
|
|
var canReveal = has || (secretState && secretState.state === 'revealed');
|
|
|
|
|
|
if (!canReveal && !showEmpty) return '';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var shown = state.revealed[key]
|
|
|
|
|
|
? state._revealedValues && state._revealedValues[key]
|
|
|
|
|
|
? escapeHtml(state._revealedValues[key])
|
2026-07-21 05:13:05 +08:00
|
|
|
|
: '…'
|
|
|
|
|
|
: maskLabel(secretState);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var extra =
|
|
|
|
|
|
(accountId ? ' data-vault-account-id="' + escapeHtml(accountId) + '"' : '') +
|
|
|
|
|
|
(secretId ? ' data-vault-secret-id="' + escapeHtml(secretId) + '"' : '');
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row" data-vault-secret-row="' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
escapeHtml(key) +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'">' +
|
|
|
|
|
|
'<label>' +
|
|
|
|
|
|
escapeHtml(label) +
|
|
|
|
|
|
'</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
escapeHtml(key) +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'">' +
|
|
|
|
|
|
shown +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
'<div class="mnote-vault-secret-actions">' +
|
|
|
|
|
|
(canReveal
|
|
|
|
|
|
? '<button type="button" data-vault-reveal="' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
escapeHtml(field) +
|
|
|
|
|
|
'"' +
|
|
|
|
|
|
extra +
|
|
|
|
|
|
'>' +
|
|
|
|
|
|
(state.revealed[key] ? '隐藏' : '显示') +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'</button>' +
|
|
|
|
|
|
'<button type="button" data-vault-copy-secret="' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
escapeHtml(field) +
|
|
|
|
|
|
'"' +
|
|
|
|
|
|
extra +
|
|
|
|
|
|
'>复制</button>'
|
2026-07-21 05:13:05 +08:00
|
|
|
|
: '<span class="mnote-vault-muted">未设置</span>') +
|
|
|
|
|
|
'</div></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sameUrlAccounts(item) {
|
|
|
|
|
|
var url = (item.url || '').trim();
|
|
|
|
|
|
if (!url) return [];
|
|
|
|
|
|
return state.items.filter(function (other) {
|
|
|
|
|
|
return other.id !== item.id && (other.url || '').trim() === url;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderView(item) {
|
|
|
|
|
|
var visibleTags = (item.tags || []).filter(function (t) {
|
|
|
|
|
|
return t !== 'ai-shared';
|
|
|
|
|
|
});
|
|
|
|
|
|
var tags = visibleTags
|
|
|
|
|
|
.map(function (t) {
|
|
|
|
|
|
return '<span class="mnote-vault-tag">' + escapeHtml(t) + '</span>';
|
|
|
|
|
|
})
|
|
|
|
|
|
.join(' ');
|
|
|
|
|
|
var isDeleted = item.status === 'deleted' || state.status === 'deleted';
|
|
|
|
|
|
var siblings = sameUrlAccounts(item);
|
|
|
|
|
|
var siblingHtml = '';
|
|
|
|
|
|
if (siblings.length) {
|
|
|
|
|
|
siblingHtml =
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-siblings"><label>同站其他账号</label><div class="mnote-vault-sibling-list">' +
|
|
|
|
|
|
siblings
|
|
|
|
|
|
.map(function (s) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<button type="button" class="mnote-vault-sibling" data-vault-item-id="' +
|
|
|
|
|
|
escapeHtml(s.id) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
escapeHtml(s.title || s.username || s.id) +
|
|
|
|
|
|
(s.username ? ' · ' + escapeHtml(s.username) : '') +
|
|
|
|
|
|
'</button>'
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('') +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var shared = itemIsSharedToAi(item);
|
|
|
|
|
|
var aiCopy = itemIsAiCopy(item) || state.isAiVault;
|
|
|
|
|
|
var link = item.sharedToAi || {};
|
|
|
|
|
|
var titleChips = '';
|
|
|
|
|
|
if (shared && !state.isAiVault) {
|
|
|
|
|
|
titleChips +=
|
|
|
|
|
|
'<span class="mnote-vault-status-chip is-shared" data-testid="vault-shared-banner" title="编辑保存会自动同步到 AI 副本' +
|
|
|
|
|
|
(link.targetActorId ? ' · ' + escapeHtml(link.targetActorId) : '') +
|
|
|
|
|
|
(link.lastSyncedAt
|
|
|
|
|
|
? ' · ' + escapeHtml(String(link.lastSyncedAt).slice(0, 16).replace('T', ' '))
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'">已共享</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (aiCopy && (item.sharedFrom || state.isAiVault)) {
|
|
|
|
|
|
titleChips +=
|
|
|
|
|
|
'<span class="mnote-vault-status-chip is-ai" data-testid="vault-ai-copy-banner" title="' +
|
|
|
|
|
|
(item.sharedFrom
|
|
|
|
|
|
? '来自 ' + escapeHtml(item.sharedFrom.sourceActorId || '')
|
|
|
|
|
|
: 'AI 密码本') +
|
|
|
|
|
|
'">AI</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var shareBtn = '';
|
|
|
|
|
|
if (!isDeleted && !state.isAiVault && !aiCopy) {
|
|
|
|
|
|
shareBtn = shared
|
|
|
|
|
|
? '<button type="button" data-vault-share-ai data-testid="vault-share-ai" title="同步到 AI 副本">同步 AI</button>' +
|
|
|
|
|
|
'<button type="button" class="is-danger" data-vault-unshare-ai data-testid="vault-unshare-ai" title="撤销共享并删除 AI 密码本副本">撤销共享</button>'
|
|
|
|
|
|
: '<button type="button" data-vault-share-ai data-testid="vault-share-ai" title="共享到 AI 密码本">共享 AI</button>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var showEmpty = !!state.showEmptyFields;
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var accounts = normalizeAccountsFromItem(item, false);
|
|
|
|
|
|
var accountsHtml = accounts
|
|
|
|
|
|
.map(function (acc, idx) {
|
|
|
|
|
|
var title =
|
|
|
|
|
|
acc.label ||
|
|
|
|
|
|
acc.username ||
|
|
|
|
|
|
acc.email ||
|
|
|
|
|
|
'账号 ' + (idx + 1);
|
2026-07-25 14:25:37 +08:00
|
|
|
|
var sess = accountSessionView(
|
|
|
|
|
|
acc,
|
|
|
|
|
|
item,
|
|
|
|
|
|
!!acc._syntheticFromItem || accounts.length === 1
|
|
|
|
|
|
);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var body =
|
|
|
|
|
|
fieldRow('标签名', acc.label, showEmpty) +
|
|
|
|
|
|
fieldRow('用户名', acc.username, showEmpty) +
|
|
|
|
|
|
fieldRow('邮箱', acc.email, showEmpty) +
|
|
|
|
|
|
secretRow('密码', 'password', acc.password, showEmpty, {
|
|
|
|
|
|
accountId: acc.id || '',
|
|
|
|
|
|
}) +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
fieldRow('密码提示', acc.passwordHint, showEmpty) +
|
|
|
|
|
|
renderAccountSessionRows(sess, showEmpty);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var secList = acc.secrets || [];
|
|
|
|
|
|
var nestedSecretsHtml = secList
|
|
|
|
|
|
.map(function (sec, sidx) {
|
|
|
|
|
|
var kindLabel =
|
|
|
|
|
|
sec.kind === 'token'
|
|
|
|
|
|
? 'Token'
|
|
|
|
|
|
: sec.kind === 'other'
|
|
|
|
|
|
? '其他'
|
|
|
|
|
|
: 'API Key';
|
|
|
|
|
|
var stitle = sec.label || kindLabel + ' ' + (sidx + 1);
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-nested-secret" data-vault-secret-view="' +
|
|
|
|
|
|
escapeHtml(sec.id || String(sidx)) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<div class="mnote-vault-nested-secret-title">' +
|
|
|
|
|
|
escapeHtml(kindLabel) +
|
|
|
|
|
|
(sec.label ? ' · ' + escapeHtml(sec.label) : '') +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
secretRow(
|
|
|
|
|
|
stitle,
|
|
|
|
|
|
sec.kind === 'token' ? 'token' : 'apikey',
|
|
|
|
|
|
sec.value,
|
|
|
|
|
|
showEmpty,
|
|
|
|
|
|
{ secretId: sec.id || '' }
|
|
|
|
|
|
) +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
if (nestedSecretsHtml) {
|
|
|
|
|
|
// 附录密钥默认折叠;展开态记在 state,避免显示/隐藏密码时整页重绘后被收起。
|
|
|
|
|
|
body += renderSecretsPanelHtml({
|
|
|
|
|
|
accKey: accountUiKey(acc.id, idx),
|
|
|
|
|
|
title: '附录密钥',
|
|
|
|
|
|
count: secList.length,
|
|
|
|
|
|
bodyHtml: nestedSecretsHtml,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (showEmpty) {
|
|
|
|
|
|
body +=
|
|
|
|
|
|
'<div class="mnote-vault-muted mnote-vault-account-secrets-empty">暂无附录密钥</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!body.trim()) {
|
|
|
|
|
|
body = '<div class="mnote-vault-muted">(空账号组)</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<details class="mnote-vault-slot-group" open data-vault-account-view="' +
|
|
|
|
|
|
escapeHtml(acc.id || String(idx)) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<summary class="mnote-vault-slot-summary">' +
|
|
|
|
|
|
'<span class="mnote-vault-slot-title">账号 · ' +
|
|
|
|
|
|
escapeHtml(title) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
(secList.length
|
|
|
|
|
|
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
|
|
|
|
|
|
: '') +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
(sess.hasLoginSession
|
|
|
|
|
|
? '<span class="mnote-vault-session-badge is-saved is-compact" title="已保存登录态">登录态</span>'
|
|
|
|
|
|
: '') +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
'</summary>' +
|
|
|
|
|
|
'<div class="mnote-vault-slot-body">' +
|
|
|
|
|
|
body +
|
|
|
|
|
|
'</div></details>'
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
if (!accountsHtml && showEmpty) {
|
|
|
|
|
|
accountsHtml =
|
|
|
|
|
|
'<div class="mnote-vault-muted">暂无账号组</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
var urls = normalizeUrlsFromItem(item);
|
|
|
|
|
|
var urlsHtml = '';
|
|
|
|
|
|
if (urls.length) {
|
|
|
|
|
|
urlsHtml = urls
|
|
|
|
|
|
.map(function (u, idx) {
|
|
|
|
|
|
var label = idx === 0 ? '主网址' : '备用 ' + idx;
|
|
|
|
|
|
var link =
|
|
|
|
|
|
'<a href="' +
|
|
|
|
|
|
escapeHtml(u) +
|
|
|
|
|
|
'" target="_blank" rel="noopener noreferrer">' +
|
|
|
|
|
|
escapeHtml(u) +
|
|
|
|
|
|
'</a>';
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row">' +
|
|
|
|
|
|
'<label>' +
|
|
|
|
|
|
escapeHtml(label) +
|
|
|
|
|
|
'</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-value">' +
|
|
|
|
|
|
link +
|
|
|
|
|
|
'</div></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
} else if (showEmpty) {
|
|
|
|
|
|
urlsHtml = fieldRow('网址', '', true);
|
|
|
|
|
|
}
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var fieldsHtml =
|
|
|
|
|
|
fieldRow('分组', item.folderPath, showEmpty) +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
(urls.length > 1
|
|
|
|
|
|
? '<div class="mnote-vault-section mnote-vault-urls-view">' +
|
|
|
|
|
|
'<div class="mnote-vault-section-head"><span>网址(等价入口)</span>' +
|
|
|
|
|
|
'<span class="mnote-vault-muted">' +
|
|
|
|
|
|
urls.length +
|
|
|
|
|
|
' 个</span></div>' +
|
|
|
|
|
|
urlsHtml +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
: urls.length === 1
|
|
|
|
|
|
? fieldRow('网址', urls[0], showEmpty)
|
|
|
|
|
|
: urlsHtml) +
|
|
|
|
|
|
'<div class="mnote-vault-section">' +
|
|
|
|
|
|
'<div class="mnote-vault-section-head"><span>登录账号</span>' +
|
|
|
|
|
|
(accounts.length > 1
|
|
|
|
|
|
? '<span class="mnote-vault-muted">' + accounts.length + ' 组</span>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
accountsHtml +
|
|
|
|
|
|
'</div>' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
(visibleTags.length || showEmpty
|
|
|
|
|
|
? '<div class="mnote-vault-field-row"><label>标签</label><div>' +
|
|
|
|
|
|
(tags || '<span class="mnote-vault-muted">无</span>') +
|
|
|
|
|
|
'</div></div>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
siblingHtml +
|
|
|
|
|
|
(!isNotesEmpty(item.notesMarkdown) || showEmpty
|
|
|
|
|
|
? '<div class="mnote-vault-field-row mnote-vault-notes"><label>备注</label><pre>' +
|
|
|
|
|
|
escapeHtml(item.notesMarkdown || '') +
|
|
|
|
|
|
'</pre></div>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'<p class="mnote-vault-hint-mini">' +
|
|
|
|
|
|
'<button type="button" class="mnote-vault-linkish" data-vault-toggle-empty-fields data-testid="vault-toggle-empty-fields">' +
|
|
|
|
|
|
(showEmpty ? '隐藏空字段' : '显示空字段') +
|
|
|
|
|
|
'</button></p>';
|
|
|
|
|
|
|
|
|
|
|
|
detailEl.innerHTML =
|
|
|
|
|
|
'<div class="mnote-vault-detail-view" data-testid="vault-detail-view">' +
|
|
|
|
|
|
'<div class="mnote-vault-detail-header">' +
|
|
|
|
|
|
'<h2 class="mnote-vault-detail-title">' +
|
|
|
|
|
|
'<span class="mnote-vault-detail-title-text">' +
|
|
|
|
|
|
escapeHtml(item.title || '') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
titleChips +
|
|
|
|
|
|
'</h2>' +
|
|
|
|
|
|
'<div class="mnote-vault-detail-actions">' +
|
|
|
|
|
|
(isDeleted
|
|
|
|
|
|
? '<button type="button" data-vault-restore>恢复</button>' +
|
|
|
|
|
|
'<button type="button" class="is-danger" data-vault-purge>永久删除</button>'
|
2026-07-24 11:36:06 +08:00
|
|
|
|
: shareBtn +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'<button type="button" data-vault-edit>编辑</button>' +
|
|
|
|
|
|
'<button type="button" class="is-danger" data-vault-delete>删除</button>') +
|
|
|
|
|
|
'</div></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-fields">' +
|
|
|
|
|
|
fieldsHtml +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fieldRow(label, value, showEmpty) {
|
|
|
|
|
|
if (!value && !showEmpty) return '';
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>' +
|
|
|
|
|
|
escapeHtml(label) +
|
|
|
|
|
|
'</label><div>' +
|
|
|
|
|
|
(value
|
|
|
|
|
|
? escapeHtml(value)
|
|
|
|
|
|
: '<span class="mnote-vault-muted">—</span>') +
|
|
|
|
|
|
'</div></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 14:25:37 +08:00
|
|
|
|
/** Unique folderPath values from current list (for picker / datalist). */
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function collectFolderPaths() {
|
|
|
|
|
|
var set = {};
|
|
|
|
|
|
(state.items || []).forEach(function (item) {
|
|
|
|
|
|
var fp = (item.folderPath || '').trim();
|
|
|
|
|
|
if (!fp) return;
|
|
|
|
|
|
set[fp] = true;
|
|
|
|
|
|
var parts = fp.split('/').filter(Boolean);
|
|
|
|
|
|
for (var i = 1; i < parts.length; i++) {
|
|
|
|
|
|
set[parts.slice(0, i).join('/')] = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return Object.keys(set).sort(function (a, b) {
|
|
|
|
|
|
return a.localeCompare(b, 'zh');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 14:25:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Nested tree of known folderPath segments for the expand-style picker.
|
|
|
|
|
|
* @returns {{ name: string, path: string, children: Array }}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildFolderPickerTree(paths) {
|
|
|
|
|
|
var root = { name: '', path: '', children: [] };
|
|
|
|
|
|
var byPath = { '': root };
|
|
|
|
|
|
(paths || []).forEach(function (fp) {
|
|
|
|
|
|
var parts = String(fp || '')
|
|
|
|
|
|
.split('/')
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
var acc = [];
|
|
|
|
|
|
var parent = root;
|
|
|
|
|
|
parts.forEach(function (part) {
|
|
|
|
|
|
acc.push(part);
|
|
|
|
|
|
var p = acc.join('/');
|
|
|
|
|
|
if (!byPath[p]) {
|
|
|
|
|
|
var node = { name: part, path: p, children: [] };
|
|
|
|
|
|
byPath[p] = node;
|
|
|
|
|
|
parent.children.push(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
parent = byPath[p];
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
function sortNodes(nodes) {
|
|
|
|
|
|
nodes.sort(function (a, b) {
|
|
|
|
|
|
return a.name.localeCompare(b.name, 'zh');
|
|
|
|
|
|
});
|
|
|
|
|
|
nodes.forEach(function (n) {
|
|
|
|
|
|
if (n.children && n.children.length) sortNodes(n.children);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
sortNodes(root.children);
|
|
|
|
|
|
return root;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether a picker branch starts expanded.
|
|
|
|
|
|
* Default: all collapsed. Only expand ancestors of the current selection
|
|
|
|
|
|
* (so the selected path stays visible), or branches the user toggled open.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function isFolderPickerBranchOpen(path, depth, current) {
|
|
|
|
|
|
var ui = ensureFormUi();
|
|
|
|
|
|
if (!ui.folderPickerOpen) ui.folderPickerOpen = {};
|
|
|
|
|
|
if (Object.prototype.hasOwnProperty.call(ui.folderPickerOpen, path)) {
|
|
|
|
|
|
return !!ui.folderPickerOpen[path];
|
|
|
|
|
|
}
|
|
|
|
|
|
// Only auto-open ancestors of the selected path (not the leaf itself unless it has kids under selection).
|
|
|
|
|
|
if (current && path && current.indexOf(path + '/') === 0) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderFolderPickerNode(node, depth, current) {
|
|
|
|
|
|
var kids = node.children || [];
|
|
|
|
|
|
var hasKids = kids.length > 0;
|
|
|
|
|
|
var selected = current === node.path;
|
|
|
|
|
|
var open = hasKids && isFolderPickerBranchOpen(node.path, depth, current);
|
|
|
|
|
|
var html = '';
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-folder-picker-node" data-vault-picker-path="' +
|
|
|
|
|
|
escapeHtml(node.path) +
|
|
|
|
|
|
'" style="--vault-picker-depth:' +
|
|
|
|
|
|
depth +
|
|
|
|
|
|
'">';
|
|
|
|
|
|
html += '<div class="mnote-vault-folder-picker-row' + (selected ? ' is-selected' : '') + '">';
|
|
|
|
|
|
if (hasKids) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<button type="button" class="mnote-vault-folder-picker-chevron" data-vault-folder-picker-toggle="' +
|
|
|
|
|
|
escapeHtml(node.path) +
|
|
|
|
|
|
'" aria-expanded="' +
|
|
|
|
|
|
(open ? 'true' : 'false') +
|
|
|
|
|
|
'" title="' +
|
|
|
|
|
|
(open ? '收起' : '展开') +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
(open ? '▼' : '▶') +
|
|
|
|
|
|
'</button>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
html += '<span class="mnote-vault-folder-picker-chevron is-leaf" aria-hidden="true"></span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<button type="button" class="mnote-vault-folder-picker-label" data-vault-folder-pick="' +
|
|
|
|
|
|
escapeHtml(node.path) +
|
|
|
|
|
|
'" data-testid="vault-folder-pick-' +
|
|
|
|
|
|
escapeHtml(node.path || 'root') +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
escapeHtml(node.name || node.path) +
|
|
|
|
|
|
'</button>';
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
if (hasKids) {
|
|
|
|
|
|
html +=
|
|
|
|
|
|
'<div class="mnote-vault-folder-picker-children"' +
|
|
|
|
|
|
(open ? '' : ' hidden') +
|
|
|
|
|
|
'>';
|
|
|
|
|
|
kids.forEach(function (child) {
|
|
|
|
|
|
html += renderFolderPickerNode(child, depth + 1, current);
|
|
|
|
|
|
});
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function formFolderPathField(value) {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
var current = normalizeFolderPath(value || '');
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var paths = collectFolderPaths();
|
|
|
|
|
|
var seen = {};
|
|
|
|
|
|
paths.forEach(function (p) {
|
|
|
|
|
|
seen[p] = true;
|
|
|
|
|
|
});
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// 当前值若不在列表中,仍挂到树上便于高亮/再选。
|
|
|
|
|
|
var treePaths = paths.slice();
|
2026-07-21 05:13:05 +08:00
|
|
|
|
if (current && !seen[current]) {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
treePaths.push(current);
|
|
|
|
|
|
var parts = current.split('/').filter(Boolean);
|
|
|
|
|
|
for (var i = 1; i < parts.length; i++) {
|
|
|
|
|
|
var prefix = parts.slice(0, i).join('/');
|
|
|
|
|
|
if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
var tree = buildFolderPickerTree(treePaths);
|
|
|
|
|
|
var treeHtml = '';
|
|
|
|
|
|
treeHtml +=
|
|
|
|
|
|
'<button type="button" class="mnote-vault-folder-picker-none' +
|
|
|
|
|
|
(!current ? ' is-selected' : '') +
|
|
|
|
|
|
'" data-vault-folder-pick="" data-testid="vault-folder-pick-none">(无分组)</button>';
|
|
|
|
|
|
tree.children.forEach(function (child) {
|
|
|
|
|
|
treeHtml += renderFolderPickerNode(child, 0, current);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!tree.children.length && !current) {
|
|
|
|
|
|
treeHtml +=
|
|
|
|
|
|
'<div class="mnote-vault-folder-picker-empty mnote-vault-muted">暂无已有分组,可在下方输入新建</div>';
|
2026-07-21 05:13:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
|
|
|
|
|
|
'<label for="vault-f-folderPath">分组</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-folder-controls">' +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'<div class="mnote-vault-folder-picker" data-vault-folder-picker data-testid="vault-folder-select" role="listbox" aria-label="选择分组">' +
|
|
|
|
|
|
treeHtml +
|
|
|
|
|
|
'</div>' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
|
|
|
|
|
|
'value="' +
|
|
|
|
|
|
escapeHtml(current) +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'" placeholder="点上方分组选择,或直接输入;用 / 分层" ' +
|
|
|
|
|
|
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" autocomplete="off" />' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'<datalist id="vault-folder-path-list">' +
|
|
|
|
|
|
paths
|
|
|
|
|
|
.map(function (p) {
|
|
|
|
|
|
return '<option value="' + escapeHtml(p) + '"></option>';
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('') +
|
|
|
|
|
|
'</datalist>' +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'<p class="mnote-vault-form-hint mnote-vault-folder-hint">分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。</p>' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'</div></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 14:25:37 +08:00
|
|
|
|
function syncFolderPickerSelection(form, path) {
|
|
|
|
|
|
if (!form) return;
|
|
|
|
|
|
var picker = qs('[data-vault-folder-picker]', form);
|
|
|
|
|
|
if (!picker) return;
|
|
|
|
|
|
var normalized = normalizeFolderPath(path || '');
|
|
|
|
|
|
qsa('[data-vault-folder-pick]', picker).forEach(function (btn) {
|
|
|
|
|
|
var p = btn.getAttribute('data-vault-folder-pick');
|
|
|
|
|
|
if (p == null) p = '';
|
|
|
|
|
|
var row = btn.closest('.mnote-vault-folder-picker-row');
|
|
|
|
|
|
var selected = p === normalized;
|
|
|
|
|
|
btn.classList.toggle('is-selected', selected);
|
|
|
|
|
|
if (row) row.classList.toggle('is-selected', selected);
|
|
|
|
|
|
if (btn.classList.contains('mnote-vault-folder-picker-none')) {
|
|
|
|
|
|
btn.classList.toggle('is-selected', selected);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function bindFolderPathControls(form) {
|
|
|
|
|
|
if (!form) return;
|
|
|
|
|
|
var input = qs('[data-vault-folder-input]', form);
|
2026-07-25 14:25:37 +08:00
|
|
|
|
var picker = qs('[data-vault-folder-picker]', form);
|
|
|
|
|
|
if (picker) {
|
|
|
|
|
|
picker.addEventListener('click', function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (!(t instanceof Element)) return;
|
|
|
|
|
|
var toggle = t.closest('[data-vault-folder-picker-toggle]');
|
|
|
|
|
|
if (toggle && picker.contains(toggle)) {
|
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
var tpath = toggle.getAttribute('data-vault-folder-picker-toggle') || '';
|
|
|
|
|
|
var nodeEl = toggle.closest('.mnote-vault-folder-picker-node');
|
|
|
|
|
|
// First matching descendants is the direct children panel for this node.
|
|
|
|
|
|
var kidsEl =
|
|
|
|
|
|
nodeEl && nodeEl.querySelector('.mnote-vault-folder-picker-children');
|
|
|
|
|
|
var open = toggle.getAttribute('aria-expanded') === 'true';
|
|
|
|
|
|
var next = !open;
|
|
|
|
|
|
ensureFormUi().folderPickerOpen = ensureFormUi().folderPickerOpen || {};
|
|
|
|
|
|
ensureFormUi().folderPickerOpen[tpath] = next;
|
|
|
|
|
|
toggle.setAttribute('aria-expanded', next ? 'true' : 'false');
|
|
|
|
|
|
toggle.textContent = next ? '▼' : '▶';
|
|
|
|
|
|
toggle.setAttribute('title', next ? '收起' : '展开');
|
|
|
|
|
|
if (kidsEl) kidsEl.hidden = !next;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var pick = t.closest('[data-vault-folder-pick]');
|
|
|
|
|
|
if (pick && picker.contains(pick)) {
|
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
var chosen = pick.getAttribute('data-vault-folder-pick');
|
|
|
|
|
|
if (chosen == null) chosen = '';
|
|
|
|
|
|
chosen = normalizeFolderPath(chosen);
|
|
|
|
|
|
if (input) input.value = chosen;
|
|
|
|
|
|
syncFolderPickerSelection(form, chosen);
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
}
|
2026-07-21 05:13:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (input) {
|
|
|
|
|
|
input.addEventListener('input', function () {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
syncFolderPickerSelection(form, input.value);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('change', function () {
|
|
|
|
|
|
var n = normalizeFolderPath(input.value);
|
|
|
|
|
|
input.value = n;
|
2026-07-25 14:25:37 +08:00
|
|
|
|
syncFolderPickerSelection(form, n);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderForm(item, isCreate) {
|
|
|
|
|
|
var tagsStr = (item.tags || []).join(', ');
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。
|
|
|
|
|
|
var accounts = normalizeAccountsFromItem(item, true);
|
|
|
|
|
|
var urls = normalizeUrlsFromItem(item);
|
|
|
|
|
|
if (!urls.length) urls = [''];
|
|
|
|
|
|
state._formUrls = urls.slice();
|
2026-07-25 14:25:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Rebuild draft slots from item projection.
|
|
|
|
|
|
* reRenderFormKeepingSlots injects in-progress plaintext as
|
|
|
|
|
|
* `{ state: 'revealed', value }` — must become valueText/passwordValue,
|
|
|
|
|
|
* not be wiped to '' (that made the 1st new secret disappear after +密钥).
|
|
|
|
|
|
* valueClear/passwordClear on the pseudo-item are also preserved.
|
|
|
|
|
|
*/
|
2026-07-24 11:36:06 +08:00
|
|
|
|
state._formAccounts = accounts.map(function (a) {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
var pwd = a.password || { state: 'absent' };
|
|
|
|
|
|
var passwordClear = !!a.passwordClear;
|
|
|
|
|
|
var passwordValue = '';
|
|
|
|
|
|
var passwordState = pwd;
|
|
|
|
|
|
if (passwordClear) {
|
|
|
|
|
|
passwordState = { state: 'absent' };
|
|
|
|
|
|
passwordValue = '';
|
|
|
|
|
|
} else if (pwd && pwd.state === 'revealed') {
|
|
|
|
|
|
// Draft plaintext typed in this session (not yet saved / mid re-render).
|
|
|
|
|
|
passwordValue = String(pwd.value || '');
|
|
|
|
|
|
// Treat as "no stored secret yet" for keep-logic; save uses passwordValue.
|
|
|
|
|
|
passwordState = { state: 'absent' };
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
return {
|
|
|
|
|
|
id: a.id || newClientId('acc'),
|
|
|
|
|
|
label: a.label || '',
|
|
|
|
|
|
username: a.username || '',
|
|
|
|
|
|
email: a.email || '',
|
|
|
|
|
|
passwordHint: a.passwordHint || '',
|
2026-07-25 14:25:37 +08:00
|
|
|
|
passwordState: passwordState,
|
|
|
|
|
|
passwordClear: passwordClear,
|
|
|
|
|
|
passwordValue: passwordValue,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
secrets: (a.secrets || []).map(function (s) {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
var v = s.value || { state: 'absent' };
|
|
|
|
|
|
var valueClear = !!s.valueClear;
|
|
|
|
|
|
var valueText = '';
|
|
|
|
|
|
var valueState = v;
|
|
|
|
|
|
if (valueClear) {
|
|
|
|
|
|
valueState = { state: 'absent' };
|
|
|
|
|
|
valueText = '';
|
|
|
|
|
|
} else if (v && v.state === 'revealed') {
|
|
|
|
|
|
valueText = String(v.value || '');
|
|
|
|
|
|
valueState = { state: 'absent' };
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
return {
|
|
|
|
|
|
id: s.id || newClientId('sec'),
|
|
|
|
|
|
kind: s.kind || 'apikey',
|
|
|
|
|
|
label: s.label || '',
|
2026-07-25 14:25:37 +08:00
|
|
|
|
valueState: valueState,
|
|
|
|
|
|
valueClear: valueClear,
|
|
|
|
|
|
valueText: valueText,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
accountId: a.id || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!state._formAccounts.length) {
|
|
|
|
|
|
state._formAccounts.push({
|
|
|
|
|
|
id: newClientId('acc'),
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
passwordHint: '',
|
|
|
|
|
|
passwordState: { state: 'absent' },
|
|
|
|
|
|
passwordClear: false,
|
|
|
|
|
|
passwordValue: '',
|
|
|
|
|
|
secrets: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// Legacy flat list no longer used in UI; kept empty for re-render injects.
|
|
|
|
|
|
state._formSecrets = [];
|
|
|
|
|
|
|
|
|
|
|
|
function urlsEditorHtml() {
|
|
|
|
|
|
var rows = (state._formUrls || ['']).map(function (u, idx) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-url-row" data-vault-url-idx="' +
|
|
|
|
|
|
idx +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<label class="mnote-vault-url-label">' +
|
|
|
|
|
|
(idx === 0 ? '主网址' : '备用 ' + idx) +
|
|
|
|
|
|
'</label>' +
|
|
|
|
|
|
'<input type="url" data-vault-url-input data-url-idx="' +
|
|
|
|
|
|
idx +
|
|
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(u || '') +
|
|
|
|
|
|
'" placeholder="' +
|
|
|
|
|
|
(idx === 0
|
|
|
|
|
|
? 'https://example.com'
|
|
|
|
|
|
: '等价入口,如 FRP / 内网') +
|
|
|
|
|
|
'" autocomplete="off" />' +
|
|
|
|
|
|
(idx > 0
|
|
|
|
|
|
? '<button type="button" class="is-ghost" data-vault-remove-url="' +
|
|
|
|
|
|
idx +
|
|
|
|
|
|
'" title="移除">×</button>'
|
|
|
|
|
|
: '<span class="mnote-vault-url-spacer"></span>') +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-section" data-vault-urls-section>' +
|
|
|
|
|
|
'<div class="mnote-vault-section-head">' +
|
|
|
|
|
|
'<span>网址(等价入口 / fallback)</span>' +
|
|
|
|
|
|
'<button type="button" data-vault-add-url data-testid="vault-add-url">+ 网址</button>' +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
'<p class="mnote-vault-form-hint mnote-vault-url-hint">同一服务多个可达地址时,第一项为主入口,其余为备用(VPS / FRP / 本地等)。</p>' +
|
|
|
|
|
|
rows.join('') +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function accountGroupHtml(acc, idx) {
|
|
|
|
|
|
// Preserve which account groups were open across re-renders (not only idx===0).
|
|
|
|
|
|
var uiOpen = state._formUi && state._formUi.openAccountIds;
|
|
|
|
|
|
var open =
|
|
|
|
|
|
(uiOpen && uiOpen[acc.id] === true) ||
|
|
|
|
|
|
(uiOpen && uiOpen[acc.id] !== false && idx === 0) ||
|
|
|
|
|
|
(!uiOpen && idx === 0)
|
|
|
|
|
|
? ' open'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
var title = acc.label || acc.username || acc.email || '账号 ' + (idx + 1);
|
|
|
|
|
|
var hasPwd = acc.passwordState && acc.passwordState.state === 'masked';
|
|
|
|
|
|
var secrets = acc.secrets || [];
|
|
|
|
|
|
var secretsBody =
|
|
|
|
|
|
secrets.length > 0
|
|
|
|
|
|
? secrets
|
|
|
|
|
|
.map(function (sec, sidx) {
|
|
|
|
|
|
return secretRowHtml(sec, acc.id, sidx);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('')
|
|
|
|
|
|
: '<div class="mnote-vault-muted mnote-vault-slot-empty">暂无密钥,可点 + 密钥 添加</div>';
|
|
|
|
|
|
// 附录密钥编辑区默认折叠;展开态跨 re-render 保留(+密钥 / 显示密文等)。
|
|
|
|
|
|
var secretsPanel = renderSecretsPanelHtml({
|
|
|
|
|
|
accKey: accountUiKey(acc.id, idx),
|
|
|
|
|
|
title: '附录密钥(API Key / Token)',
|
|
|
|
|
|
count: secrets.length,
|
|
|
|
|
|
bodyHtml: secretsBody,
|
|
|
|
|
|
extraAttrs:
|
|
|
|
|
|
' data-vault-account-secrets="' + escapeHtml(acc.id) + '"',
|
|
|
|
|
|
headExtra:
|
|
|
|
|
|
'<button type="button" data-vault-add-secret-slot data-acc-id="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'" data-testid="vault-add-secret-slot">+ 密钥</button>',
|
|
|
|
|
|
});
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<details class="mnote-vault-slot-group" data-vault-account-slot="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'"' +
|
|
|
|
|
|
open +
|
|
|
|
|
|
'>' +
|
|
|
|
|
|
'<summary class="mnote-vault-slot-summary">' +
|
|
|
|
|
|
'<span class="mnote-vault-slot-title">账号 · ' +
|
|
|
|
|
|
escapeHtml(title) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
(secrets.length
|
|
|
|
|
|
? '<span class="mnote-vault-muted">密钥 ' + secrets.length + '</span>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'<span class="mnote-vault-slot-actions" onclick="event.preventDefault()">' +
|
|
|
|
|
|
(state._formAccounts.length > 1
|
|
|
|
|
|
? '<button type="button" class="is-danger" data-vault-remove-account="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'">删除</button>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</span></summary>' +
|
|
|
|
|
|
'<div class="mnote-vault-slot-body">' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>组标签</label>' +
|
|
|
|
|
|
'<input type="text" data-acc-field="label" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(acc.label) +
|
|
|
|
|
|
'" placeholder="可选,如 主号 / 工作" /></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>用户名(可用 [Key])</label>' +
|
|
|
|
|
|
'<input type="text" data-acc-field="username" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(acc.username) +
|
|
|
|
|
|
'" placeholder="明文或 [QY] 等密文片段" autocomplete="off" spellcheck="false" /></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>邮箱(可用 [Key])</label>' +
|
|
|
|
|
|
'<input type="text" data-acc-field="email" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(acc.email) +
|
|
|
|
|
|
'" placeholder="明文或 [QY] 等密文片段" autocomplete="off" spellcheck="false" /></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>密码(可用 [Key])</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-secret-edit">' +
|
|
|
|
|
|
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'" class="mnote-vault-secret-input-plain" value="' +
|
|
|
|
|
|
escapeHtml(acc.passwordValue || '') +
|
|
|
|
|
|
'" placeholder="' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
escapeHtml(
|
2026-07-25 14:25:37 +08:00
|
|
|
|
!isCreate && hasPwd && !acc.passwordValue
|
2026-07-24 11:36:06 +08:00
|
|
|
|
? '留空不改;输入新值覆盖'
|
|
|
|
|
|
: '可选;可用 [Key] 密文片段'
|
|
|
|
|
|
) +
|
|
|
|
|
|
'" />' +
|
|
|
|
|
|
(!isCreate && hasPwd
|
|
|
|
|
|
? '<button type="button" data-acc-show-password="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'"' +
|
|
|
|
|
|
(acc.passwordValue ? ' data-shown="1"' : '') +
|
|
|
|
|
|
' data-testid="vault-acc-show-password">' +
|
|
|
|
|
|
(acc.passwordValue ? '隐藏' : '显示') +
|
|
|
|
|
|
'</button>' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
'<button type="button" data-acc-clear-password="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'">清空</button>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</div></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>密码提示</label>' +
|
|
|
|
|
|
'<input type="text" data-acc-field="passwordHint" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(acc.id) +
|
|
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(acc.passwordHint) +
|
|
|
|
|
|
'" /></div>' +
|
|
|
|
|
|
secretsPanel +
|
|
|
|
|
|
'</div></details>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function secretRowHtml(sec, accountId, idx) {
|
|
|
|
|
|
var kindLabel =
|
|
|
|
|
|
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
|
|
|
|
|
|
var title = sec.label || kindLabel + ' ' + (idx + 1);
|
|
|
|
|
|
var hasVal = sec.valueState && sec.valueState.state === 'masked';
|
2026-07-25 14:25:37 +08:00
|
|
|
|
var draftText = sec.valueText || '';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<div class="mnote-vault-nested-secret-head">' +
|
|
|
|
|
|
'<span class="mnote-vault-nested-secret-title">' +
|
|
|
|
|
|
escapeHtml(kindLabel) +
|
|
|
|
|
|
' · ' +
|
|
|
|
|
|
escapeHtml(title) +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<button type="button" class="is-danger" data-vault-remove-secret="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
|
|
|
|
|
'">删除</button>' +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>类型</label>' +
|
|
|
|
|
|
'<select data-sec-field="kind" data-sec-id="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<option value="apikey"' +
|
|
|
|
|
|
(sec.kind === 'apikey' ? ' selected' : '') +
|
|
|
|
|
|
'>API Key</option>' +
|
|
|
|
|
|
'<option value="token"' +
|
|
|
|
|
|
(sec.kind === 'token' ? ' selected' : '') +
|
|
|
|
|
|
'>Token</option>' +
|
|
|
|
|
|
'<option value="other"' +
|
|
|
|
|
|
(sec.kind === 'other' ? ' selected' : '') +
|
|
|
|
|
|
'>其他</option>' +
|
|
|
|
|
|
'</select></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>标签</label>' +
|
|
|
|
|
|
'<input type="text" data-sec-field="label" data-sec-id="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
|
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(sec.label) +
|
|
|
|
|
|
'" placeholder="如 Gemini free / App 密码" /></div>' +
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label>值</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-secret-edit">' +
|
|
|
|
|
|
'<input type="text" autocomplete="off" spellcheck="false" class="mnote-vault-secret-input-plain" data-sec-field="value" data-sec-id="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'" value="' +
|
|
|
|
|
|
escapeHtml(draftText) +
|
|
|
|
|
|
'" placeholder="' +
|
|
|
|
|
|
escapeHtml(hasVal && !draftText ? '留空不改;输入新值覆盖' : '密钥明文') +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
'" />' +
|
|
|
|
|
|
(hasVal
|
|
|
|
|
|
? '<button type="button" data-sec-show-value="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
2026-07-25 14:25:37 +08:00
|
|
|
|
'"' +
|
|
|
|
|
|
(draftText ? ' data-shown="1"' : '') +
|
|
|
|
|
|
' data-testid="vault-sec-show-value">' +
|
|
|
|
|
|
(draftText ? '隐藏' : '显示') +
|
|
|
|
|
|
'</button>' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
'<button type="button" data-sec-clear-value="' +
|
|
|
|
|
|
escapeHtml(sec.id) +
|
|
|
|
|
|
'" data-acc-id="' +
|
|
|
|
|
|
escapeHtml(accountId) +
|
|
|
|
|
|
'">清空</button>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</div></div>' +
|
|
|
|
|
|
'</div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
detailEl.innerHTML =
|
|
|
|
|
|
'<form class="mnote-vault-form" data-testid="vault-form" data-vault-form="true">' +
|
|
|
|
|
|
'<div class="mnote-vault-detail-header">' +
|
|
|
|
|
|
'<h2>' +
|
|
|
|
|
|
(isCreate ? '新建条目' : '编辑条目') +
|
|
|
|
|
|
'</h2>' +
|
|
|
|
|
|
'<div class="mnote-vault-detail-actions">' +
|
|
|
|
|
|
'<button type="button" data-vault-cancel>取消</button>' +
|
|
|
|
|
|
'<button type="submit" data-vault-save data-testid="vault-save">保存</button>' +
|
|
|
|
|
|
'</div></div>' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
'<p class="mnote-vault-form-hint">可先点用户名 / 邮箱 / 密码框,再点顶栏「插入密文」选择已有 [Key];也可手写 <code>[Key]</code>。</p>' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'<div class="mnote-vault-fields">' +
|
|
|
|
|
|
formField('title', '标题 *', item.title || '', true) +
|
|
|
|
|
|
formFolderPathField(item.folderPath || '') +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
urlsEditorHtml() +
|
|
|
|
|
|
'<div class="mnote-vault-section" data-vault-accounts-section>' +
|
|
|
|
|
|
'<div class="mnote-vault-section-head">' +
|
|
|
|
|
|
'<span>登录账号(用户名 / 邮箱 / 密码 / 提示)</span>' +
|
|
|
|
|
|
'<button type="button" data-vault-add-account-slot data-testid="vault-add-account-slot">+ 账号</button>' +
|
|
|
|
|
|
'</div>' +
|
|
|
|
|
|
state._formAccounts.map(accountGroupHtml).join('') +
|
|
|
|
|
|
'</div>' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
formField('tags', '标签(逗号分隔)', tagsStr, false) +
|
|
|
|
|
|
'<div class="mnote-vault-field-row mnote-vault-notes"><label>备注</label>' +
|
|
|
|
|
|
'<textarea name="notesMarkdown" rows="6">' +
|
|
|
|
|
|
escapeHtml(item.notesMarkdown || '') +
|
|
|
|
|
|
'</textarea></div>' +
|
|
|
|
|
|
'</div></form>';
|
|
|
|
|
|
var form = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (form) {
|
2026-07-24 11:36:06 +08:00
|
|
|
|
state.lastCipherInsertTarget = null;
|
|
|
|
|
|
state.lastCipherInsertRange = null;
|
2026-07-21 05:13:05 +08:00
|
|
|
|
bindFolderPathControls(form);
|
2026-07-24 11:36:06 +08:00
|
|
|
|
bindFormCipherInsertTracking(form);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
form.addEventListener('input', function () {
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
});
|
2026-07-24 11:36:06 +08:00
|
|
|
|
form.addEventListener('change', function () {
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
});
|
2026-07-21 05:13:05 +08:00
|
|
|
|
form.addEventListener('submit', function (ev) {
|
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
saveForm(form, isCreate);
|
|
|
|
|
|
});
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 顶栏「插入密文」在编辑态刷新 key 列表
|
|
|
|
|
|
fetchCipherKeys({ silent: true })
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
refreshCipherInsertSelect();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function () {
|
|
|
|
|
|
/* 静默:下拉仍显示已有缓存或空 */
|
|
|
|
|
|
});
|
2026-07-21 05:13:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
function snapshotFormSlots(form) {
|
|
|
|
|
|
if (!form) return;
|
|
|
|
|
|
var urlInputs = form.querySelectorAll('[data-vault-url-input]');
|
|
|
|
|
|
if (urlInputs && urlInputs.length) {
|
|
|
|
|
|
state._formUrls = [];
|
|
|
|
|
|
for (var ui = 0; ui < urlInputs.length; ui++) {
|
|
|
|
|
|
state._formUrls.push(urlInputs[ui].value || '');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state._formAccounts) return;
|
|
|
|
|
|
state._formAccounts.forEach(function (acc) {
|
|
|
|
|
|
var id = acc.id;
|
|
|
|
|
|
var labelEl = form.querySelector('[data-acc-field="label"][data-acc-id="' + id + '"]');
|
|
|
|
|
|
var userEl = form.querySelector('[data-acc-field="username"][data-acc-id="' + id + '"]');
|
|
|
|
|
|
var emailEl = form.querySelector('[data-acc-field="email"][data-acc-id="' + id + '"]');
|
|
|
|
|
|
var hintEl = form.querySelector(
|
|
|
|
|
|
'[data-acc-field="passwordHint"][data-acc-id="' + id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
var pwdEl = form.querySelector('[data-acc-field="password"][data-acc-id="' + id + '"]');
|
|
|
|
|
|
if (labelEl) acc.label = labelEl.value;
|
|
|
|
|
|
if (userEl) acc.username = userEl.value;
|
|
|
|
|
|
if (emailEl) acc.email = emailEl.value;
|
|
|
|
|
|
if (hintEl) acc.passwordHint = hintEl.value;
|
|
|
|
|
|
if (pwdEl) acc.passwordValue = pwdEl.value;
|
|
|
|
|
|
(acc.secrets || []).forEach(function (sec) {
|
|
|
|
|
|
var sid = sec.id;
|
|
|
|
|
|
var kindEl = form.querySelector(
|
|
|
|
|
|
'[data-sec-field="kind"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
var secLabelEl = form.querySelector(
|
|
|
|
|
|
'[data-sec-field="label"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
var valEl = form.querySelector(
|
|
|
|
|
|
'[data-sec-field="value"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (kindEl) sec.kind = kindEl.value;
|
|
|
|
|
|
if (secLabelEl) sec.label = secLabelEl.value;
|
|
|
|
|
|
if (valEl) sec.valueText = valEl.value;
|
|
|
|
|
|
sec.accountId = id;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
state._formSecrets = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collectFormUrls(form) {
|
|
|
|
|
|
var raw = [];
|
|
|
|
|
|
if (form) {
|
|
|
|
|
|
var inputs = form.querySelectorAll('[data-vault-url-input]');
|
|
|
|
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
|
|
|
|
raw.push(String(inputs[i].value || '').trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (state._formUrls) {
|
|
|
|
|
|
raw = state._formUrls.map(function (u) {
|
|
|
|
|
|
return String(u || '').trim();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
var out = [];
|
|
|
|
|
|
raw.forEach(function (t) {
|
|
|
|
|
|
if (!t) return;
|
|
|
|
|
|
if (out.indexOf(t) >= 0) return;
|
|
|
|
|
|
out.push(t);
|
|
|
|
|
|
});
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reRenderFormKeepingSlots(isCreate) {
|
|
|
|
|
|
var form = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (form) snapshotFormSlots(form);
|
|
|
|
|
|
var base = isCreate
|
|
|
|
|
|
? emptyForm(state._createDefaults || {})
|
|
|
|
|
|
: state.item || emptyForm();
|
|
|
|
|
|
// inject draft slots into a pseudo item for normalize
|
|
|
|
|
|
base = Object.assign({}, base, {
|
|
|
|
|
|
urls: (state._formUrls || []).slice(),
|
|
|
|
|
|
url: (state._formUrls && state._formUrls[0]) || base.url || '',
|
|
|
|
|
|
accounts: (state._formAccounts || []).map(function (a) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: a.id,
|
|
|
|
|
|
label: a.label,
|
|
|
|
|
|
username: a.username,
|
|
|
|
|
|
email: a.email,
|
|
|
|
|
|
passwordHint: a.passwordHint,
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// Flags survive re-render so clear intent is not lost.
|
|
|
|
|
|
passwordClear: !!a.passwordClear,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
password: a.passwordClear
|
|
|
|
|
|
? { state: 'absent' }
|
|
|
|
|
|
: a.passwordValue
|
|
|
|
|
|
? { state: 'revealed', value: a.passwordValue }
|
|
|
|
|
|
: a.passwordState,
|
|
|
|
|
|
secrets: (a.secrets || []).map(function (s) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: s.id,
|
|
|
|
|
|
kind: s.kind,
|
|
|
|
|
|
label: s.label,
|
|
|
|
|
|
accountId: a.id,
|
2026-07-25 14:25:37 +08:00
|
|
|
|
valueClear: !!s.valueClear,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
value: s.valueClear
|
|
|
|
|
|
? { state: 'absent' }
|
|
|
|
|
|
: s.valueText
|
|
|
|
|
|
? { state: 'revealed', value: s.valueText }
|
|
|
|
|
|
: s.valueState,
|
|
|
|
|
|
};
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}),
|
|
|
|
|
|
secrets: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
// preserve title/url etc from form
|
|
|
|
|
|
if (form) {
|
|
|
|
|
|
var fd = new FormData(form);
|
|
|
|
|
|
base.title = String(fd.get('title') || base.title || '');
|
|
|
|
|
|
var draftUrls = collectFormUrls(form);
|
|
|
|
|
|
if (draftUrls.length || (state._formUrls && state._formUrls.length)) {
|
|
|
|
|
|
// Keep empty draft rows while editing multi-url so re-render doesn't drop blanks mid-type.
|
|
|
|
|
|
base.urls = (state._formUrls || draftUrls).slice();
|
|
|
|
|
|
base.url = draftUrls[0] || base.urls[0] || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
base.folderPath = String(fd.get('folderPath') || base.folderPath || '');
|
|
|
|
|
|
base.tags = String(fd.get('tags') || '')
|
|
|
|
|
|
.split(/[,,;;\n]/)
|
|
|
|
|
|
.map(function (s) {
|
|
|
|
|
|
return s.trim();
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
base.notesMarkdown = String(fd.get('notesMarkdown') || base.notesMarkdown || '');
|
|
|
|
|
|
}
|
|
|
|
|
|
renderForm(base, isCreate);
|
|
|
|
|
|
// restore typed secret values after re-render
|
|
|
|
|
|
var form2 = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (!form2) return;
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
var pwdEl = form2.querySelector(
|
|
|
|
|
|
'[data-acc-field="password"][data-acc-id="' + acc.id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (pwdEl && acc.passwordValue) pwdEl.value = acc.passwordValue;
|
|
|
|
|
|
var showPwdBtn = form2.querySelector(
|
|
|
|
|
|
'[data-acc-show-password="' + acc.id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (showPwdBtn && acc.passwordValue && !acc.passwordClear) {
|
|
|
|
|
|
showPwdBtn.setAttribute('data-shown', '1');
|
|
|
|
|
|
showPwdBtn.textContent = '隐藏';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (sec) {
|
|
|
|
|
|
var valEl = form2.querySelector(
|
|
|
|
|
|
'[data-sec-field="value"][data-sec-id="' +
|
|
|
|
|
|
sec.id +
|
|
|
|
|
|
'"][data-acc-id="' +
|
|
|
|
|
|
acc.id +
|
|
|
|
|
|
'"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (valEl && sec.valueText) valEl.value = sec.valueText;
|
|
|
|
|
|
var showSecBtn = form2.querySelector(
|
|
|
|
|
|
'[data-sec-show-value="' + sec.id + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (showSecBtn && sec.valueText && !sec.valueClear) {
|
|
|
|
|
|
showSecBtn.setAttribute('data-shown', '1');
|
|
|
|
|
|
showSecBtn.textContent = '隐藏';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function formField(name, label, value, required) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label for="vault-f-' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
escapeHtml(label) +
|
|
|
|
|
|
'</label><input id="vault-f-' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'" name="' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'" type="text" value="' +
|
|
|
|
|
|
escapeHtml(value) +
|
|
|
|
|
|
'"' +
|
|
|
|
|
|
(required ? ' required' : '') +
|
|
|
|
|
|
' /></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formSecretField(name, label, secretState, isCreate) {
|
|
|
|
|
|
var placeholder =
|
|
|
|
|
|
!isCreate && secretState && secretState.state === 'masked'
|
|
|
|
|
|
? '留空表示不修改;输入新值覆盖;点「清空」删除'
|
|
|
|
|
|
: '可选;可用 [Key] 密文片段';
|
|
|
|
|
|
// Editing secrets is always plaintext so users can see typos / [Key] templates.
|
|
|
|
|
|
// View mode still masks; reveal remains explicit + audited.
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-field-row"><label for="vault-f-' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
escapeHtml(label) +
|
|
|
|
|
|
'</label>' +
|
|
|
|
|
|
'<div class="mnote-vault-secret-edit">' +
|
|
|
|
|
|
'<input id="vault-f-' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'" name="' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'" type="text" autocomplete="off" spellcheck="false" placeholder="' +
|
|
|
|
|
|
escapeHtml(placeholder) +
|
|
|
|
|
|
'" data-secret-field="' +
|
|
|
|
|
|
name +
|
|
|
|
|
|
'" class="mnote-vault-secret-input-plain" />' +
|
|
|
|
|
|
(!isCreate
|
|
|
|
|
|
? '<button type="button" data-vault-clear-secret="' + name + '">清空</button>'
|
|
|
|
|
|
: '') +
|
|
|
|
|
|
'</div></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadList() {
|
|
|
|
|
|
if (!state.rootUri) {
|
|
|
|
|
|
state.items = [];
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
setStatus('未绑定本地文件夹 rootUri', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setStatus('加载列表…', 'info');
|
|
|
|
|
|
try {
|
|
|
|
|
|
var result = await apiJson('GET', '/api/vault/list', state.rootUri, null, {
|
|
|
|
|
|
status: state.status,
|
|
|
|
|
|
});
|
|
|
|
|
|
state.items = (result && result.items) || [];
|
|
|
|
|
|
state.isAiVault = !!(result && result.isAiVault);
|
|
|
|
|
|
state.aiVaultActorId =
|
|
|
|
|
|
(result && result.aiVaultActorId) || state.aiVaultActorId || 'mnote-e2e';
|
|
|
|
|
|
state.vaultRole = (result && result.vaultRole) || (state.isAiVault ? 'ai' : 'user');
|
|
|
|
|
|
updateHeaderChrome();
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
if (state.selectedId) {
|
|
|
|
|
|
var still = state.items.some(function (i) {
|
|
|
|
|
|
return i.id === state.selectedId;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!still && state.mode !== 'create') {
|
|
|
|
|
|
state.selectedId = '';
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state.selectedId && state.mode !== 'create' && state.mode !== 'edit') {
|
|
|
|
|
|
await loadItem(state.selectedId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
}
|
|
|
|
|
|
setStatus(
|
|
|
|
|
|
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条',
|
|
|
|
|
|
'success'
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadItem(id) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
var result = await apiJson('GET', '/api/vault/items/' + encodeURIComponent(id), state.rootUri);
|
|
|
|
|
|
state.item = result.item || result;
|
|
|
|
|
|
state.selectedId = id;
|
|
|
|
|
|
state.mode = 'view';
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
state.revealed = { password: false, apikey: false, token: false };
|
|
|
|
|
|
state._revealedValues = {};
|
|
|
|
|
|
state.secretDraft = { password: null, apikey: null, token: null };
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 切换条目时重置附录密钥展开态(同一条目内 reveal 仍保留)。
|
|
|
|
|
|
ensureFormUi().expandedSecretPanels = {};
|
2026-07-21 05:13:05 +08:00
|
|
|
|
renderList();
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveForm(form, isCreate) {
|
2026-07-24 11:36:06 +08:00
|
|
|
|
snapshotFormSlots(form);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var fd = new FormData(form);
|
|
|
|
|
|
var title = String(fd.get('title') || '').trim();
|
|
|
|
|
|
if (!title) {
|
|
|
|
|
|
toast('标题不能为空', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var tagsRaw = String(fd.get('tags') || '');
|
|
|
|
|
|
var tags = tagsRaw
|
|
|
|
|
|
.split(/[,,;;\n]/)
|
|
|
|
|
|
.map(function (s) {
|
|
|
|
|
|
return s.trim();
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var accounts = (state._formAccounts || [])
|
|
|
|
|
|
.map(function (acc) {
|
2026-07-25 14:25:37 +08:00
|
|
|
|
// Prefer live input draft; fall back to revealed draft left by re-render inject.
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var raw = String(acc.passwordValue || '');
|
2026-07-25 14:25:37 +08:00
|
|
|
|
if (
|
|
|
|
|
|
!raw &&
|
|
|
|
|
|
acc.passwordState &&
|
|
|
|
|
|
acc.passwordState.state === 'revealed' &&
|
|
|
|
|
|
acc.passwordState.value
|
|
|
|
|
|
) {
|
|
|
|
|
|
raw = String(acc.passwordState.value);
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
if (/^[•*·]+$/.test(raw) || raw === '••••••••') {
|
|
|
|
|
|
toast('不能将掩码写回 password', 'error');
|
|
|
|
|
|
throw new Error('mask');
|
|
|
|
|
|
}
|
|
|
|
|
|
var nestedSecrets = (acc.secrets || []).map(function (sec) {
|
|
|
|
|
|
var sraw = String(sec.valueText || '');
|
2026-07-25 14:25:37 +08:00
|
|
|
|
if (
|
|
|
|
|
|
!sraw &&
|
|
|
|
|
|
sec.valueState &&
|
|
|
|
|
|
sec.valueState.state === 'revealed' &&
|
|
|
|
|
|
sec.valueState.value
|
|
|
|
|
|
) {
|
|
|
|
|
|
sraw = String(sec.valueState.value);
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') {
|
|
|
|
|
|
toast('不能将掩码写回 secret', 'error');
|
|
|
|
|
|
throw new Error('mask');
|
|
|
|
|
|
}
|
|
|
|
|
|
var sslot = {
|
|
|
|
|
|
id: sec.id || undefined,
|
|
|
|
|
|
kind: sec.kind || 'apikey',
|
|
|
|
|
|
label: String(sec.label || '').trim() || null,
|
|
|
|
|
|
accountId: acc.id || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (isCreate) {
|
|
|
|
|
|
if (sraw) sslot.value = sraw;
|
|
|
|
|
|
} else if (sec.valueClear) {
|
|
|
|
|
|
sslot.value = { clear: true };
|
|
|
|
|
|
} else if (sraw) {
|
|
|
|
|
|
sslot.value = { set: sraw };
|
|
|
|
|
|
} else if (sec.valueState && sec.valueState.state === 'masked') {
|
|
|
|
|
|
sslot._keep = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return sslot;
|
|
|
|
|
|
});
|
|
|
|
|
|
var slot = {
|
|
|
|
|
|
id: acc.id && String(acc.id).indexOf('acc_') === 0 ? acc.id : acc.id || undefined,
|
|
|
|
|
|
label: String(acc.label || '').trim() || null,
|
|
|
|
|
|
username: String(acc.username || '').trim() || null,
|
|
|
|
|
|
email: String(acc.email || '').trim() || null,
|
|
|
|
|
|
passwordHint: String(acc.passwordHint || '').trim() || null,
|
|
|
|
|
|
secrets: nestedSecrets,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (isCreate) {
|
|
|
|
|
|
if (raw) slot.password = raw;
|
|
|
|
|
|
} else if (acc.passwordClear) {
|
|
|
|
|
|
slot.password = { clear: true };
|
|
|
|
|
|
} else if (raw) {
|
|
|
|
|
|
slot.password = { set: raw };
|
|
|
|
|
|
}
|
|
|
|
|
|
return slot;
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(function (slot) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
slot.label ||
|
|
|
|
|
|
slot.username ||
|
|
|
|
|
|
slot.email ||
|
|
|
|
|
|
slot.passwordHint ||
|
|
|
|
|
|
slot.password ||
|
|
|
|
|
|
(slot.secrets && slot.secrets.length)
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
// Flatten nested secrets for top-level API field (compat + server rebuild).
|
|
|
|
|
|
var secrets = [];
|
|
|
|
|
|
accounts.forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (s) {
|
|
|
|
|
|
secrets.push(s);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Edit full-replace: masked fields that were not retyped carry `_keep`.
|
|
|
|
|
|
// Do NOT reveal them client-side (avoid race / drop). Strip `_keep` and omit
|
|
|
|
|
|
// value/password; server merges previous secrets by id. Explicit clear uses
|
|
|
|
|
|
// `{ clear: true }` → empty string on the wire and is not restored.
|
|
|
|
|
|
function stripKeepMarkers(list) {
|
|
|
|
|
|
return (list || []).map(function (s) {
|
|
|
|
|
|
var copy = Object.assign({}, s);
|
|
|
|
|
|
delete copy._keep;
|
|
|
|
|
|
return copy;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isCreate) {
|
|
|
|
|
|
// accounts keep password when not retyped and not cleared
|
|
|
|
|
|
for (var ai = 0; ai < accounts.length; ai++) {
|
|
|
|
|
|
var a = accounts[ai];
|
|
|
|
|
|
if (!a.password && a.id) {
|
|
|
|
|
|
var draftA = (state._formAccounts || []).find(function (x) {
|
|
|
|
|
|
return x.id === a.id;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (
|
|
|
|
|
|
draftA &&
|
|
|
|
|
|
!draftA.passwordClear &&
|
|
|
|
|
|
draftA.passwordState &&
|
|
|
|
|
|
draftA.passwordState.state === 'masked'
|
|
|
|
|
|
) {
|
|
|
|
|
|
a._keep = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
accounts = stripKeepMarkers(accounts);
|
|
|
|
|
|
accounts.forEach(function (acc) {
|
|
|
|
|
|
if (acc.secrets && acc.secrets.length) {
|
|
|
|
|
|
acc.secrets = stripKeepMarkers(acc.secrets);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
secrets = [];
|
|
|
|
|
|
accounts.forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (s) {
|
|
|
|
|
|
secrets.push(s);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// create: drop empty secret shells
|
|
|
|
|
|
accounts.forEach(function (acc) {
|
|
|
|
|
|
acc.secrets = (acc.secrets || []).filter(function (s) {
|
|
|
|
|
|
return s.value || s.label;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
secrets = [];
|
|
|
|
|
|
accounts.forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (s) {
|
|
|
|
|
|
secrets.push(s);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var urls = collectFormUrls(form);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var body = {
|
|
|
|
|
|
title: title,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
url: urls[0] || null,
|
|
|
|
|
|
urls: urls,
|
2026-07-21 05:13:05 +08:00
|
|
|
|
folderPath: folderPath || null,
|
|
|
|
|
|
tags: tags,
|
|
|
|
|
|
notesMarkdown: String(fd.get('notesMarkdown') || ''),
|
2026-07-24 11:36:06 +08:00
|
|
|
|
accounts: accounts,
|
|
|
|
|
|
secrets: secrets,
|
2026-07-21 05:13:05 +08:00
|
|
|
|
syncAiShare: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
var result;
|
|
|
|
|
|
if (isCreate) {
|
|
|
|
|
|
result = await apiJson('POST', '/api/vault/items', state.rootUri, body);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
body.baseRevision = state.item && state.item.revision;
|
|
|
|
|
|
result = await apiJson(
|
|
|
|
|
|
'PATCH',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId),
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
body
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
state.item = result.item || result;
|
|
|
|
|
|
state.selectedId = state.item.id;
|
|
|
|
|
|
state.mode = 'view';
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
state.secretDraft = { password: null, apikey: null, token: null };
|
|
|
|
|
|
state._createDefaults = null;
|
|
|
|
|
|
var msg = isCreate ? '已创建' : '已保存';
|
|
|
|
|
|
var aiSync = result && result.aiShareSync;
|
|
|
|
|
|
if (aiSync && aiSync.ok) {
|
|
|
|
|
|
msg += ' · 已同步 AI 副本';
|
|
|
|
|
|
} else if (aiSync && aiSync.ok === false) {
|
|
|
|
|
|
msg += ' · AI 同步未完成(' + (aiSync.message || aiSync.code || '') + ')';
|
|
|
|
|
|
}
|
|
|
|
|
|
toast(msg, aiSync && aiSync.ok === false ? 'info' : 'success');
|
|
|
|
|
|
setStatus(msg, 'success');
|
|
|
|
|
|
await loadList();
|
|
|
|
|
|
await loadItem(state.selectedId);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (err.message === 'mask') return;
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
/** 仅拉取密文 key 列表(L0,无明文),供编辑表单「插入密文」使用。 */
|
|
|
|
|
|
async function fetchCipherKeys(options) {
|
|
|
|
|
|
var opts = options || {};
|
2026-07-21 05:13:05 +08:00
|
|
|
|
try {
|
|
|
|
|
|
var result = await apiJson('GET', '/api/vault/cipher-book', state.rootUri);
|
|
|
|
|
|
state.cipherKeys = (result && result.keys) || [];
|
2026-07-24 11:36:06 +08:00
|
|
|
|
if (opts.resetRevealed) state.cipherRevealed = {};
|
|
|
|
|
|
if (state.mode === 'create' || state.mode === 'edit') {
|
|
|
|
|
|
refreshCipherInsertSelect();
|
|
|
|
|
|
}
|
|
|
|
|
|
return state.cipherKeys;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!opts.silent) {
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadCipherBook() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetchCipherKeys({ resetRevealed: true });
|
2026-07-21 05:13:05 +08:00
|
|
|
|
renderCipherBook();
|
|
|
|
|
|
setStatus('密文簿 ' + state.cipherKeys.length + ' 个 key', 'info');
|
|
|
|
|
|
} catch (err) {
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// fetchCipherKeys 已 toast
|
2026-07-21 05:13:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
/** 判断输入是否支持插入 [Key] 密文片段(用户名/邮箱/密码/附录密钥值)。 */
|
|
|
|
|
|
function isCipherInsertTarget(el) {
|
|
|
|
|
|
if (!el || !(el instanceof HTMLElement)) return false;
|
|
|
|
|
|
if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return false;
|
|
|
|
|
|
if (el.disabled || el.readOnly) return false;
|
|
|
|
|
|
var acc = el.getAttribute('data-acc-field');
|
|
|
|
|
|
if (acc === 'username' || acc === 'email' || acc === 'password') return true;
|
|
|
|
|
|
if (el.getAttribute('data-sec-field') === 'value') return true;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rememberCipherInsertTarget(el) {
|
|
|
|
|
|
if (isCipherInsertTarget(el)) {
|
|
|
|
|
|
state.lastCipherInsertTarget = el;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') {
|
|
|
|
|
|
state.lastCipherInsertRange = {
|
|
|
|
|
|
start: el.selectionStart,
|
|
|
|
|
|
end: el.selectionEnd,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function captureCipherInsertRange(el) {
|
|
|
|
|
|
if (!isCipherInsertTarget(el)) return;
|
|
|
|
|
|
state.lastCipherInsertTarget = el;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') {
|
|
|
|
|
|
state.lastCipherInsertRange = {
|
|
|
|
|
|
start: el.selectionStart,
|
|
|
|
|
|
end: el.selectionEnd,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveCipherInsertTarget(form) {
|
|
|
|
|
|
var active = document.activeElement;
|
|
|
|
|
|
if (form && active && form.contains(active) && isCipherInsertTarget(active)) {
|
|
|
|
|
|
captureCipherInsertRange(active);
|
|
|
|
|
|
return active;
|
|
|
|
|
|
}
|
|
|
|
|
|
var remembered = state.lastCipherInsertTarget;
|
|
|
|
|
|
if (
|
|
|
|
|
|
remembered &&
|
|
|
|
|
|
form &&
|
|
|
|
|
|
form.contains(remembered) &&
|
|
|
|
|
|
isCipherInsertTarget(remembered)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return remembered;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 回退:第一个密码框 → 用户名 → 邮箱 → 密钥值
|
|
|
|
|
|
if (!form) return null;
|
|
|
|
|
|
state.lastCipherInsertRange = null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
form.querySelector('[data-acc-field="password"]') ||
|
|
|
|
|
|
form.querySelector('[data-acc-field="username"]') ||
|
|
|
|
|
|
form.querySelector('[data-acc-field="email"]') ||
|
|
|
|
|
|
form.querySelector('[data-sec-field="value"]')
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 在光标处插入 `[Key]`;不改服务端解析规则。 */
|
|
|
|
|
|
function insertCipherKeyIntoField(input, key) {
|
|
|
|
|
|
if (!input || !key) return false;
|
|
|
|
|
|
var token = '[' + key + ']';
|
|
|
|
|
|
var start;
|
|
|
|
|
|
var end;
|
|
|
|
|
|
var range = state.lastCipherInsertRange;
|
|
|
|
|
|
if (
|
|
|
|
|
|
range &&
|
|
|
|
|
|
state.lastCipherInsertTarget === input &&
|
|
|
|
|
|
typeof range.start === 'number' &&
|
|
|
|
|
|
typeof range.end === 'number'
|
|
|
|
|
|
) {
|
|
|
|
|
|
start = range.start;
|
|
|
|
|
|
end = range.end;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
start =
|
|
|
|
|
|
typeof input.selectionStart === 'number'
|
|
|
|
|
|
? input.selectionStart
|
|
|
|
|
|
: input.value.length;
|
|
|
|
|
|
end =
|
|
|
|
|
|
typeof input.selectionEnd === 'number' ? input.selectionEnd : input.value.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 钳制到当前值长度(输入可能在失焦后被改过)
|
|
|
|
|
|
var len = input.value.length;
|
|
|
|
|
|
if (start < 0) start = 0;
|
|
|
|
|
|
if (end < 0) end = 0;
|
|
|
|
|
|
if (start > len) start = len;
|
|
|
|
|
|
if (end > len) end = len;
|
|
|
|
|
|
if (end < start) end = start;
|
|
|
|
|
|
var before = input.value.slice(0, start);
|
|
|
|
|
|
var after = input.value.slice(end);
|
|
|
|
|
|
input.value = before + token + after;
|
|
|
|
|
|
var caret = start + token.length;
|
|
|
|
|
|
try {
|
|
|
|
|
|
input.focus();
|
|
|
|
|
|
input.setSelectionRange(caret, caret);
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
/* ignore non-text inputs */
|
|
|
|
|
|
}
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
state.lastCipherInsertTarget = input;
|
|
|
|
|
|
state.lastCipherInsertRange = { start: caret, end: caret };
|
|
|
|
|
|
// 触发已绑定的 input 监听
|
|
|
|
|
|
try {
|
|
|
|
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
|
} catch (_e2) {
|
|
|
|
|
|
/* older engines */
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 顶栏「插入密文」options HTML(与密文簿 key 列表同步)。 */
|
|
|
|
|
|
function cipherInsertOptionsHtml() {
|
|
|
|
|
|
var keys = state.cipherKeys || [];
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<option value="">插入密文…</option>' +
|
|
|
|
|
|
keys
|
|
|
|
|
|
.map(function (k) {
|
|
|
|
|
|
var key = (k && k.key) || k;
|
|
|
|
|
|
if (!key) return '';
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<option value="' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
'">[' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
']</option>'
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('')
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function headerCipherInsertSelect() {
|
|
|
|
|
|
return qs('[data-vault-insert-cipher]', root);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refreshCipherInsertSelect() {
|
|
|
|
|
|
var sel = headerCipherInsertSelect();
|
|
|
|
|
|
if (!sel) return;
|
|
|
|
|
|
sel.innerHTML = cipherInsertOptionsHtml();
|
|
|
|
|
|
sel.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 仅在新建/编辑表单时显示顶栏「插入密文」;
|
|
|
|
|
|
* 放在密文簿旁,详情区滚动时仍可见。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function syncHeaderCipherInsertVisibility() {
|
|
|
|
|
|
var wrap = qs('.mnote-vault-insert-cipher', root);
|
|
|
|
|
|
if (!wrap) return;
|
|
|
|
|
|
var editing = state.mode === 'create' || state.mode === 'edit';
|
|
|
|
|
|
wrap.hidden = !editing;
|
|
|
|
|
|
wrap.setAttribute('aria-hidden', editing ? 'false' : 'true');
|
|
|
|
|
|
if (editing) refreshCipherInsertSelect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 表单内记录焦点/光标,供顶栏下拉插入使用。 */
|
|
|
|
|
|
function bindFormCipherInsertTracking(form) {
|
|
|
|
|
|
if (!form || form.getAttribute('data-cipher-insert-bound') === '1') return;
|
|
|
|
|
|
form.setAttribute('data-cipher-insert-bound', '1');
|
|
|
|
|
|
form.addEventListener('focusin', function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (t instanceof HTMLElement) rememberCipherInsertTarget(t);
|
|
|
|
|
|
});
|
|
|
|
|
|
form.addEventListener('focusout', function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (t instanceof HTMLElement) captureCipherInsertRange(t);
|
|
|
|
|
|
});
|
|
|
|
|
|
form.addEventListener('mouseup', function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (t instanceof HTMLElement) captureCipherInsertRange(t);
|
|
|
|
|
|
});
|
|
|
|
|
|
form.addEventListener('keyup', function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (t instanceof HTMLElement) captureCipherInsertRange(t);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 顶栏「插入密文」下拉:绑定一次,插入目标为当前编辑表单字段。 */
|
|
|
|
|
|
function bindHeaderCipherInsertControls() {
|
|
|
|
|
|
if (root.getAttribute('data-cipher-insert-header-bound') === '1') return;
|
|
|
|
|
|
root.setAttribute('data-cipher-insert-header-bound', '1');
|
|
|
|
|
|
var sel = headerCipherInsertSelect();
|
|
|
|
|
|
if (!sel) return;
|
|
|
|
|
|
sel.addEventListener('change', function () {
|
|
|
|
|
|
var key = String(sel.value || '').trim();
|
|
|
|
|
|
sel.value = '';
|
|
|
|
|
|
if (!key) return;
|
|
|
|
|
|
var form = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (!form || (state.mode !== 'create' && state.mode !== 'edit')) {
|
|
|
|
|
|
toast('请先新建或编辑条目', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var target = resolveCipherInsertTarget(form);
|
|
|
|
|
|
if (!target) {
|
|
|
|
|
|
toast('请先点击用户名 / 邮箱 / 密码 / 密钥值输入框', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (insertCipherKeyIntoField(target, key)) {
|
|
|
|
|
|
toast('已插入 [' + key + ']', 'success');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// 失焦前点下拉时仍能记住光标:mousedown 捕获阶段
|
|
|
|
|
|
sel.addEventListener('mousedown', function () {
|
|
|
|
|
|
var form = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (!form) return;
|
|
|
|
|
|
var active = document.activeElement;
|
|
|
|
|
|
if (active && form.contains(active) && active instanceof HTMLElement) {
|
|
|
|
|
|
captureCipherInsertRange(active);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
function renderCipherBook() {
|
|
|
|
|
|
if (!detailEl) return;
|
|
|
|
|
|
var rows = state.cipherKeys
|
|
|
|
|
|
.map(function (k) {
|
|
|
|
|
|
var key = k.key;
|
|
|
|
|
|
var revealed = state.cipherRevealed[key];
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<div class="mnote-vault-cipher-row" data-cipher-key="' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
'<code class="mnote-vault-cipher-key">[' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
']</code>' +
|
|
|
|
|
|
'<span class="mnote-vault-cipher-value">' +
|
|
|
|
|
|
(revealed
|
|
|
|
|
|
? escapeHtml(revealed)
|
|
|
|
|
|
: k.hasValue
|
|
|
|
|
|
? '••••••••'
|
|
|
|
|
|
: '(空)') +
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
'<span class="mnote-vault-cipher-actions">' +
|
|
|
|
|
|
'<button type="button" data-vault-cipher-reveal="' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
'">' +
|
|
|
|
|
|
(revealed ? '隐藏' : '显示') +
|
|
|
|
|
|
'</button>' +
|
|
|
|
|
|
'<button type="button" data-vault-cipher-edit="' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
'">改</button>' +
|
|
|
|
|
|
'<button type="button" class="is-danger" data-vault-cipher-delete="' +
|
|
|
|
|
|
escapeHtml(key) +
|
|
|
|
|
|
'">删</button>' +
|
|
|
|
|
|
'</span></div>'
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
detailEl.innerHTML =
|
|
|
|
|
|
'<div class="mnote-vault-cipher-panel" data-testid="vault-cipher-panel">' +
|
|
|
|
|
|
'<div class="mnote-vault-detail-header">' +
|
|
|
|
|
|
'<h2>密文簿</h2>' +
|
|
|
|
|
|
'<div class="mnote-vault-detail-actions">' +
|
|
|
|
|
|
'<button type="button" data-vault-cipher-close>关闭</button>' +
|
|
|
|
|
|
'</div></div>' +
|
|
|
|
|
|
|
|
|
|
|
|
'<form class="mnote-vault-cipher-add" data-vault-cipher-form="true">' +
|
2026-07-24 11:36:06 +08:00
|
|
|
|
'<input name="key" placeholder="Key(如 A)" maxlength="32" required pattern="[A-Za-z0-9_]+" autocomplete="off" spellcheck="false" />' +
|
|
|
|
|
|
'<input name="value" type="text" class="mnote-vault-secret-input-plain" placeholder="片段明文(输入可见)" required autocomplete="off" spellcheck="false" />' +
|
2026-07-21 05:13:05 +08:00
|
|
|
|
'<button type="submit">添加 / 更新</button>' +
|
|
|
|
|
|
'</form>' +
|
|
|
|
|
|
'<div class="mnote-vault-cipher-list" data-testid="vault-cipher-list">' +
|
|
|
|
|
|
(rows ||
|
|
|
|
|
|
'<div class="mnote-vault-empty">尚无片段。例如 key=<code>A</code> 值为 <code>Li@</code>,密码可写 <code>Li@[A]s3cret</code>。</div>') +
|
|
|
|
|
|
'</div></div>';
|
|
|
|
|
|
var form = qs('[data-vault-cipher-form]', detailEl);
|
|
|
|
|
|
if (form) {
|
|
|
|
|
|
form.addEventListener('submit', function (ev) {
|
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
var fd = new FormData(form);
|
|
|
|
|
|
var key = String(fd.get('key') || '').trim();
|
|
|
|
|
|
var value = String(fd.get('value') || '');
|
|
|
|
|
|
if (!key || !value) {
|
|
|
|
|
|
toast('需要 Key 与片段值', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'PUT',
|
|
|
|
|
|
'/api/vault/cipher-book/' + encodeURIComponent(key),
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{ value: value }
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
toast('已保存 [' + key + ']', 'success');
|
|
|
|
|
|
return loadCipherBook();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
root.addEventListener('click', function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (!(t instanceof Element)) return;
|
|
|
|
|
|
|
|
|
|
|
|
var toggle = t.closest('[data-vault-toggle-folder]');
|
|
|
|
|
|
if (toggle && root.contains(toggle)) {
|
|
|
|
|
|
var fpath = toggle.getAttribute('data-vault-toggle-folder') || '';
|
2026-07-24 11:36:06 +08:00
|
|
|
|
toggleCollapseKey(fpath);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
renderList();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var itemBtn = t.closest('[data-vault-item-id]');
|
|
|
|
|
|
if (itemBtn && root.contains(itemBtn)) {
|
|
|
|
|
|
var id = itemBtn.getAttribute('data-vault-item-id');
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.mode = 'view';
|
|
|
|
|
|
loadItem(id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-create], [data-vault-new]')) {
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.mode = 'create';
|
|
|
|
|
|
state.selectedId = '';
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
state._createDefaults = null;
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
state.secretDraft = { password: null, apikey: null, token: null };
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-toggle-empty-fields]')) {
|
|
|
|
|
|
state.showEmptyFields = !state.showEmptyFields;
|
|
|
|
|
|
if (state.item && state.mode === 'view') renderView(state.item);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 附录密钥折叠/展开(详情与编辑共用);写入 state,避免 reveal 重绘后丢失。
|
|
|
|
|
|
var secretsToggle = t.closest('[data-vault-toggle-secrets]');
|
|
|
|
|
|
if (secretsToggle && root.contains(secretsToggle)) {
|
|
|
|
|
|
var panel =
|
|
|
|
|
|
secretsToggle.closest('[data-vault-secrets-panel]') ||
|
|
|
|
|
|
secretsToggle.closest('[data-vault-account-secrets]');
|
|
|
|
|
|
if (panel) {
|
|
|
|
|
|
var bodyEl = panel.querySelector('.mnote-vault-account-secrets-body');
|
|
|
|
|
|
var collapsed = panel.classList.contains('is-collapsed');
|
|
|
|
|
|
var nextExpanded = !!collapsed;
|
|
|
|
|
|
var accKey =
|
|
|
|
|
|
panel.getAttribute('data-vault-secrets-acc') ||
|
|
|
|
|
|
panel.getAttribute('data-vault-account-secrets') ||
|
|
|
|
|
|
'default';
|
|
|
|
|
|
setSecretsPanelExpanded(accKey, nextExpanded);
|
|
|
|
|
|
if (nextExpanded) {
|
|
|
|
|
|
panel.classList.remove('is-collapsed');
|
|
|
|
|
|
if (bodyEl) bodyEl.hidden = false;
|
|
|
|
|
|
secretsToggle.setAttribute('aria-expanded', 'true');
|
|
|
|
|
|
var chevOpen = secretsToggle.querySelector('.mnote-vault-secrets-chevron');
|
|
|
|
|
|
if (chevOpen) chevOpen.textContent = '▼';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
panel.classList.add('is-collapsed');
|
|
|
|
|
|
if (bodyEl) bodyEl.hidden = true;
|
|
|
|
|
|
secretsToggle.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
var chevClose = secretsToggle.querySelector('.mnote-vault-secrets-chevron');
|
|
|
|
|
|
if (chevClose) chevClose.textContent = '▶';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-add-url]')) {
|
|
|
|
|
|
var formAddUrl = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formAddUrl) snapshotFormSlots(formAddUrl);
|
|
|
|
|
|
state._formUrls = state._formUrls || [''];
|
|
|
|
|
|
state._formUrls.push('');
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var removeUrl = t.closest('[data-vault-remove-url]');
|
|
|
|
|
|
if (removeUrl) {
|
|
|
|
|
|
var urlIdx = parseInt(removeUrl.getAttribute('data-vault-remove-url'), 10);
|
|
|
|
|
|
var formRmUrl = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formRmUrl) snapshotFormSlots(formRmUrl);
|
|
|
|
|
|
state._formUrls = (state._formUrls || []).filter(function (_u, i) {
|
|
|
|
|
|
return i !== urlIdx;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!state._formUrls.length) state._formUrls = [''];
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-add-account-slot]')) {
|
|
|
|
|
|
var formAddAcc = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formAddAcc) snapshotFormSlots(formAddAcc);
|
|
|
|
|
|
state._formAccounts = state._formAccounts || [];
|
|
|
|
|
|
state._formAccounts.push({
|
|
|
|
|
|
id: newClientId('acc'),
|
|
|
|
|
|
label: '',
|
2026-07-21 05:13:05 +08:00
|
|
|
|
username: '',
|
2026-07-24 11:36:06 +08:00
|
|
|
|
email: '',
|
|
|
|
|
|
passwordHint: '',
|
|
|
|
|
|
passwordState: { state: 'absent' },
|
|
|
|
|
|
passwordClear: false,
|
|
|
|
|
|
passwordValue: '',
|
|
|
|
|
|
secrets: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var removeAcc = t.closest('[data-vault-remove-account]');
|
|
|
|
|
|
if (removeAcc) {
|
|
|
|
|
|
var rid = removeAcc.getAttribute('data-vault-remove-account');
|
|
|
|
|
|
var formRmAcc = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formRmAcc) snapshotFormSlots(formRmAcc);
|
|
|
|
|
|
state._formAccounts = (state._formAccounts || []).filter(function (a) {
|
|
|
|
|
|
return a.id !== rid;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!state._formAccounts.length) {
|
|
|
|
|
|
state._formAccounts.push({
|
|
|
|
|
|
id: newClientId('acc'),
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
passwordHint: '',
|
|
|
|
|
|
passwordState: { state: 'absent' },
|
|
|
|
|
|
passwordClear: false,
|
|
|
|
|
|
passwordValue: '',
|
|
|
|
|
|
secrets: [],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var addSecBtn = t.closest('[data-vault-add-secret-slot]');
|
|
|
|
|
|
if (addSecBtn) {
|
|
|
|
|
|
var formAddSec = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formAddSec) snapshotFormSlots(formAddSec);
|
|
|
|
|
|
var accIdForSec =
|
|
|
|
|
|
addSecBtn.getAttribute('data-acc-id') ||
|
|
|
|
|
|
(addSecBtn.closest('[data-vault-account-slot]') &&
|
|
|
|
|
|
addSecBtn.closest('[data-vault-account-slot]').getAttribute('data-vault-account-slot'));
|
|
|
|
|
|
var targetAcc = (state._formAccounts || []).find(function (a) {
|
|
|
|
|
|
return a.id === accIdForSec;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!targetAcc && state._formAccounts && state._formAccounts.length) {
|
|
|
|
|
|
targetAcc = state._formAccounts[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (targetAcc) {
|
|
|
|
|
|
targetAcc.secrets = targetAcc.secrets || [];
|
|
|
|
|
|
targetAcc.secrets.push({
|
|
|
|
|
|
id: newClientId('sec'),
|
|
|
|
|
|
kind: 'apikey',
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
valueState: { state: 'absent' },
|
|
|
|
|
|
valueClear: false,
|
|
|
|
|
|
valueText: '',
|
|
|
|
|
|
accountId: targetAcc.id,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// 添加密钥后展开对应附录密钥面板(写入 state,re-render 后仍展开)。
|
|
|
|
|
|
if (targetAcc) {
|
|
|
|
|
|
setSecretsPanelExpanded(accountUiKey(targetAcc.id), true);
|
|
|
|
|
|
} else if (accIdForSec) {
|
|
|
|
|
|
setSecretsPanelExpanded(accountUiKey(accIdForSec), true);
|
|
|
|
|
|
}
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var removeSec = t.closest('[data-vault-remove-secret]');
|
|
|
|
|
|
if (removeSec) {
|
|
|
|
|
|
var rsid = removeSec.getAttribute('data-vault-remove-secret');
|
|
|
|
|
|
var rAccId = removeSec.getAttribute('data-acc-id');
|
|
|
|
|
|
var formRmSec = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formRmSec) snapshotFormSlots(formRmSec);
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
if (rAccId && acc.id !== rAccId) return;
|
|
|
|
|
|
acc.secrets = (acc.secrets || []).filter(function (s) {
|
|
|
|
|
|
return s.id !== rsid;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var showAccPwd = t.closest('[data-acc-show-password]');
|
|
|
|
|
|
if (showAccPwd) {
|
|
|
|
|
|
var said = showAccPwd.getAttribute('data-acc-show-password');
|
|
|
|
|
|
var pwdShowInput = detailEl.querySelector(
|
|
|
|
|
|
'[data-acc-field="password"][data-acc-id="' + said + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
// 已显示则收起:清空输入框(留空=保存时不改),不触发 passwordClear
|
|
|
|
|
|
if (showAccPwd.getAttribute('data-shown') === '1') {
|
|
|
|
|
|
if (pwdShowInput) {
|
|
|
|
|
|
pwdShowInput.value = '';
|
|
|
|
|
|
pwdShowInput.placeholder = '留空不改;输入新值覆盖';
|
|
|
|
|
|
}
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (a) {
|
|
|
|
|
|
if (a.id === said) {
|
|
|
|
|
|
a.passwordValue = '';
|
|
|
|
|
|
a.passwordClear = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
showAccPwd.setAttribute('data-shown', '0');
|
|
|
|
|
|
showAccPwd.textContent = '显示';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.selectedId) {
|
|
|
|
|
|
toast('请先保存条目后再查看已存密码', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
showAccPwd.disabled = true;
|
|
|
|
|
|
var revealPwdBody = { field: 'password' };
|
|
|
|
|
|
if (said) revealPwdBody.accountId = said;
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
revealPwdBody
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var secret = result && result.secret;
|
|
|
|
|
|
showAccPwd.disabled = false;
|
|
|
|
|
|
if (!secret || secret.state !== 'revealed') {
|
|
|
|
|
|
toast('该账号未设置密码', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 编辑优先写回 [Key] 模板,避免把展开明文误存为新值
|
|
|
|
|
|
var editVal =
|
|
|
|
|
|
secret.template != null && secret.template !== ''
|
|
|
|
|
|
? String(secret.template)
|
|
|
|
|
|
: String(secret.value || '');
|
|
|
|
|
|
if (pwdShowInput) {
|
|
|
|
|
|
pwdShowInput.value = editVal;
|
|
|
|
|
|
pwdShowInput.focus();
|
|
|
|
|
|
try {
|
|
|
|
|
|
var len = editVal.length;
|
|
|
|
|
|
pwdShowInput.setSelectionRange(len, len);
|
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (a) {
|
|
|
|
|
|
if (a.id === said) {
|
|
|
|
|
|
a.passwordValue = editVal;
|
|
|
|
|
|
a.passwordClear = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
showAccPwd.setAttribute('data-shown', '1');
|
|
|
|
|
|
showAccPwd.textContent = '隐藏';
|
|
|
|
|
|
if (secret.missingCipherKeys && secret.missingCipherKeys.length) {
|
|
|
|
|
|
toast(
|
|
|
|
|
|
'密文簿缺少 key: ' + secret.missingCipherKeys.join(', '),
|
|
|
|
|
|
'info'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
showAccPwd.disabled = false;
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var clearAccPwd = t.closest('[data-acc-clear-password]');
|
|
|
|
|
|
if (clearAccPwd) {
|
|
|
|
|
|
var caid = clearAccPwd.getAttribute('data-acc-clear-password');
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (a) {
|
|
|
|
|
|
if (a.id === caid) {
|
|
|
|
|
|
a.passwordClear = true;
|
|
|
|
|
|
a.passwordValue = '';
|
|
|
|
|
|
a.passwordState = { state: 'absent' };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
var pwdInput = detailEl.querySelector(
|
|
|
|
|
|
'[data-acc-field="password"][data-acc-id="' + caid + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (pwdInput) {
|
|
|
|
|
|
pwdInput.value = '';
|
|
|
|
|
|
pwdInput.placeholder = '(将清空)';
|
|
|
|
|
|
}
|
|
|
|
|
|
var showBtnAfterClear = detailEl.querySelector(
|
|
|
|
|
|
'[data-acc-show-password="' + caid + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (showBtnAfterClear) {
|
|
|
|
|
|
showBtnAfterClear.setAttribute('data-shown', '0');
|
|
|
|
|
|
showBtnAfterClear.textContent = '显示';
|
|
|
|
|
|
}
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
toast('该账号密码将在保存时清空', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var showSecVal = t.closest('[data-sec-show-value]');
|
|
|
|
|
|
if (showSecVal) {
|
|
|
|
|
|
var ssid = showSecVal.getAttribute('data-sec-show-value');
|
|
|
|
|
|
var secShowInput = detailEl.querySelector(
|
|
|
|
|
|
'[data-sec-field="value"][data-sec-id="' + ssid + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (showSecVal.getAttribute('data-shown') === '1') {
|
|
|
|
|
|
if (secShowInput) {
|
|
|
|
|
|
secShowInput.value = '';
|
|
|
|
|
|
secShowInput.placeholder = '留空不改;输入新值覆盖';
|
|
|
|
|
|
}
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (s) {
|
|
|
|
|
|
if (s.id === ssid) {
|
|
|
|
|
|
s.valueText = '';
|
|
|
|
|
|
s.valueClear = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
showSecVal.setAttribute('data-shown', '0');
|
|
|
|
|
|
showSecVal.textContent = '显示';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.selectedId) {
|
|
|
|
|
|
toast('请先保存条目后再查看已存密钥', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
showSecVal.disabled = true;
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{ secretId: ssid }
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var secret = result && result.secret;
|
|
|
|
|
|
showSecVal.disabled = false;
|
|
|
|
|
|
if (!secret || secret.state !== 'revealed') {
|
|
|
|
|
|
toast('该密钥未设置', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var editVal =
|
|
|
|
|
|
secret.template != null && secret.template !== ''
|
|
|
|
|
|
? String(secret.template)
|
|
|
|
|
|
: String(secret.value || '');
|
|
|
|
|
|
if (secShowInput) {
|
|
|
|
|
|
secShowInput.value = editVal;
|
|
|
|
|
|
secShowInput.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (s) {
|
|
|
|
|
|
if (s.id === ssid) {
|
|
|
|
|
|
s.valueText = editVal;
|
|
|
|
|
|
s.valueClear = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
showSecVal.setAttribute('data-shown', '1');
|
|
|
|
|
|
showSecVal.textContent = '隐藏';
|
|
|
|
|
|
if (secret.missingCipherKeys && secret.missingCipherKeys.length) {
|
|
|
|
|
|
toast(
|
|
|
|
|
|
'密文簿缺少 key: ' + secret.missingCipherKeys.join(', '),
|
|
|
|
|
|
'info'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
showSecVal.disabled = false;
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var clearSecVal = t.closest('[data-sec-clear-value]');
|
|
|
|
|
|
if (clearSecVal) {
|
|
|
|
|
|
var csid = clearSecVal.getAttribute('data-sec-clear-value');
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
(acc.secrets || []).forEach(function (s) {
|
|
|
|
|
|
if (s.id === csid) {
|
|
|
|
|
|
s.valueClear = true;
|
|
|
|
|
|
s.valueText = '';
|
|
|
|
|
|
s.valueState = { state: 'absent' };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
var secInput = detailEl.querySelector(
|
|
|
|
|
|
'[data-sec-field="value"][data-sec-id="' + csid + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (secInput) {
|
|
|
|
|
|
secInput.value = '';
|
|
|
|
|
|
secInput.placeholder = '(将清空)';
|
|
|
|
|
|
}
|
|
|
|
|
|
var showSecAfterClear = detailEl.querySelector(
|
|
|
|
|
|
'[data-sec-show-value="' + csid + '"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (showSecAfterClear) {
|
|
|
|
|
|
showSecAfterClear.setAttribute('data-shown', '0');
|
|
|
|
|
|
showSecAfterClear.textContent = '显示';
|
|
|
|
|
|
}
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
toast('该密钥将在保存时清空', 'info');
|
2026-07-21 05:13:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-share-ai]')) {
|
|
|
|
|
|
if (!state.selectedId || !state.rootUri) return;
|
|
|
|
|
|
if (state.isAiVault) {
|
|
|
|
|
|
toast('当前已是 AI 密码本,无需再共享', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var shareTitle =
|
|
|
|
|
|
(state.item && state.item.title) || state.selectedId;
|
|
|
|
|
|
var alreadyShared = itemIsSharedToAi(state.item);
|
|
|
|
|
|
if (
|
|
|
|
|
|
!window.confirm(
|
|
|
|
|
|
alreadyShared
|
|
|
|
|
|
? '将「' +
|
|
|
|
|
|
shareTitle +
|
|
|
|
|
|
'」重新同步到已关联的 AI 密码本副本?\n' +
|
|
|
|
|
|
'会更新目标条目内容与缺失密文 key(目标已有 key 不覆盖)。'
|
|
|
|
|
|
: '将「' +
|
|
|
|
|
|
shareTitle +
|
|
|
|
|
|
'」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' +
|
|
|
|
|
|
'会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' +
|
|
|
|
|
|
'密文 [Key] 模板原样复制,并自动同步缺失密文 key。'
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/share-to-ai',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{ syncCipherKeys: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var target = (result && result.targetActorId) || 'mnote-e2e';
|
|
|
|
|
|
var newId = result && result.item && result.item.id;
|
|
|
|
|
|
var cipher = (result && result.cipherSync) || {};
|
|
|
|
|
|
var copied = cipher.copiedKeys || cipher.copied || [];
|
|
|
|
|
|
var missing = cipher.missingInSource || [];
|
|
|
|
|
|
var parts = [
|
|
|
|
|
|
result && result.updatedExisting
|
|
|
|
|
|
? '已同步 AI 副本(' + target + ')'
|
|
|
|
|
|
: '已共享到 AI 密码本(' + target + ')',
|
|
|
|
|
|
];
|
|
|
|
|
|
if (newId) parts.push(newId);
|
|
|
|
|
|
if (copied.length) parts.push('同步密文 key: ' + copied.join(', '));
|
|
|
|
|
|
if (missing.length) {
|
|
|
|
|
|
parts.push('源缺 key: ' + missing.join(', '));
|
|
|
|
|
|
}
|
|
|
|
|
|
toast(parts.join(' · '), missing.length ? 'info' : 'success');
|
|
|
|
|
|
setStatus(parts.join(' · '), missing.length ? 'info' : 'success');
|
|
|
|
|
|
// Refresh source item so shared banner / button label update
|
|
|
|
|
|
return loadItem(state.selectedId).then(function () {
|
|
|
|
|
|
return loadList();
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-unshare-ai]')) {
|
|
|
|
|
|
if (!state.selectedId || !state.rootUri) return;
|
|
|
|
|
|
if (state.isAiVault) {
|
|
|
|
|
|
toast('请在用户密码箱中撤销共享', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!itemIsSharedToAi(state.item)) {
|
|
|
|
|
|
toast('当前条目未共享到 AI', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var unshareTitle =
|
|
|
|
|
|
(state.item && state.item.title) || state.selectedId;
|
|
|
|
|
|
var linkActor =
|
|
|
|
|
|
(state.item &&
|
|
|
|
|
|
state.item.sharedToAi &&
|
|
|
|
|
|
state.item.sharedToAi.targetActorId) ||
|
|
|
|
|
|
'mnote-e2e';
|
|
|
|
|
|
if (
|
|
|
|
|
|
!window.confirm(
|
|
|
|
|
|
'撤销「' +
|
|
|
|
|
|
unshareTitle +
|
|
|
|
|
|
'」对 AI 密码本(' +
|
|
|
|
|
|
linkActor +
|
|
|
|
|
|
')的共享?\n' +
|
|
|
|
|
|
'将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' +
|
|
|
|
|
|
'原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。'
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' +
|
|
|
|
|
|
encodeURIComponent(state.selectedId) +
|
|
|
|
|
|
'/unshare-from-ai',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{}
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var target = (result && result.targetActorId) || linkActor;
|
|
|
|
|
|
var parts = [];
|
|
|
|
|
|
if (result && result.wasShared === false) {
|
|
|
|
|
|
parts.push('本来就未共享');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
parts.push('已撤销共享(' + target + ')');
|
|
|
|
|
|
if (result && result.targetDeleted) {
|
|
|
|
|
|
parts.push('AI 副本已移入回收站');
|
|
|
|
|
|
} else if (result && result.removedTargetId) {
|
|
|
|
|
|
parts.push('AI 副本已不存在或已在回收站');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
toast(parts.join(' · '), 'success');
|
|
|
|
|
|
setStatus(parts.join(' · '), 'success');
|
|
|
|
|
|
return loadItem(state.selectedId).then(function () {
|
|
|
|
|
|
return loadList();
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-cipher-book]')) {
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.mode = 'cipher';
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
loadCipherBook();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (t.closest('[data-vault-cipher-close]')) {
|
|
|
|
|
|
state.mode = state.selectedId ? 'view' : 'view';
|
|
|
|
|
|
if (state.selectedId) loadItem(state.selectedId);
|
|
|
|
|
|
else {
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var cipherReveal = t.closest('[data-vault-cipher-reveal]');
|
|
|
|
|
|
if (cipherReveal) {
|
|
|
|
|
|
var ck = cipherReveal.getAttribute('data-vault-cipher-reveal');
|
|
|
|
|
|
if (state.cipherRevealed[ck]) {
|
|
|
|
|
|
delete state.cipherRevealed[ck];
|
|
|
|
|
|
renderCipherBook();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/cipher-book/' + encodeURIComponent(ck) + '/reveal',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{}
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var secret = result && result.secret;
|
|
|
|
|
|
if (secret && secret.state === 'revealed') {
|
|
|
|
|
|
state.cipherRevealed[ck] = secret.value;
|
|
|
|
|
|
renderCipherBook();
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var cipherEdit = t.closest('[data-vault-cipher-edit]');
|
|
|
|
|
|
if (cipherEdit) {
|
|
|
|
|
|
var ek = cipherEdit.getAttribute('data-vault-cipher-edit');
|
|
|
|
|
|
var nv = window.prompt('更新 [' + ek + '] 的片段值(输入明文)', '');
|
|
|
|
|
|
if (nv == null || nv === '') return;
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'PUT',
|
|
|
|
|
|
'/api/vault/cipher-book/' + encodeURIComponent(ek),
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{ value: nv }
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
toast('已更新 [' + ek + ']', 'success');
|
|
|
|
|
|
delete state.cipherRevealed[ek];
|
|
|
|
|
|
return loadCipherBook();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var cipherDel = t.closest('[data-vault-cipher-delete]');
|
|
|
|
|
|
if (cipherDel) {
|
|
|
|
|
|
var dk = cipherDel.getAttribute('data-vault-cipher-delete');
|
|
|
|
|
|
if (!window.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。')) return;
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'DELETE',
|
|
|
|
|
|
'/api/vault/cipher-book/' + encodeURIComponent(dk),
|
|
|
|
|
|
state.rootUri
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
toast('已删除 [' + dk + ']', 'success');
|
|
|
|
|
|
delete state.cipherRevealed[dk];
|
|
|
|
|
|
return loadCipherBook();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-refresh]')) {
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.mode = 'view';
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-tab="active"]')) {
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.status = 'active';
|
|
|
|
|
|
state.mode = 'view';
|
|
|
|
|
|
state.selectedId = '';
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
if (tabActive) tabActive.classList.add('is-active');
|
|
|
|
|
|
if (tabDeleted) tabDeleted.classList.remove('is-active');
|
|
|
|
|
|
root.setAttribute('data-status', 'active');
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (t.closest('[data-vault-tab="deleted"]')) {
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.status = 'deleted';
|
|
|
|
|
|
state.mode = 'view';
|
|
|
|
|
|
state.selectedId = '';
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
if (tabDeleted) tabDeleted.classList.add('is-active');
|
|
|
|
|
|
if (tabActive) tabActive.classList.remove('is-active');
|
|
|
|
|
|
root.setAttribute('data-status', 'deleted');
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-edit]')) {
|
|
|
|
|
|
state.mode = 'edit';
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
state.secretDraft = { password: null, apikey: null, token: null };
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (t.closest('[data-vault-cancel]')) {
|
|
|
|
|
|
if (!confirmDiscard()) return;
|
|
|
|
|
|
state.mode = state.selectedId ? 'view' : 'view';
|
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
|
if (state.selectedId) loadItem(state.selectedId);
|
|
|
|
|
|
else {
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var clearSecVal = t.closest('[data-vault-clear-sec-value]');
|
|
|
|
|
|
if (clearSecVal) {
|
|
|
|
|
|
var csid = clearSecVal.getAttribute('data-vault-clear-sec-value');
|
|
|
|
|
|
var csAcc = clearSecVal.getAttribute('data-acc-id');
|
|
|
|
|
|
var formCs = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formCs) snapshotFormSlots(formCs);
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
if (csAcc && acc.id !== csAcc) return;
|
|
|
|
|
|
(acc.secrets || []).forEach(function (sec) {
|
|
|
|
|
|
if (sec.id !== csid) return;
|
|
|
|
|
|
sec.valueClear = true;
|
|
|
|
|
|
sec.valueState = { state: 'absent' };
|
|
|
|
|
|
sec.valueText = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
toast('密钥将在保存时清空', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var clearNestedSec = t.closest('[data-vault-clear-sec-value]');
|
|
|
|
|
|
if (clearNestedSec) {
|
|
|
|
|
|
var csid = clearNestedSec.getAttribute('data-vault-clear-sec-value');
|
|
|
|
|
|
var cAcc = clearNestedSec.getAttribute('data-acc-id');
|
|
|
|
|
|
var formClearSec = qs('[data-vault-form]', detailEl);
|
|
|
|
|
|
if (formClearSec) snapshotFormSlots(formClearSec);
|
|
|
|
|
|
(state._formAccounts || []).forEach(function (acc) {
|
|
|
|
|
|
if (cAcc && acc.id !== cAcc) return;
|
|
|
|
|
|
(acc.secrets || []).forEach(function (sec) {
|
|
|
|
|
|
if (sec.id !== csid) return;
|
|
|
|
|
|
sec.valueClear = true;
|
|
|
|
|
|
sec.valueText = '';
|
|
|
|
|
|
sec.valueState = { state: 'absent' };
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
reRenderFormKeepingSlots(state.mode === 'create');
|
|
|
|
|
|
toast('密钥将在保存时清空', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var clearBtn = t.closest('[data-vault-clear-secret]');
|
|
|
|
|
|
if (clearBtn) {
|
|
|
|
|
|
var cf = clearBtn.getAttribute('data-vault-clear-secret');
|
|
|
|
|
|
state.secretDraft[cf] = 'clear';
|
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
|
var input = detailEl.querySelector('[data-secret-field="' + cf + '"]');
|
|
|
|
|
|
if (input) {
|
|
|
|
|
|
input.value = '';
|
|
|
|
|
|
input.placeholder = '(将清空)';
|
|
|
|
|
|
}
|
|
|
|
|
|
toast(cf + ' 将在保存时清空', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t.closest('[data-vault-delete]')) {
|
|
|
|
|
|
if (!window.confirm('确定将条目移入密码箱回收站?')) return;
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'DELETE',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId),
|
|
|
|
|
|
state.rootUri
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
toast('已移入回收站', 'success');
|
|
|
|
|
|
state.selectedId = '';
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
return loadList();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (t.closest('[data-vault-restore]')) {
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/restore',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{}
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
toast('已恢复', 'success');
|
|
|
|
|
|
state.status = 'active';
|
|
|
|
|
|
if (tabActive) tabActive.classList.add('is-active');
|
|
|
|
|
|
if (tabDeleted) tabDeleted.classList.remove('is-active');
|
|
|
|
|
|
return loadList();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (t.closest('[data-vault-purge]')) {
|
|
|
|
|
|
if (!window.confirm('永久删除后无法恢复,确定?')) return;
|
|
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge',
|
|
|
|
|
|
state.rootUri,
|
|
|
|
|
|
{}
|
|
|
|
|
|
)
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
toast('已永久删除', 'success');
|
|
|
|
|
|
state.selectedId = '';
|
|
|
|
|
|
state.item = null;
|
|
|
|
|
|
return loadList();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var revealBtn = t.closest('[data-vault-reveal]');
|
|
|
|
|
|
if (revealBtn) {
|
|
|
|
|
|
var field = revealBtn.getAttribute('data-vault-reveal');
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var accountId = revealBtn.getAttribute('data-vault-account-id') || '';
|
|
|
|
|
|
var secretId = revealBtn.getAttribute('data-vault-secret-id') || '';
|
|
|
|
|
|
var rkey = revealKey(field, accountId, secretId);
|
|
|
|
|
|
if (state.revealed[rkey]) {
|
|
|
|
|
|
state.revealed[rkey] = false;
|
2026-07-21 05:13:05 +08:00
|
|
|
|
renderDetail();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var revealBody = { field: field };
|
|
|
|
|
|
if (accountId) revealBody.accountId = accountId;
|
|
|
|
|
|
if (secretId) {
|
|
|
|
|
|
revealBody.secretId = secretId;
|
|
|
|
|
|
if (!field || field === 'value') {
|
|
|
|
|
|
// appendix secret: field optional on server when secretId present
|
|
|
|
|
|
delete revealBody.field;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-21 05:13:05 +08:00
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
|
|
|
|
|
state.rootUri,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
revealBody
|
2026-07-21 05:13:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var secret = result && result.secret;
|
|
|
|
|
|
state._revealedValues = state._revealedValues || {};
|
|
|
|
|
|
if (secret && secret.state === 'revealed') {
|
2026-07-24 11:36:06 +08:00
|
|
|
|
state._revealedValues[rkey] = secret.value || '';
|
|
|
|
|
|
state.revealed[rkey] = true;
|
2026-07-21 05:13:05 +08:00
|
|
|
|
if (secret.missingCipherKeys && secret.missingCipherKeys.length) {
|
|
|
|
|
|
toast(
|
|
|
|
|
|
'密文簿缺少 key: ' + secret.missingCipherKeys.join(', '),
|
|
|
|
|
|
'info'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast('该字段未设置', 'info');
|
|
|
|
|
|
}
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var copyBtn = t.closest('[data-vault-copy-secret]');
|
|
|
|
|
|
if (copyBtn) {
|
|
|
|
|
|
var cfield = copyBtn.getAttribute('data-vault-copy-secret');
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var cAccountId = copyBtn.getAttribute('data-vault-account-id') || '';
|
|
|
|
|
|
var cSecretId = copyBtn.getAttribute('data-vault-secret-id') || '';
|
|
|
|
|
|
var ckey = revealKey(cfield, cAccountId, cSecretId);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
var doCopy = function (text) {
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
toast('无可复制内容', 'info');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
|
|
|
navigator.clipboard.writeText(text).then(
|
|
|
|
|
|
function () {
|
|
|
|
|
|
toast('已复制', 'success');
|
|
|
|
|
|
},
|
|
|
|
|
|
function () {
|
|
|
|
|
|
toast('复制失败', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast('剪贴板不可用', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-24 11:36:06 +08:00
|
|
|
|
if (state.revealed[ckey] && state._revealedValues && state._revealedValues[ckey]) {
|
|
|
|
|
|
doCopy(state._revealedValues[ckey]);
|
2026-07-21 05:13:05 +08:00
|
|
|
|
} else {
|
2026-07-24 11:36:06 +08:00
|
|
|
|
var copyBody = { field: cfield };
|
|
|
|
|
|
if (cAccountId) copyBody.accountId = cAccountId;
|
|
|
|
|
|
if (cSecretId) {
|
|
|
|
|
|
copyBody.secretId = cSecretId;
|
|
|
|
|
|
if (!cfield || cfield === 'value') delete copyBody.field;
|
|
|
|
|
|
}
|
2026-07-21 05:13:05 +08:00
|
|
|
|
apiJson(
|
|
|
|
|
|
'POST',
|
|
|
|
|
|
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
|
|
|
|
|
state.rootUri,
|
2026-07-24 11:36:06 +08:00
|
|
|
|
copyBody
|
2026-07-21 05:13:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
.then(function (result) {
|
|
|
|
|
|
var secret = result && result.secret;
|
|
|
|
|
|
if (secret && secret.state === 'revealed') {
|
|
|
|
|
|
doCopy(secret.value || '');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast('该字段未设置', 'info');
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
toast(err.message || String(err), 'error');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var copyPlain = t.closest('[data-vault-copy]');
|
|
|
|
|
|
if (copyPlain) {
|
|
|
|
|
|
var text = copyPlain.getAttribute('data-vault-copy') || '';
|
|
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
|
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
|
|
|
|
toast('已复制', 'success');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (searchEl) {
|
|
|
|
|
|
searchEl.addEventListener('input', function () {
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 11:36:06 +08:00
|
|
|
|
// 顶栏「插入密文」:固定在密文簿旁,仅编辑态显示
|
|
|
|
|
|
bindHeaderCipherInsertControls();
|
|
|
|
|
|
syncHeaderCipherInsertVisibility();
|
|
|
|
|
|
|
2026-07-21 05:13:05 +08:00
|
|
|
|
// deep-link id
|
|
|
|
|
|
try {
|
|
|
|
|
|
var params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
var deepId = params.get('id');
|
|
|
|
|
|
if (deepId) state.selectedId = deepId;
|
|
|
|
|
|
var st = params.get('status');
|
|
|
|
|
|
if (st === 'deleted') {
|
|
|
|
|
|
state.status = 'deleted';
|
|
|
|
|
|
if (tabDeleted) tabDeleted.classList.add('is-active');
|
|
|
|
|
|
if (tabActive) tabActive.classList.remove('is-active');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {}
|
|
|
|
|
|
|
|
|
|
|
|
// ensure then list
|
|
|
|
|
|
if (state.rootUri) {
|
|
|
|
|
|
apiJson('POST', '/api/vault/ensure', state.rootUri, {})
|
|
|
|
|
|
.then(function () {
|
|
|
|
|
|
return loadList();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(function (err) {
|
|
|
|
|
|
setStatus(err.message || String(err), 'error');
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
renderList();
|
|
|
|
|
|
renderDetail();
|
|
|
|
|
|
setStatus('请先打开本地文件夹', 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Keep left-rail /vault links in the current local_folder context. */
|
|
|
|
|
|
function enrichVaultNavLinks() {
|
|
|
|
|
|
var rootUri =
|
|
|
|
|
|
(document.body && document.body.getAttribute('data-mnote-root-uri')) ||
|
|
|
|
|
|
new URLSearchParams(window.location.search).get('rootUri') ||
|
|
|
|
|
|
'';
|
|
|
|
|
|
rootUri = String(rootUri || '').trim();
|
|
|
|
|
|
var sourceKind =
|
|
|
|
|
|
(document.body && document.body.getAttribute('data-mnote-source-kind')) ||
|
|
|
|
|
|
new URLSearchParams(window.location.search).get('sourceKind') ||
|
|
|
|
|
|
(rootUri ? 'local_folder' : '');
|
|
|
|
|
|
sourceKind = String(sourceKind || '').trim();
|
|
|
|
|
|
if (!rootUri && !sourceKind) return;
|
|
|
|
|
|
qsa('a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]').forEach(
|
|
|
|
|
|
function (link) {
|
|
|
|
|
|
if (!(link instanceof HTMLAnchorElement)) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
var url = new URL(link.getAttribute('href') || '/vault', window.location.origin);
|
|
|
|
|
|
if (url.pathname !== '/vault') return;
|
|
|
|
|
|
if (rootUri && !url.searchParams.get('rootUri')) {
|
|
|
|
|
|
url.searchParams.set('rootUri', rootUri);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sourceKind && !url.searchParams.get('sourceKind')) {
|
|
|
|
|
|
url.searchParams.set('sourceKind', sourceKind);
|
|
|
|
|
|
}
|
|
|
|
|
|
link.setAttribute('href', url.pathname + url.search + url.hash);
|
|
|
|
|
|
} catch (_e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function boot() {
|
|
|
|
|
|
enrichVaultNavLinks();
|
|
|
|
|
|
qsa('[data-testid="mnote-vault-workbench"]').forEach(bindWorkbench);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
boot();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Capture-phase enrich only. Soft-open is owned by sidebar-tree-runtime
|
|
|
|
|
|
// (keeps PageTree/FileTree DOM). Full assign only when soft-open is unavailable.
|
|
|
|
|
|
if (!window.__mnoteVaultNavCapture) {
|
|
|
|
|
|
window.__mnoteVaultNavCapture = true;
|
|
|
|
|
|
document.addEventListener(
|
|
|
|
|
|
'click',
|
|
|
|
|
|
function (ev) {
|
|
|
|
|
|
var t = ev.target;
|
|
|
|
|
|
if (!(t instanceof Element)) return;
|
|
|
|
|
|
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
|
|
|
|
|
var link = t.closest(
|
|
|
|
|
|
'a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]'
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!(link instanceof HTMLAnchorElement)) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
var url = new URL(link.getAttribute('href') || '/vault', window.location.origin);
|
|
|
|
|
|
if (url.pathname !== '/vault') return;
|
|
|
|
|
|
var rootUri =
|
|
|
|
|
|
(document.body && document.body.getAttribute('data-mnote-root-uri')) ||
|
|
|
|
|
|
new URLSearchParams(window.location.search).get('rootUri') ||
|
|
|
|
|
|
'';
|
|
|
|
|
|
rootUri = String(rootUri || '').trim();
|
|
|
|
|
|
if (rootUri && !url.searchParams.get('rootUri')) {
|
|
|
|
|
|
url.searchParams.set('rootUri', rootUri);
|
|
|
|
|
|
if (!url.searchParams.get('sourceKind')) {
|
|
|
|
|
|
url.searchParams.set('sourceKind', 'local_folder');
|
|
|
|
|
|
}
|
|
|
|
|
|
link.setAttribute('href', url.pathname + url.search + url.hash);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Prefer soft-open so sidebar tree expansion is preserved.
|
|
|
|
|
|
if (typeof window.__mnoteOpenVaultWorkbench === 'function') {
|
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
|
void window.__mnoteOpenVaultWorkbench(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_e) {}
|
|
|
|
|
|
},
|
|
|
|
|
|
true
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.mnote = window.mnote || {};
|
|
|
|
|
|
window.mnote.vaultWorkbench = { boot: boot, enrichVaultNavLinks: enrichVaultNavLinks };
|
|
|
|
|
|
})();
|