- API:桌面登录 session、Google 托管回调与轮询 - Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码 - 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL
1021 lines
34 KiB
JavaScript
1021 lines
34 KiB
JavaScript
const invoke = window.__TAURI_INTERNALS__?.invoke;
|
||
|
||
const loginView = document.getElementById("login-view");
|
||
const appShell = document.getElementById("app-shell");
|
||
const loginButton = document.getElementById("login-button");
|
||
const loginError = document.getElementById("login-error");
|
||
const vaultModal = document.getElementById("vault-modal");
|
||
const vaultModalTitle = document.getElementById("vault-modal-title");
|
||
const vaultModalCopy = document.getElementById("vault-modal-copy");
|
||
const vaultPasswordInput = document.getElementById("vault-password-input");
|
||
const vaultModalError = document.getElementById("vault-modal-error");
|
||
const vaultModalSave = document.getElementById("vault-modal-save");
|
||
const logoutButton = document.getElementById("logout-button");
|
||
const userTrigger = document.getElementById("user-trigger");
|
||
const userMenu = document.getElementById("user-menu");
|
||
const manageDevicesButton = document.getElementById("manage-devices");
|
||
const openMcpModalButton = document.getElementById("open-mcp-modal");
|
||
const deviceModal = document.getElementById("device-modal");
|
||
const deviceList = document.getElementById("device-list");
|
||
const closeDeviceModal = document.getElementById("close-device-modal");
|
||
const mcpModal = document.getElementById("mcp-modal");
|
||
const closeMcpModal = document.getElementById("close-mcp-modal");
|
||
const mcpIntegrationList = document.getElementById("mcp-integration-list");
|
||
const mcpConfig = document.getElementById("mcp-config");
|
||
const copyMcpConfigButton = document.getElementById("copy-mcp-config");
|
||
const copyMcpConfigButtonLabel = copyMcpConfigButton.querySelector(".button-label");
|
||
|
||
const userName = document.getElementById("user-name");
|
||
const userEmail = document.getElementById("user-email");
|
||
const folderList = document.getElementById("folder-list");
|
||
const entryList = document.getElementById("entry-list");
|
||
const detailFolderLabel = document.getElementById("detail-folder-label");
|
||
const entryTitle = document.getElementById("entry-title");
|
||
const metadataList = document.getElementById("metadata-list");
|
||
const secretList = document.getElementById("secret-list");
|
||
const searchInput = document.getElementById("search-input");
|
||
const typeFilter = document.getElementById("type-filter");
|
||
const detailBadge = document.getElementById("detail-badge");
|
||
const editEntryButton = document.getElementById("edit-entry-button");
|
||
const deleteEntryButton = document.getElementById("delete-entry-button");
|
||
const restoreEntryButton = document.getElementById("restore-entry-button");
|
||
const saveEntryButton = document.getElementById("save-entry-button");
|
||
const cancelEditButton = document.getElementById("cancel-edit-button");
|
||
const addSecretButton = document.getElementById("add-secret-button");
|
||
const newEntryButton = document.getElementById("new-entry-button");
|
||
const nameSection = document.getElementById("name-section");
|
||
const nameView = document.getElementById("name-view");
|
||
const nameInput = document.getElementById("name-input");
|
||
const metadataEditor = document.getElementById("metadata-editor");
|
||
const addMetadataButton = document.getElementById("add-metadata-button");
|
||
|
||
const entryModal = document.getElementById("entry-modal");
|
||
const closeEntryModal = document.getElementById("close-entry-modal");
|
||
const entryModalCancel = document.getElementById("entry-modal-cancel");
|
||
const entryModalSave = document.getElementById("entry-modal-save");
|
||
const entryModalFolder = document.getElementById("entry-modal-folder");
|
||
const entryModalTitle = document.getElementById("entry-modal-title");
|
||
const entryModalType = document.getElementById("entry-modal-type");
|
||
|
||
const secretModal = document.getElementById("secret-modal");
|
||
const closeSecretModal = document.getElementById("close-secret-modal");
|
||
const secretModalCancel = document.getElementById("secret-modal-cancel");
|
||
const secretModalSave = document.getElementById("secret-modal-save");
|
||
const secretModalTitle = document.getElementById("secret-modal-title");
|
||
const secretNameInput = document.getElementById("secret-name-input");
|
||
const secretTypeInput = document.getElementById("secret-type-input");
|
||
const secretValueInput = document.getElementById("secret-value-input");
|
||
|
||
const historyModal = document.getElementById("history-modal");
|
||
const closeHistoryModal = document.getElementById("close-history-modal");
|
||
const historyModalCopy = document.getElementById("history-modal-copy");
|
||
const historyList = document.getElementById("history-list");
|
||
|
||
const appState = {
|
||
shell: null,
|
||
activeFolder: "所有项目",
|
||
activeType: "",
|
||
query: "",
|
||
mcpJson: "",
|
||
editing: false,
|
||
draftMetadata: [],
|
||
revealedSecrets: {},
|
||
secretModalMode: "create",
|
||
editingSecretId: null,
|
||
historySecretId: null,
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function displaySecretName(name) {
|
||
const normalized = String(name ?? "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replaceAll("_", " ");
|
||
if (normalized === "api token" || normalized === "token") return "访问令牌";
|
||
return String(name ?? "");
|
||
}
|
||
|
||
function integrationGlyph(appName) {
|
||
if (appName === "Cursor") return "⌘";
|
||
if (appName === "Claude Code") return ">_";
|
||
return "⌁";
|
||
}
|
||
|
||
function selectedEntry() {
|
||
return appState.shell?.selected_entry || null;
|
||
}
|
||
|
||
function resetSecretCache() {
|
||
appState.revealedSecrets = {};
|
||
}
|
||
|
||
function setSelectedDetail(detail) {
|
||
if (!appState.shell) return;
|
||
appState.shell.selected_entry_id = detail?.id || null;
|
||
appState.shell.selected_entry = detail;
|
||
appState.editing = false;
|
||
appState.draftMetadata = detail?.metadata ? detail.metadata.map((item) => ({ ...item })) : [];
|
||
resetSecretCache();
|
||
}
|
||
|
||
function showLogin() {
|
||
loginView.classList.remove("hidden");
|
||
appShell.classList.add("hidden");
|
||
}
|
||
|
||
function showShell() {
|
||
loginView.classList.add("hidden");
|
||
appShell.classList.remove("hidden");
|
||
}
|
||
|
||
function setLoginError(message) {
|
||
loginError.textContent = message || "";
|
||
loginError.classList.toggle("hidden", !message);
|
||
}
|
||
|
||
function setVaultModalError(message) {
|
||
vaultModalError.textContent = message || "";
|
||
vaultModalError.classList.toggle("hidden", !message);
|
||
}
|
||
|
||
function openVaultModal({ setup = false } = {}) {
|
||
vaultModalTitle.textContent = setup ? "设置本地 Vault 主密码" : "解锁本地 Vault";
|
||
vaultModalCopy.textContent = setup
|
||
? "首次使用,请先设置本地 vault 主密码。"
|
||
: "请输入本地 vault 主密码以解锁。";
|
||
vaultPasswordInput.value = "";
|
||
setVaultModalError("");
|
||
vaultModal.classList.remove("hidden");
|
||
setTimeout(() => vaultPasswordInput.focus(), 0);
|
||
}
|
||
|
||
function closeVaultModal() {
|
||
vaultModal.classList.add("hidden");
|
||
vaultPasswordInput.value = "";
|
||
setVaultModalError("");
|
||
}
|
||
|
||
function setAvatar() {
|
||
const avatar = document.querySelector(".avatar");
|
||
const initial = appState.shell?.user?.name?.trim()?.[0] || "S";
|
||
avatar.textContent = initial.toUpperCase();
|
||
}
|
||
|
||
function renderFolders() {
|
||
if (!appState.shell) return;
|
||
userName.textContent = appState.shell.user.name;
|
||
userEmail.textContent = appState.shell.user.email;
|
||
setAvatar();
|
||
const activeFolder = appState.activeFolder;
|
||
const folders = [
|
||
{
|
||
label: "所有项目",
|
||
count: appState.shell.folders
|
||
.filter((folder) => folder.kind === "folder")
|
||
.reduce((sum, folder) => sum + folder.count, 0),
|
||
},
|
||
...appState.shell.folders,
|
||
];
|
||
folderList.innerHTML = folders
|
||
.map(
|
||
(folder) => `
|
||
<button class="folder-item ${folder.label === activeFolder ? "active" : ""}" data-folder="${escapeHtml(folder.label)}">
|
||
<span>${escapeHtml(folder.label)}</span>
|
||
<span class="folder-count">${folder.count}</span>
|
||
</button>
|
||
`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderTypeFilter() {
|
||
if (!appState.shell) return;
|
||
typeFilter.innerHTML = [
|
||
`<option value="">全部类型</option>`,
|
||
...appState.shell.entry_types.map(
|
||
(entryType) =>
|
||
`<option value="${escapeHtml(entryType)}" ${entryType === appState.activeType ? "selected" : ""}>${escapeHtml(entryType)}</option>`
|
||
),
|
||
].join("");
|
||
}
|
||
|
||
function renderEntries() {
|
||
if (!appState.shell) return;
|
||
const entries = appState.shell.entries;
|
||
const selectedId = appState.shell.selected_entry_id;
|
||
if (!entries.length) {
|
||
entryList.innerHTML = `
|
||
<div class="empty-card">
|
||
<div class="empty-title">没有匹配的条目</div>
|
||
<div class="empty-copy">调整搜索词、类型筛选或文件夹后再试。</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
entryList.innerHTML = entries
|
||
.map(
|
||
(entry) => `
|
||
<button class="entry-item ${entry.id === selectedId ? "active" : ""}" data-entry-id="${escapeHtml(entry.id)}">
|
||
<div class="entry-title">${escapeHtml(entry.title)}</div>
|
||
<div class="entry-subtitle">${escapeHtml(entry.subtitle)}</div>
|
||
</button>
|
||
`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderMetadataEditor() {
|
||
metadataEditor.innerHTML = appState.draftMetadata
|
||
.map(
|
||
(field, index) => `
|
||
<div class="metadata-row">
|
||
<input class="detail-input" data-metadata-key="${index}" value="${escapeHtml(field.label)}" placeholder="元数据名称" />
|
||
<input class="detail-input" data-metadata-value="${index}" value="${escapeHtml(field.value)}" placeholder="元数据内容" />
|
||
<button class="secondary-button small danger" data-remove-metadata="${index}">删除</button>
|
||
</div>
|
||
`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function syncEditMode() {
|
||
const editing = appState.editing;
|
||
const hasSelection = Boolean(selectedEntry());
|
||
const deleted = Boolean(selectedEntry()?.deleted);
|
||
editEntryButton.classList.toggle("hidden", editing || !hasSelection || deleted);
|
||
saveEntryButton.classList.toggle("hidden", !editing);
|
||
cancelEditButton.classList.toggle("hidden", !editing);
|
||
nameSection.classList.toggle("hidden", !editing);
|
||
nameView.classList.add("hidden");
|
||
nameInput.classList.toggle("hidden", !editing);
|
||
metadataList.classList.toggle("hidden", editing);
|
||
metadataEditor.classList.toggle("hidden", !editing);
|
||
addMetadataButton.classList.toggle("hidden", !editing);
|
||
addSecretButton.classList.toggle("hidden", editing || !hasSelection || deleted);
|
||
}
|
||
|
||
function renderSecretCard(secret) {
|
||
const revealed = appState.revealedSecrets[secret.id];
|
||
const isVisible = Boolean(revealed?.visible);
|
||
const value = isVisible ? revealed.value : secret.masked_value;
|
||
return `
|
||
<div class="secret-card">
|
||
<div class="secret-type">${escapeHtml(displaySecretName(secret.name))}</div>
|
||
<div class="secret-row">
|
||
<div class="secret-shell">
|
||
<span class="secret-leading-icon" aria-hidden="true">🔒</span>
|
||
<div class="secret-value">${escapeHtml(value)}</div>
|
||
<button class="secret-inline-action" type="button" data-secret-action="toggle" data-secret-id="${escapeHtml(secret.id)}" aria-label="${isVisible ? "隐藏密钥" : "显示密钥"}">
|
||
${isVisible ? "◑" : "◔"}
|
||
</button>
|
||
</div>
|
||
<div class="secret-actions">
|
||
<button class="secondary-button small secret-copy-button" type="button" data-secret-action="copy" data-secret-id="${escapeHtml(secret.id)}">
|
||
<span class="button-icon" aria-hidden="true">⧉</span>
|
||
<span class="button-label">复制</span>
|
||
</button>
|
||
<button class="secondary-button small" type="button" data-secret-action="edit" data-secret-id="${escapeHtml(secret.id)}">
|
||
<span class="button-label">编辑</span>
|
||
</button>
|
||
<button class="secondary-button small" type="button" data-secret-action="history" data-secret-id="${escapeHtml(secret.id)}">
|
||
<span class="button-label">历史</span>
|
||
</button>
|
||
<button class="secondary-button small danger" type="button" data-secret-action="delete" data-secret-id="${escapeHtml(secret.id)}">
|
||
<span class="button-label">删除</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDetail() {
|
||
const detail = selectedEntry();
|
||
if (!detail) {
|
||
detailFolderLabel.textContent = "-";
|
||
entryTitle.textContent = "未选择条目";
|
||
nameView.textContent = "-";
|
||
nameInput.value = "";
|
||
metadataList.innerHTML = `<div class="empty-card"><div class="empty-copy">从左侧列表选择一个条目后,这里会显示结构化元数据。</div></div>`;
|
||
metadataEditor.innerHTML = "";
|
||
secretList.innerHTML = `<div class="empty-card"><div class="empty-copy">选中条目后会在这里显示受保护的密钥字段。</div></div>`;
|
||
detailBadge.classList.add("hidden");
|
||
deleteEntryButton.classList.add("hidden");
|
||
restoreEntryButton.classList.add("hidden");
|
||
syncEditMode();
|
||
return;
|
||
}
|
||
|
||
detailFolderLabel.textContent = detail.folder;
|
||
entryTitle.textContent = detail.title;
|
||
nameView.textContent = detail.title;
|
||
nameInput.value = detail.title;
|
||
detailBadge.classList.toggle("hidden", !detail.deleted);
|
||
deleteEntryButton.classList.toggle("hidden", detail.deleted);
|
||
restoreEntryButton.classList.toggle("hidden", !detail.deleted);
|
||
metadataList.innerHTML = detail.metadata.length
|
||
? detail.metadata
|
||
.map(
|
||
(field) => `
|
||
<div class="detail-field">
|
||
<div class="detail-label">${escapeHtml(field.label)}</div>
|
||
<div class="detail-value">${escapeHtml(field.value)}</div>
|
||
</div>
|
||
`
|
||
)
|
||
.join("")
|
||
: `<div class="empty-card"><div class="empty-copy">当前条目还没有元数据。</div></div>`;
|
||
renderMetadataEditor();
|
||
|
||
secretList.innerHTML = detail.secrets.length
|
||
? detail.secrets.map(renderSecretCard).join("")
|
||
: `<div class="empty-card"><div class="empty-copy">当前条目还没有密钥字段。</div></div>`;
|
||
syncEditMode();
|
||
}
|
||
|
||
function renderShell(shell) {
|
||
appState.shell = shell;
|
||
appState.activeFolder = "所有项目";
|
||
appState.activeType = "";
|
||
appState.query = "";
|
||
appState.editing = false;
|
||
appState.draftMetadata = shell.selected_entry?.metadata
|
||
? shell.selected_entry.metadata.map((item) => ({ ...item }))
|
||
: [];
|
||
resetSecretCache();
|
||
searchInput.value = "";
|
||
renderFolders();
|
||
renderTypeFilter();
|
||
renderEntries();
|
||
renderDetail();
|
||
}
|
||
|
||
async function ensureUnlockedShell(data) {
|
||
if (!data?.logged_in) return data;
|
||
if (data?.shell) return data;
|
||
|
||
const vault = data?.vault;
|
||
if (!vault) return data;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const setup = !vault.has_master_password;
|
||
openVaultModal({ setup });
|
||
|
||
const submit = async () => {
|
||
const password = vaultPasswordInput.value.trim();
|
||
if (!password) {
|
||
setVaultModalError(setup ? "请先设置本地 vault 主密码" : "请输入本地 vault 主密码");
|
||
return;
|
||
}
|
||
|
||
vaultModalSave.disabled = true;
|
||
try {
|
||
if (setup) {
|
||
await invoke("setup_master_password", { password });
|
||
} else {
|
||
await invoke("unlock_vault", { password });
|
||
}
|
||
cleanup();
|
||
closeVaultModal();
|
||
const bootstrapped = await invoke("app_bootstrap");
|
||
resolve(bootstrapped);
|
||
} catch (error) {
|
||
setVaultModalError(String(error));
|
||
} finally {
|
||
vaultModalSave.disabled = false;
|
||
}
|
||
};
|
||
|
||
const onClick = () => {
|
||
void submit();
|
||
};
|
||
const onKeydown = (event) => {
|
||
if (event.key !== "Enter") return;
|
||
event.preventDefault();
|
||
void submit();
|
||
};
|
||
const cleanup = () => {
|
||
vaultModalSave.removeEventListener("click", onClick);
|
||
vaultPasswordInput.removeEventListener("keydown", onKeydown);
|
||
};
|
||
|
||
vaultModalSave.addEventListener("click", onClick);
|
||
vaultPasswordInput.addEventListener("keydown", onKeydown);
|
||
});
|
||
}
|
||
|
||
function renderMcpDialog(data) {
|
||
appState.mcpJson = data.mcp_json;
|
||
mcpIntegrationList.innerHTML = data.integrations
|
||
.map(
|
||
(integration) => `
|
||
<div class="integration-row-card">
|
||
<div class="integration-app">
|
||
<div class="integration-icon" aria-hidden="true">${integrationGlyph(integration.app_name)}</div>
|
||
<div>
|
||
<div class="entry-title">${escapeHtml(integration.app_name)}</div>
|
||
</div>
|
||
</div>
|
||
<button class="integration-toggle ${integration.configured ? "is-on" : ""}" data-apply-target="${
|
||
integration.app_name === "Cursor" ? "cursor" : "claude-code"
|
||
}" aria-label="写入 ${integration.app_name}">
|
||
<span class="integration-toggle-knob"></span>
|
||
</button>
|
||
</div>
|
||
`
|
||
)
|
||
.join("");
|
||
mcpConfig.textContent = data.mcp_json;
|
||
}
|
||
|
||
function closeEntryModalPanel() {
|
||
entryModal.classList.add("hidden");
|
||
entryModalFolder.value = "";
|
||
entryModalTitle.value = "";
|
||
entryModalType.value = "";
|
||
}
|
||
|
||
function openEntryModalPanel() {
|
||
const detail = selectedEntry();
|
||
entryModalFolder.value = detail?.folder || "Refining";
|
||
entryModalType.value = detail?.entry_type || "service";
|
||
entryModalTitle.value = "";
|
||
entryModal.classList.remove("hidden");
|
||
}
|
||
|
||
function closeSecretModalPanel() {
|
||
secretModal.classList.add("hidden");
|
||
appState.secretModalMode = "create";
|
||
appState.editingSecretId = null;
|
||
secretNameInput.value = "";
|
||
secretTypeInput.value = "text";
|
||
secretValueInput.value = "";
|
||
}
|
||
|
||
function openSecretModalPanel(mode, options = {}) {
|
||
appState.secretModalMode = mode;
|
||
appState.editingSecretId = options.secretId || null;
|
||
secretModalTitle.textContent = mode === "edit" ? "编辑密钥" : "新增密钥";
|
||
secretNameInput.value = options.name || "";
|
||
secretTypeInput.value = options.secretType || "text";
|
||
secretValueInput.value = options.value || "";
|
||
secretModal.classList.remove("hidden");
|
||
}
|
||
|
||
function closeHistoryModalPanel() {
|
||
historyModal.classList.add("hidden");
|
||
appState.historySecretId = null;
|
||
historyList.innerHTML = "";
|
||
}
|
||
|
||
function setTransientLabel(labelNode, nextText, originalText) {
|
||
if (!labelNode) return;
|
||
labelNode.textContent = nextText;
|
||
setTimeout(() => {
|
||
labelNode.textContent = originalText;
|
||
}, 1200);
|
||
}
|
||
|
||
async function bootstrap() {
|
||
if (!invoke) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let data = await invoke("app_bootstrap");
|
||
if (!data.logged_in) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
data = await ensureUnlockedShell(data);
|
||
|
||
renderShell(data.shell);
|
||
showShell();
|
||
} catch (error) {
|
||
setLoginError(String(error));
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
async function doDemoLogin() {
|
||
if (!invoke) return;
|
||
setLoginError("");
|
||
loginButton.disabled = true;
|
||
loginButton.textContent = "正在打开浏览器...";
|
||
try {
|
||
let data = await invoke("continue_demo_login");
|
||
data = await ensureUnlockedShell(data);
|
||
renderShell(data.shell);
|
||
showShell();
|
||
} catch (error) {
|
||
setLoginError(String(error));
|
||
} finally {
|
||
loginButton.textContent = "前往浏览器登录";
|
||
loginButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doLogout() {
|
||
if (!invoke) return;
|
||
await invoke("logout");
|
||
userMenu.classList.add("hidden");
|
||
appState.shell = null;
|
||
showLogin();
|
||
}
|
||
|
||
async function loadEntryDetail(entryId) {
|
||
const detail = await invoke("entry_detail", { entryId });
|
||
setSelectedDetail(detail);
|
||
renderEntries();
|
||
renderDetail();
|
||
}
|
||
|
||
async function refreshEntries() {
|
||
if (!appState.shell) return;
|
||
const deletedOnly = appState.activeFolder === "最近删除";
|
||
const folder =
|
||
appState.activeFolder === "所有项目" || deletedOnly
|
||
? null
|
||
: appState.activeFolder;
|
||
const query = appState.query.trim() || null;
|
||
const entries = await invoke("list_entries", {
|
||
query: {
|
||
folder,
|
||
entryType: appState.activeType || null,
|
||
query,
|
||
deletedOnly,
|
||
},
|
||
});
|
||
appState.shell.entries = entries;
|
||
renderEntries();
|
||
const nextSelected = entries.find((entry) => entry.id === appState.shell.selected_entry_id) || entries[0];
|
||
if (!nextSelected) {
|
||
setSelectedDetail(null);
|
||
renderDetail();
|
||
return;
|
||
}
|
||
if (!selectedEntry() || nextSelected.id !== appState.shell.selected_entry_id) {
|
||
await loadEntryDetail(nextSelected.id);
|
||
return;
|
||
}
|
||
renderDetail();
|
||
}
|
||
|
||
async function refreshShell() {
|
||
const data = await invoke("app_bootstrap");
|
||
if (!data.logged_in || !data.shell) return;
|
||
const previousFolder = appState.activeFolder;
|
||
const previousType = appState.activeType;
|
||
const previousQuery = appState.query;
|
||
const previousSelectedId = appState.shell?.selected_entry_id;
|
||
renderShell(data.shell);
|
||
appState.activeFolder = previousFolder;
|
||
appState.activeType = previousType;
|
||
appState.query = previousQuery;
|
||
renderFolders();
|
||
renderTypeFilter();
|
||
await refreshEntries();
|
||
if (previousSelectedId && appState.shell.entries.some((entry) => entry.id === previousSelectedId)) {
|
||
await loadEntryDetail(previousSelectedId);
|
||
}
|
||
}
|
||
|
||
function applyUpdatedDetail(detail) {
|
||
if (!appState.shell) return;
|
||
const current = appState.shell.entries.find((entry) => entry.id === detail.id);
|
||
if (current) {
|
||
current.title = detail.title;
|
||
current.subtitle = detail.entry_type;
|
||
current.folder = detail.folder;
|
||
}
|
||
setSelectedDetail(detail);
|
||
renderEntries();
|
||
renderDetail();
|
||
}
|
||
|
||
async function openDevices() {
|
||
if (!invoke) return;
|
||
const devices = await invoke("device_list");
|
||
deviceList.innerHTML = devices.length
|
||
? devices
|
||
.map(
|
||
(device) => `
|
||
<div class="device-card">
|
||
<div class="entry-title">${escapeHtml(device.name)}</div>
|
||
<div class="entry-subtitle">${escapeHtml(device.platform)} · ${escapeHtml(device.client_version)}</div>
|
||
<div class="detail-value">${escapeHtml(device.last_seen)}</div>
|
||
${
|
||
device.ip
|
||
? `<div class="entry-subtitle">IP · ${escapeHtml(device.ip)}</div>`
|
||
: ""
|
||
}
|
||
</div>
|
||
`
|
||
)
|
||
.join("")
|
||
: `<div class="empty-card"><div class="empty-copy">当前没有在线设备。</div></div>`;
|
||
deviceModal.classList.remove("hidden");
|
||
userMenu.classList.add("hidden");
|
||
}
|
||
|
||
async function openMcpDialog() {
|
||
if (!invoke) return;
|
||
const data = await invoke("mcp_dialog_data");
|
||
renderMcpDialog(data);
|
||
mcpModal.classList.remove("hidden");
|
||
}
|
||
|
||
async function ensureSecretValue(secretId, { visible = false } = {}) {
|
||
const existing = appState.revealedSecrets[secretId];
|
||
if (existing) {
|
||
if (visible) existing.visible = true;
|
||
return existing;
|
||
}
|
||
const revealed = await invoke("reveal_secret_value", { secretId });
|
||
appState.revealedSecrets[secretId] = { ...revealed, visible };
|
||
return appState.revealedSecrets[secretId];
|
||
}
|
||
|
||
async function toggleSecretVisibility(secretId) {
|
||
const existing = appState.revealedSecrets[secretId];
|
||
if (existing?.visible) {
|
||
existing.visible = false;
|
||
} else {
|
||
await ensureSecretValue(secretId, { visible: true });
|
||
}
|
||
renderDetail();
|
||
}
|
||
|
||
async function copySecretValue(secretId, button) {
|
||
const revealed = await ensureSecretValue(secretId);
|
||
await navigator.clipboard.writeText(revealed.value);
|
||
setTransientLabel(button?.querySelector(".button-label"), "已复制", "复制");
|
||
}
|
||
|
||
async function beginEditSecret(secretId) {
|
||
const detail = selectedEntry();
|
||
const secret = detail?.secrets.find((item) => item.id === secretId);
|
||
if (!secret) return;
|
||
const revealed = await ensureSecretValue(secretId);
|
||
openSecretModalPanel("edit", {
|
||
secretId,
|
||
name: secret.name,
|
||
secretType: secret.secret_type,
|
||
value: revealed.value,
|
||
});
|
||
}
|
||
|
||
async function openSecretHistory(secretId) {
|
||
const detail = selectedEntry();
|
||
const secret = detail?.secrets.find((item) => item.id === secretId);
|
||
if (!secret) return;
|
||
const history = await invoke("secret_history", { secretId });
|
||
appState.historySecretId = secretId;
|
||
historyModalCopy.textContent = `查看 ${secret.name} 的历史版本并选择回滚目标。`;
|
||
historyList.innerHTML = history.length
|
||
? history
|
||
.map(
|
||
(item) => `
|
||
<div class="history-item">
|
||
<div class="history-topline">
|
||
<div class="entry-title">v${item.version} · ${escapeHtml(item.action)}</div>
|
||
<div class="entry-subtitle">${escapeHtml(item.created_at)}</div>
|
||
</div>
|
||
<div class="history-meta">
|
||
<span>名称:${escapeHtml(item.name)}</span>
|
||
<span>类型:${escapeHtml(item.secret_type)}</span>
|
||
<span>history_id:${item.history_id}</span>
|
||
</div>
|
||
<div class="history-value">${escapeHtml(item.value)}</div>
|
||
<div class="history-actions">
|
||
<button class="secondary-button small" data-history-version="${item.version}" data-history-id="${item.history_id}">
|
||
回滚到此版本
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`
|
||
)
|
||
.join("")
|
||
: `<div class="empty-card"><div class="empty-copy">当前密钥还没有历史记录。</div></div>`;
|
||
historyModal.classList.remove("hidden");
|
||
}
|
||
|
||
async function saveSecretModalState() {
|
||
const detail = selectedEntry();
|
||
if (!detail) return;
|
||
const payload = {
|
||
name: secretNameInput.value.trim(),
|
||
secretType: secretTypeInput.value || "text",
|
||
value: secretValueInput.value,
|
||
};
|
||
if (!payload.name || !payload.value) {
|
||
window.alert("请填写完整的密钥名称和内容。");
|
||
return;
|
||
}
|
||
if (appState.secretModalMode === "edit" && appState.editingSecretId) {
|
||
const updated = await invoke("update_secret", {
|
||
secret: {
|
||
id: appState.editingSecretId,
|
||
name: payload.name,
|
||
secretType: payload.secretType,
|
||
value: payload.value,
|
||
},
|
||
});
|
||
closeSecretModalPanel();
|
||
applyUpdatedDetail(updated);
|
||
return;
|
||
}
|
||
const created = await invoke("create_secret", {
|
||
entryId: detail.id,
|
||
secret: payload,
|
||
});
|
||
closeSecretModalPanel();
|
||
applyUpdatedDetail(created);
|
||
}
|
||
|
||
async function createNewEntry() {
|
||
const folder = entryModalFolder.value.trim();
|
||
const title = entryModalTitle.value.trim();
|
||
const entryType = entryModalType.value.trim() || "service";
|
||
if (!folder || !title) {
|
||
window.alert("请填写项目和名称。");
|
||
return;
|
||
}
|
||
const detail = await invoke("create_entry", {
|
||
entry: {
|
||
folder,
|
||
title,
|
||
entryType,
|
||
metadata: [],
|
||
secrets: [],
|
||
},
|
||
});
|
||
closeEntryModalPanel();
|
||
appState.activeFolder = folder;
|
||
await refreshShell();
|
||
await loadEntryDetail(detail.id);
|
||
}
|
||
|
||
folderList.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-folder]");
|
||
if (!button || !appState.shell) return;
|
||
appState.activeFolder = button.dataset.folder;
|
||
appState.editing = false;
|
||
renderFolders();
|
||
await refreshEntries();
|
||
});
|
||
|
||
entryList.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-entry-id]");
|
||
if (!button) return;
|
||
const { entryId } = button.dataset;
|
||
if (!entryId || entryId === appState.shell?.selected_entry_id) return;
|
||
await loadEntryDetail(entryId);
|
||
});
|
||
|
||
searchInput.addEventListener("input", async () => {
|
||
appState.query = searchInput.value;
|
||
await refreshEntries();
|
||
});
|
||
|
||
typeFilter.addEventListener("change", async () => {
|
||
appState.activeType = typeFilter.value;
|
||
await refreshEntries();
|
||
});
|
||
|
||
editEntryButton.addEventListener("click", () => {
|
||
const detail = selectedEntry();
|
||
if (!detail) return;
|
||
appState.editing = true;
|
||
appState.draftMetadata = detail.metadata.map((item) => ({ ...item }));
|
||
renderDetail();
|
||
});
|
||
|
||
cancelEditButton.addEventListener("click", () => {
|
||
appState.editing = false;
|
||
appState.draftMetadata = selectedEntry()?.metadata.map((item) => ({ ...item })) || [];
|
||
renderDetail();
|
||
});
|
||
|
||
metadataEditor.addEventListener("input", (event) => {
|
||
const target = event.target;
|
||
const keyIndex = target.dataset.metadataKey;
|
||
const valueIndex = target.dataset.metadataValue;
|
||
if (keyIndex !== undefined) {
|
||
appState.draftMetadata[Number(keyIndex)].label = target.value;
|
||
}
|
||
if (valueIndex !== undefined) {
|
||
appState.draftMetadata[Number(valueIndex)].value = target.value;
|
||
}
|
||
});
|
||
|
||
metadataEditor.addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-remove-metadata]");
|
||
if (!button) return;
|
||
const index = Number(button.dataset.removeMetadata);
|
||
appState.draftMetadata.splice(index, 1);
|
||
renderMetadataEditor();
|
||
});
|
||
|
||
addMetadataButton.addEventListener("click", () => {
|
||
appState.draftMetadata.push({ label: "", value: "" });
|
||
renderMetadataEditor();
|
||
});
|
||
|
||
saveEntryButton.addEventListener("click", async () => {
|
||
const detail = selectedEntry();
|
||
if (!detail) return;
|
||
try {
|
||
const updated = await invoke("update_entry_detail", {
|
||
entry: {
|
||
...detail,
|
||
title: nameInput.value.trim(),
|
||
metadata: appState.draftMetadata.filter((item) => item.label.trim()),
|
||
},
|
||
});
|
||
applyUpdatedDetail(updated);
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
|
||
deleteEntryButton.addEventListener("click", async () => {
|
||
if (!appState.shell?.selected_entry_id) return;
|
||
if (!window.confirm("确定要将当前条目移到最近删除吗?")) return;
|
||
try {
|
||
await invoke("delete_entry", { entryId: appState.shell.selected_entry_id });
|
||
appState.editing = false;
|
||
await refreshShell();
|
||
await refreshEntries();
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
|
||
restoreEntryButton.addEventListener("click", async () => {
|
||
if (!appState.shell?.selected_entry_id) return;
|
||
try {
|
||
await invoke("restore_deleted_entry", { entryId: appState.shell.selected_entry_id });
|
||
appState.activeFolder = "所有项目";
|
||
appState.editing = false;
|
||
await refreshShell();
|
||
await refreshEntries();
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
|
||
addSecretButton.addEventListener("click", () => {
|
||
if (!selectedEntry()) return;
|
||
openSecretModalPanel("create");
|
||
});
|
||
|
||
newEntryButton.addEventListener("click", () => {
|
||
openEntryModalPanel();
|
||
});
|
||
|
||
loginButton.addEventListener("click", doDemoLogin);
|
||
logoutButton.addEventListener("click", doLogout);
|
||
manageDevicesButton.addEventListener("click", openDevices);
|
||
openMcpModalButton.addEventListener("click", openMcpDialog);
|
||
userTrigger.addEventListener("click", () => userMenu.classList.toggle("hidden"));
|
||
closeDeviceModal.addEventListener("click", () => deviceModal.classList.add("hidden"));
|
||
closeMcpModal.addEventListener("click", () => mcpModal.classList.add("hidden"));
|
||
closeEntryModal.addEventListener("click", closeEntryModalPanel);
|
||
entryModalCancel.addEventListener("click", closeEntryModalPanel);
|
||
entryModalSave.addEventListener("click", async () => {
|
||
try {
|
||
await createNewEntry();
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
closeSecretModal.addEventListener("click", closeSecretModalPanel);
|
||
secretModalCancel.addEventListener("click", closeSecretModalPanel);
|
||
secretModalSave.addEventListener("click", async () => {
|
||
try {
|
||
await saveSecretModalState();
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
closeHistoryModal.addEventListener("click", closeHistoryModalPanel);
|
||
|
||
deviceModal.addEventListener("click", (event) => {
|
||
if (event.target === deviceModal) {
|
||
deviceModal.classList.add("hidden");
|
||
}
|
||
});
|
||
mcpModal.addEventListener("click", (event) => {
|
||
if (event.target === mcpModal) {
|
||
mcpModal.classList.add("hidden");
|
||
}
|
||
});
|
||
entryModal.addEventListener("click", (event) => {
|
||
if (event.target === entryModal) {
|
||
closeEntryModalPanel();
|
||
}
|
||
});
|
||
secretModal.addEventListener("click", (event) => {
|
||
if (event.target === secretModal) {
|
||
closeSecretModalPanel();
|
||
}
|
||
});
|
||
historyModal.addEventListener("click", (event) => {
|
||
if (event.target === historyModal) {
|
||
closeHistoryModalPanel();
|
||
}
|
||
});
|
||
|
||
mcpIntegrationList.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-apply-target]");
|
||
if (!button || !invoke) return;
|
||
button.disabled = true;
|
||
button.classList.add("is-loading");
|
||
try {
|
||
const result = await invoke("apply_mcp_config", {
|
||
target: button.dataset.applyTarget,
|
||
});
|
||
renderMcpDialog(result.data);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.classList.remove("is-loading");
|
||
}
|
||
});
|
||
|
||
secretList.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-secret-action]");
|
||
if (!button) return;
|
||
const secretId = button.dataset.secretId;
|
||
const action = button.dataset.secretAction;
|
||
if (!secretId) return;
|
||
try {
|
||
if (action === "toggle") {
|
||
await toggleSecretVisibility(secretId);
|
||
return;
|
||
}
|
||
if (action === "copy") {
|
||
await copySecretValue(secretId, button);
|
||
return;
|
||
}
|
||
if (action === "edit") {
|
||
await beginEditSecret(secretId);
|
||
return;
|
||
}
|
||
if (action === "history") {
|
||
await openSecretHistory(secretId);
|
||
return;
|
||
}
|
||
if (action === "delete") {
|
||
if (!window.confirm("确定要删除这个密钥吗?")) return;
|
||
await invoke("delete_secret", { secretId });
|
||
await loadEntryDetail(selectedEntry().id);
|
||
}
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
|
||
historyList.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-history-version]");
|
||
if (!button || !appState.historySecretId) return;
|
||
if (!window.confirm(`确定回滚到 v${button.dataset.historyVersion} 吗?`)) return;
|
||
try {
|
||
const detail = await invoke("rollback_secret", {
|
||
secretId: appState.historySecretId,
|
||
version: Number(button.dataset.historyVersion),
|
||
historyId: Number(button.dataset.historyId),
|
||
});
|
||
closeHistoryModalPanel();
|
||
applyUpdatedDetail(detail);
|
||
} catch (error) {
|
||
window.alert(String(error));
|
||
}
|
||
});
|
||
|
||
copyMcpConfigButton.addEventListener("click", async () => {
|
||
if (!appState.mcpJson) return;
|
||
await navigator.clipboard.writeText(appState.mcpJson);
|
||
copyMcpConfigButtonLabel.textContent = "已复制";
|
||
setTimeout(() => {
|
||
copyMcpConfigButtonLabel.textContent = "复制";
|
||
}, 1200);
|
||
});
|
||
|
||
document.addEventListener("click", (event) => {
|
||
if (!userTrigger.contains(event.target) && !userMenu.contains(event.target)) {
|
||
userMenu.classList.add("hidden");
|
||
}
|
||
});
|
||
|
||
bootstrap();
|