Files
database_render/web/static/js/template-engine.js
2025-08-07 20:03:53 +08:00

460 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 模板引擎 - 动态模板渲染系统
*/
class TemplateEngine {
constructor() {
this.templates = new Map();
this.cache = new Map();
this.currentTable = null;
this.currentTemplate = 'list';
this.currentData = null;
this.isLoading = false;
this.preloadQueue = new Set();
this.cacheLimit = 50; // 限制缓存大小
this.cacheAccess = new Map(); // 记录缓存访问时间用于LRU
}
/**
* 加载模板
* @param {string} templateName - 模板名称
* @param {string} tableName - 表名
*/
async loadTemplate(templateName, tableName) {
const cacheKey = `${templateName}:${tableName}`;
// 检查缓存
if (this.cache.has(cacheKey)) {
this.cacheAccess.set(cacheKey, Date.now());
return this.cache.get(cacheKey);
}
try {
// 使用 AbortController 支持请求取消
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
// 优先使用自定义模板
const customPath = `/templates/custom/${tableName}/${templateName}.html`;
let response = await fetch(customPath, { signal: controller.signal });
if (!response.ok) {
// 回退到默认自定义模板
const defaultPath = `/templates/custom/_default/${templateName}.html`;
response = await fetch(defaultPath, { signal: controller.signal });
}
if (!response.ok) {
// 回退到内置模板
const builtinPath = `/templates/builtin/${templateName}.html`;
response = await fetch(builtinPath, { signal: controller.signal });
}
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Template ${templateName} not found`);
}
const template = await response.text();
this.setCache(cacheKey, template);
return template;
} catch (error) {
console.error('Failed to load template:', error);
return this.getFallbackTemplate(templateName);
}
}
/**
* 获取回退模板
*/
getFallbackTemplate(templateName) {
const fallbacks = {
'list': `
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{{#each columns}}
{{#if this.showInList}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{this.alias}}
</th>
{{/if}}
{{/each}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{#each data}}
<tr class="hover:bg-gray-50">
{{#each ../columns}}
{{#if this.showInList}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{renderField (lookup ../this this.name) this.renderType this}}
</td>
{{/if}}
{{/each}}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="showDetail('{{this.id}}')" class="text-blue-600 hover:text-blue-900">查看详情</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
`,
'card': `
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{{#each data}}
<div class="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
{{#each ../columns}}
{{#if this.showInList}}
<div class="mb-2">
<label class="block text-sm font-medium text-gray-700">{{this.alias}}</label>
<div class="mt-1 text-sm text-gray-900">
{{renderField (lookup ../this this.name) this.renderType this}}
</div>
</div>
{{/if}}
{{/each}}
<div class="mt-4">
<button onclick="showDetail('{{this.id}}')" class="text-blue-600 hover:text-blue-900 text-sm">查看详情</button>
</div>
</div>
{{/each}}
</div>
`
};
return fallbacks[templateName] || fallbacks['list'];
}
/**
* 渲染字段
* @param {*} value - 字段值
* @param {string} type - 渲染类型
* @param {*} column - 列配置
*/
renderField(value, type, column) {
const renderers = {
'raw': (v) => v || '-',
'time': (v) => {
if (!v) return '-';
const date = new Date(v);
return column.format ?
date.toLocaleString('zh-CN') :
date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
},
'tag': (v, col) => {
if (!v || !col.values) return v || '-';
const tag = col.values[v.toString()];
if (!tag) return v;
return `<span class="px-2 py-1 text-xs font-medium rounded-full" style="background-color: ${tag.color}; color: white;">${tag.label}</span>`;
},
'category': (v) => v || '-',
'markdown': (v) => {
if (!v) return '-';
return marked.parse(v.toString());
}
};
return renderers[type] ? renderers[type](value, column) : value || '-';
}
/**
* 渲染模板
* @param {string} template - 模板内容
* @param {Object} data - 渲染数据
*/
renderTemplate(template, data) {
try {
// 简单压缩HTML模板
const compressedTemplate = this.compressHTML(template);
// 简单的模板引擎实现 - 优化性能
let rendered = compressedTemplate;
// 预编译正则表达式,提升性能
const eachRegex = /\{\{#each ([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g;
const fieldRegex = /\{\{renderField ([^}]+)\}\}/g;
const variableRegex = /\{\{([^}]+)\}\}/g;
const thisRegex = /\{\{this\.([^}]+)\}\}/g;
const simpleThisRegex = /\{\{this\}\}/g;
// 处理循环
rendered = rendered.replace(eachRegex, (match, path, content) => {
const items = this.getValue(data, path);
if (!Array.isArray(items)) return '';
return items.map(item => {
let itemContent = content;
itemContent = itemContent.replace(thisRegex, (m, key) => this.getValue(item, key) || '');
itemContent = itemContent.replace(simpleThisRegex, item);
return itemContent;
}).join('');
});
// 处理字段渲染
rendered = rendered.replace(fieldRegex, (match, params) => {
const [valuePath, typePath, columnPath] = params.split(' ');
const value = this.getValue(data, valuePath);
const type = this.getValue(data, typePath);
const column = this.getValue(data, columnPath);
return this.renderField(value, type, column);
});
// 处理简单变量
rendered = rendered.replace(variableRegex, (match, path) => {
return this.getValue(data, path) || '';
});
return rendered;
} catch (error) {
console.error('Template rendering failed:', error);
return this.renderError('模板渲染失败:' + error.message);
}
}
/**
* 获取对象值
*/
getValue(obj, path) {
if (!obj || !path) return undefined;
const keys = path.split('.');
let value = obj;
for (const key of keys) {
if (value == null) return undefined;
value = value[key];
}
return value;
}
/**
* 渲染数据表
* @param {string} tableName - 表名
* @param {Object} options - 渲染选项
*/
async renderTable(tableName, options = {}) {
try {
this.showLoading();
const { page = 1, perPage = 20, search = '', sort = '', order = 'desc', template = 'list' } = options;
// 并行加载数据和模板
const [data, templateContent] = await Promise.all([
apiClient.getTableData(tableName, {
page,
per_page: perPage,
search,
sort,
order
}),
this.loadTemplate(template, tableName)
]);
// 预加载其他常用模板
if (template !== 'list') {
this.preloadTemplates(tableName, ['list']);
}
if (template !== 'card') {
this.preloadTemplates(tableName, ['card']);
}
// 渲染数据
const rendered = this.renderTemplate(templateContent, {
data: data.data,
columns: data.columns || [],
total: data.total,
page: data.page,
perPage: data.per_page,
pages: data.pages
});
this.currentTable = tableName;
this.currentTemplate = template;
this.currentData = data;
this.hideLoading();
return rendered;
} catch (error) {
console.error('Failed to render table:', error);
this.hideLoading();
return this.renderError(error.message);
}
}
/**
* 切换模板
* @param {string} templateName - 模板名称
*/
async switchTemplate(templateName) {
if (!this.currentTable) return;
try {
const rendered = await this.renderTable(this.currentTable, {
...this.getCurrentOptions(),
template: templateName
});
this.updateContent(rendered);
// 更新URL参数
const url = new URL(window.location);
url.searchParams.set('template', templateName);
window.history.pushState({}, '', url);
} catch (error) {
console.error('Failed to switch template:', error);
}
}
/**
* 获取当前查询参数
*/
getCurrentOptions() {
const url = new URL(window.location);
return {
page: parseInt(url.searchParams.get('page')) || 1,
perPage: parseInt(url.searchParams.get('per_page')) || 20,
search: url.searchParams.get('search') || '',
sort: url.searchParams.get('sort') || '',
order: url.searchParams.get('order') || 'desc',
template: this.currentTemplate
};
}
/**
* 显示加载状态
*/
showLoading() {
this.isLoading = true;
const container = document.getElementById('mainContent');
if (container) {
container.innerHTML = `
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">加载中...</span>
</div>
`;
}
}
/**
* 隐藏加载状态
*/
hideLoading() {
this.isLoading = false;
}
/**
* 更新内容
* @param {string} content - 渲染后的HTML内容
*/
updateContent(content) {
const container = document.getElementById('mainContent');
if (container) {
container.innerHTML = content;
}
}
/**
* 渲染错误
* @param {string} message - 错误消息
*/
renderError(message) {
return `
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="text-red-600">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">加载失败</h3>
<div class="mt-2 text-sm text-red-700">
<p>${message}</p>
</div>
</div>
</div>
</div>
`;
}
/**
* 设置缓存带LRU策略
* @param {string} key - 缓存键
* @param {string} value - 缓存值
*/
setCache(key, value) {
if (this.cache.size >= this.cacheLimit) {
// 使用LRU策略移除最少使用的缓存
const oldestKey = this.getOldestCacheKey();
if (oldestKey) {
this.cache.delete(oldestKey);
this.cacheAccess.delete(oldestKey);
}
}
this.cache.set(key, value);
this.cacheAccess.set(key, Date.now());
}
/**
* 获取最久未使用的缓存键
*/
getOldestCacheKey() {
let oldestKey = null;
let oldestTime = Date.now();
for (const [key, time] of this.cacheAccess) {
if (time < oldestTime) {
oldestTime = time;
oldestKey = key;
}
}
return oldestKey;
}
/**
* 预加载模板
* @param {string} tableName - 表名
* @param {Array} templateNames - 模板名称列表
*/
async preloadTemplates(tableName, templateNames) {
const promises = templateNames.map(templateName =>
this.loadTemplate(templateName, tableName).catch(() => {
// 预加载失败时不影响主流程
console.warn(`Failed to preload template: ${templateName}`);
})
);
await Promise.allSettled(promises);
}
/**
* 清空缓存
*/
clearCache() {
this.cache.clear();
this.cacheAccess.clear();
}
/**
* 压缩HTML内容简单压缩
* @param {string} html - HTML内容
*/
compressHTML(html) {
return html
.replace(/\s+/g, ' ')
.replace(/>\s+</g, '><')
.trim();
}
}
// 全局模板引擎实例
window.templateEngine = new TemplateEngine();