557 lines
19 KiB
JavaScript
557 lines
19 KiB
JavaScript
/**
|
||
* @file js/storage/storage-adapter.js
|
||
* @description
|
||
* 存储适配器 - 支持 localStorage(前端模式)和 Backend API(后端模式)双模式
|
||
*
|
||
* 使用方式:
|
||
* 1. 前端模式(Vercel/静态/直接打开 index.html): 使用 localStorage + IndexedDB
|
||
* 2. 后端模式(Docker/自建后端): 使用 Backend API + 数据库
|
||
*/
|
||
|
||
// ---------------- 部署模式与后端探测 ----------------
|
||
// 优先级(高→低):URL 查询参数 ?mode=backend|frontend → window.ENV_DEPLOYMENT_MODE → 自动探测 /api/health → 默认 frontend
|
||
function getQueryModeOverride() {
|
||
try {
|
||
const p = new URLSearchParams(window.location.search);
|
||
const m = (p.get('mode') || '').toLowerCase();
|
||
if (m === 'backend' || m === 'frontend') return m;
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
let DEPLOYMENT_MODE = (getQueryModeOverride() || (window.ENV_DEPLOYMENT_MODE && window.ENV_DEPLOYMENT_MODE !== 'auto'
|
||
? window.ENV_DEPLOYMENT_MODE
|
||
: 'frontend'));
|
||
|
||
const API_BASE_URL = window.ENV_API_BASE_URL || '/api';
|
||
|
||
async function autoDetectBackendAvailability(timeoutMs = 900) {
|
||
// file:// 明确无后端
|
||
if (window.location.protocol === 'file:') return false;
|
||
// 显式覆盖不探测
|
||
if (getQueryModeOverride() || (window.ENV_DEPLOYMENT_MODE && window.ENV_DEPLOYMENT_MODE !== 'auto')) {
|
||
return DEPLOYMENT_MODE === 'backend';
|
||
}
|
||
try {
|
||
const controller = new AbortController();
|
||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||
const res = await fetch(`${API_BASE_URL}/health`, { signal: controller.signal, cache: 'no-store' });
|
||
clearTimeout(id);
|
||
return res.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ---------------- 认证 Token 管理 ----------------
|
||
class AuthManager {
|
||
static getToken() {
|
||
return localStorage.getItem('auth_token');
|
||
}
|
||
|
||
static setToken(token) {
|
||
localStorage.setItem('auth_token', token);
|
||
}
|
||
|
||
static removeToken() {
|
||
localStorage.removeItem('auth_token');
|
||
}
|
||
|
||
static isAuthenticated() {
|
||
return !!this.getToken();
|
||
}
|
||
|
||
static getHeaders() {
|
||
const token = this.getToken();
|
||
return token
|
||
? { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||
: { 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
/**
|
||
* 从 URL query 参数中提取 token 并存储
|
||
* 支持 ?token=xxx 或 ?auth_token=xxx
|
||
*/
|
||
static initTokenFromURL() {
|
||
try {
|
||
const p = new URLSearchParams(window.location.search);
|
||
const token = p.get('token') || p.get('auth_token');
|
||
if (token) {
|
||
this.setToken(token);
|
||
console.log('[Auth] Token initialized from URL parameter');
|
||
// 清理 URL 中的 token 参数(安全考虑)
|
||
const cleanUrl = new URL(window.location.href);
|
||
cleanUrl.searchParams.delete('token');
|
||
cleanUrl.searchParams.delete('auth_token');
|
||
window.history.replaceState({}, '', cleanUrl.toString());
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Auth] Failed to init token from URL:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 页面加载时自动从 URL 获取 token
|
||
AuthManager.initTokenFromURL();
|
||
|
||
// ---------------- 后端存储实现 ----------------
|
||
class BackendStorage {
|
||
// 回退到本地存储的方法
|
||
_fallbackTo(method, ...args) {
|
||
const localMethod = window[method];
|
||
if (typeof localMethod === 'function') {
|
||
return localMethod.apply(null, args);
|
||
}
|
||
console.warn(`[BackendStorage] No local fallback for ${method}`);
|
||
return Promise.resolve();
|
||
}
|
||
|
||
async fetchAPI(endpoint, options = {}) {
|
||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||
...options,
|
||
headers: {
|
||
...AuthManager.getHeaders(),
|
||
...options.headers
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
// Token 过期,清除token
|
||
AuthManager.removeToken();
|
||
}
|
||
throw new Error(`API Error: ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// 用户设置
|
||
async loadSettings() {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('loadSettings');
|
||
}
|
||
const data = await this.fetchAPI('/user/settings');
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Failed to load settings from backend:', error);
|
||
return this._fallbackTo('loadSettings');
|
||
}
|
||
}
|
||
|
||
async saveSettings(settings) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('saveSettings', settings);
|
||
}
|
||
await this.fetchAPI('/user/settings', {
|
||
method: 'PUT',
|
||
body: JSON.stringify(settings)
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to save settings to backend:', error);
|
||
return this._fallbackTo('saveSettings', settings);
|
||
}
|
||
}
|
||
|
||
// API Keys
|
||
async loadModelKeys(provider) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('loadModelKeys', provider);
|
||
}
|
||
const keys = await this.fetchAPI(`/user/api-keys?provider=${provider}`);
|
||
return keys;
|
||
} catch (error) {
|
||
console.error('Failed to load API keys:', error);
|
||
return this._fallbackTo('loadModelKeys', provider);
|
||
}
|
||
}
|
||
|
||
async saveModelKeys(provider, keys) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('saveModelKeys', provider, keys);
|
||
}
|
||
await this.fetchAPI('/user/api-keys', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ provider, keys })
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to save API keys:', error);
|
||
return this._fallbackTo('saveModelKeys', provider, keys);
|
||
}
|
||
}
|
||
|
||
// 文档历史
|
||
async saveResultToDB(document) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('saveResultToDB', document);
|
||
}
|
||
|
||
// 判断是更新还是创建:UUID 格式的 ID 表示更新现有文档
|
||
const isUUID = document.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(document.id);
|
||
|
||
if (isUUID) {
|
||
// 更新现有文档
|
||
await this.fetchAPI(`/documents/${document.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(document)
|
||
});
|
||
return document;
|
||
} else {
|
||
// 创建新文档(后端会生成 UUID)
|
||
const response = await this.fetchAPI('/documents', {
|
||
method: 'POST',
|
||
body: JSON.stringify(document)
|
||
});
|
||
// 返回后端生成的完整文档(包含 UUID)
|
||
return response;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to save document to backend, falling back to local:', error);
|
||
return this._fallbackTo('saveResultToDB', document);
|
||
}
|
||
}
|
||
|
||
async getAllResultsFromDB() {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('getAllResultsFromDB');
|
||
}
|
||
const data = await this.fetchAPI('/documents');
|
||
return data.documents || [];
|
||
} catch (error) {
|
||
console.error('Failed to load documents from backend, falling back to local:', error);
|
||
return this._fallbackTo('getAllResultsFromDB');
|
||
}
|
||
}
|
||
|
||
async getResultFromDB(id) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('getResultFromDB', id);
|
||
}
|
||
return await this.fetchAPI(`/documents/${id}`);
|
||
} catch (error) {
|
||
console.error('Failed to load document from backend, falling back to local:', error);
|
||
return this._fallbackTo('getResultFromDB', id);
|
||
}
|
||
}
|
||
|
||
async deleteResultFromDB(id) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('deleteResultFromDB', id);
|
||
}
|
||
await this.fetchAPI(`/documents/${id}`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Failed to delete document from backend, falling back to local:', error);
|
||
return this._fallbackTo('deleteResultFromDB', id);
|
||
}
|
||
}
|
||
|
||
async clearAllResultsFromDB() {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('clearAllResultsFromDB');
|
||
}
|
||
// 后端没有批量删除接口,逐个删除
|
||
const docs = await this.getAllResultsFromDB();
|
||
for (const doc of docs) {
|
||
await this.fetchAPI(`/documents/${doc.id}`, { method: 'DELETE' });
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to clear all documents from backend, falling back to local:', error);
|
||
return this._fallbackTo('clearAllResultsFromDB');
|
||
}
|
||
}
|
||
|
||
// 术语库
|
||
async loadGlossarySets() {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('loadGlossarySets');
|
||
}
|
||
const glossaries = await this.fetchAPI('/user/glossaries');
|
||
const sets = {};
|
||
glossaries.forEach(g => { sets[g.id] = g; });
|
||
return sets;
|
||
} catch (error) {
|
||
console.error('Failed to load glossaries:', error);
|
||
return this._fallbackTo('loadGlossarySets');
|
||
}
|
||
}
|
||
|
||
async saveGlossarySets(sets) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('saveGlossarySets', sets);
|
||
}
|
||
// 批量保存(简化实现)
|
||
for (const [id, set] of Object.entries(sets)) {
|
||
if (set._isNew) {
|
||
await this.fetchAPI('/user/glossaries', { method: 'POST', body: JSON.stringify(set) });
|
||
} else {
|
||
await this.fetchAPI(`/user/glossaries/${id}`, { method: 'PUT', body: JSON.stringify(set) });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to save glossaries:', error);
|
||
return this._fallbackTo('saveGlossarySets', sets);
|
||
}
|
||
}
|
||
|
||
// 标注
|
||
async saveAnnotationToDB(annotation) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('saveAnnotationToDB', annotation);
|
||
}
|
||
await this.fetchAPI(`/documents/${annotation.documentId}/annotations`, { method: 'POST', body: JSON.stringify(annotation) });
|
||
} catch (error) {
|
||
console.error('Failed to save annotation:', error);
|
||
return this._fallbackTo('saveAnnotationToDB', annotation);
|
||
}
|
||
}
|
||
|
||
async getAnnotationsForDocFromDB(docId) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('getAnnotationsForDocFromDB', docId);
|
||
}
|
||
return await this.fetchAPI(`/documents/${docId}/annotations`);
|
||
} catch (error) {
|
||
console.error('Failed to load annotations:', error);
|
||
return this._fallbackTo('getAnnotationsForDocFromDB', docId);
|
||
}
|
||
}
|
||
|
||
// 聊天历史
|
||
async loadChatHistory(docId) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
// 聊天历史在本地存储中没有对应方法,返回空数组
|
||
return [];
|
||
}
|
||
const data = await this.fetchAPI(`/chat/${docId}/history`);
|
||
return data.messages || [];
|
||
} catch (error) {
|
||
console.error('Failed to load chat history:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async saveChatMessage(docId, message) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
// 聊天历史在本地存储中没有对应方法
|
||
return;
|
||
}
|
||
await this.fetchAPI(`/chat/${docId}/history`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(message)
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to save chat message:', error);
|
||
}
|
||
}
|
||
|
||
async clearChatHistory(docId) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return;
|
||
}
|
||
await this.fetchAPI(`/chat/${docId}/history`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Failed to clear chat history:', error);
|
||
}
|
||
}
|
||
|
||
// 文献引用
|
||
async loadReferences(docId) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('loadReferences', docId) || [];
|
||
}
|
||
return await this.fetchAPI(`/references/${docId}/references`);
|
||
} catch (error) {
|
||
console.error('Failed to load references:', error);
|
||
return this._fallbackTo('loadReferences', docId) || [];
|
||
}
|
||
}
|
||
|
||
async saveReference(docId, reference) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('saveReference', docId, reference);
|
||
}
|
||
await this.fetchAPI(`/references/${docId}/references`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(reference)
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to save reference:', error);
|
||
return this._fallbackTo('saveReference', docId, reference);
|
||
}
|
||
}
|
||
|
||
async deleteReference(docId, refId) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('deleteReference', docId, refId);
|
||
}
|
||
await this.fetchAPI(`/references/${docId}/references/${refId}`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Failed to delete reference:', error);
|
||
return this._fallbackTo('deleteReference', docId, refId);
|
||
}
|
||
}
|
||
|
||
// Prompt Pool
|
||
async loadPromptPool() {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('loadPromptPool');
|
||
}
|
||
return await this.fetchAPI('/prompt-pool');
|
||
} catch (error) {
|
||
console.error('Failed to load prompt pool:', error);
|
||
return this._fallbackTo('loadPromptPool');
|
||
}
|
||
}
|
||
|
||
async savePromptPool(data) {
|
||
try {
|
||
if (!AuthManager.isAuthenticated()) {
|
||
return this._fallbackTo('savePromptPool', data);
|
||
}
|
||
await this.fetchAPI('/prompt-pool', {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data)
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to save prompt pool:', error);
|
||
return this._fallbackTo('savePromptPool', data);
|
||
}
|
||
}
|
||
|
||
_getDefaultSettings() {
|
||
return {
|
||
maxTokensPerChunk: 2000,
|
||
skipProcessedFiles: false,
|
||
selectedTranslationModel: 'none',
|
||
concurrencyLevel: 1,
|
||
translationConcurrencyLevel: 15,
|
||
targetLanguage: 'chinese',
|
||
customTargetLanguageName: '',
|
||
enableGlossary: false,
|
||
batchModeEnabled: false,
|
||
batchModeTemplate: '{original_name}_{output_language}_{processing_time:YYYYMMDD-HHmmss}.{original_type}',
|
||
batchModeFormats: ['original', 'markdown'],
|
||
batchModeZipEnabled: false
|
||
};
|
||
}
|
||
}
|
||
|
||
// ---------------- 存储适配器工厂 ----------------
|
||
class StorageAdapterFactory {
|
||
static create(mode) {
|
||
if (mode === 'backend') {
|
||
console.log('[Storage] Using Backend Storage Mode');
|
||
const instance = new BackendStorage();
|
||
instance.isFrontendMode = false; // 供其他模块探测
|
||
return instance;
|
||
}
|
||
console.log('[Storage] Using Local Storage Mode');
|
||
// 返回 storage.js 中的函数包装(保持现有调用不变)
|
||
const adapter = {
|
||
loadSettings: window.loadSettings,
|
||
saveSettings: window.saveSettings,
|
||
loadModelKeys: window.loadModelKeys,
|
||
saveModelKeys: window.saveModelKeys,
|
||
saveResultToDB: window.saveResultToDB,
|
||
getAllResultsFromDB: window.getAllResultsFromDB,
|
||
getResultFromDB: window.getResultFromDB,
|
||
deleteResultFromDB: window.deleteResultFromDB,
|
||
clearAllResultsFromDB: window.clearAllResultsFromDB,
|
||
loadGlossarySets: window.loadGlossarySets,
|
||
saveGlossarySets: window.saveGlossarySets,
|
||
saveAnnotationToDB: window.saveAnnotationToDB,
|
||
getAnnotationsForDocFromDB: window.getAnnotationsForDocFromDB,
|
||
updateAnnotationInDB: window.updateAnnotationInDB,
|
||
deleteAnnotationFromDB: window.deleteAnnotationFromDB,
|
||
loadProcessedFilesRecord: window.loadProcessedFilesRecord,
|
||
saveProcessedFilesRecord: window.saveProcessedFilesRecord,
|
||
// Prompt Pool(前端模式:落地到 localStorage,键与 process/prompt-pool.js 一致)
|
||
loadPromptPool: async function () {
|
||
try {
|
||
const prompts = JSON.parse(localStorage.getItem('paperBurnerPromptPool') || '[]');
|
||
const healthConfig = JSON.parse(localStorage.getItem('paperBurnerPromptHealthConfig') || 'null');
|
||
return { prompts, healthConfig };
|
||
} catch (e) {
|
||
console.warn('[StorageAdapter] loadPromptPool(local) 失败,返回空集合:', e);
|
||
return { prompts: [], healthConfig: null };
|
||
}
|
||
},
|
||
savePromptPool: async function (data) {
|
||
try {
|
||
if (!data || typeof data !== 'object') return;
|
||
if (Array.isArray(data.prompts)) {
|
||
localStorage.setItem('paperBurnerPromptPool', JSON.stringify(data.prompts));
|
||
}
|
||
if (data.healthConfig) {
|
||
localStorage.setItem('paperBurnerPromptHealthConfig', JSON.stringify(data.healthConfig));
|
||
}
|
||
} catch (e) {
|
||
console.warn('[StorageAdapter] savePromptPool(local) 失败:', e);
|
||
}
|
||
}
|
||
};
|
||
adapter.isFrontendMode = true; // 供其他模块探测
|
||
return adapter;
|
||
}
|
||
}
|
||
|
||
// ---------------- 初始化与自动切换 ----------------
|
||
function printBanner() {
|
||
const infoStyle = 'font-size: 14px; color: #10b981;';
|
||
const modeStyle = 'font-size: 14px; font-weight: bold; color: #f59e0b;';
|
||
const borderStyle = 'color: #6366f1;';
|
||
const linkStyle = 'font-size: 13px; color: #06b6d4; text-decoration: underline;';
|
||
|
||
|
||
const mode = DEPLOYMENT_MODE === 'backend' ? '后端模式 (Backend Mode)' : '前端模式 (Frontend Mode)';
|
||
const storage = DEPLOYMENT_MODE === 'backend' ? 'Backend API + PostgreSQL' : 'localStorage + IndexedDB';
|
||
const auth = DEPLOYMENT_MODE === 'backend' ? 'JWT Authentication' : 'No Authentication';
|
||
|
||
console.log('%c╔════════════════════════════════════════════════════════════╗', borderStyle);
|
||
console.log('%c║ 系统信息 / System Info ║', borderStyle);
|
||
console.log('%c╠════════════════════════════════════════════════════════════╣', borderStyle);
|
||
console.log('%c║ %c运行模式: ' + mode + ' %c║', borderStyle, modeStyle, borderStyle);
|
||
console.log('%c║ %c存储方式: ' + storage + ' %c║', borderStyle, infoStyle, borderStyle);
|
||
console.log('%c║ %c认证方式: ' + auth + ' %c║', borderStyle, infoStyle, borderStyle);
|
||
console.log('%c╚════════════════════════════════════════════════════════════╝', borderStyle);
|
||
console.log('%c\n🚀 Paper Burner X 已就绪!Ready to burn papers!\n', 'font-size: 14px; color: #8b5cf6; font-weight: bold;');
|
||
console.log('%c→ GitHub: %chttps://github.com/Feather-2/paper-burner-x', 'font-size: 13px; color: #64748b;', linkStyle);
|
||
}
|
||
|
||
// 初始实例(默认前端/显式覆盖),随后可能自动切换为 backend
|
||
window.storageAdapter = StorageAdapterFactory.create(DEPLOYMENT_MODE);
|
||
window.AuthManager = AuthManager;
|
||
window.DEPLOYMENT_MODE = DEPLOYMENT_MODE;
|
||
printBanner();
|
||
|
||
// 自动探测,有后端则无缝切换(不阻塞静态模式渲染)
|
||
autoDetectBackendAvailability().then((hasBackend) => {
|
||
if (hasBackend && DEPLOYMENT_MODE !== 'backend') {
|
||
DEPLOYMENT_MODE = 'backend';
|
||
window.DEPLOYMENT_MODE = DEPLOYMENT_MODE;
|
||
window.storageAdapter = StorageAdapterFactory.create('backend');
|
||
try { window.dispatchEvent(new CustomEvent('pb:storage-mode-changed', { detail: { mode: 'backend' } })); } catch {}
|
||
console.log('[Storage] Auto-switched to Backend mode (health check passed)');
|
||
}
|
||
}).catch(() => {/* ignore */});
|