460 lines
16 KiB
JavaScript
460 lines
16 KiB
JavaScript
/**
|
||
* 模板引擎 - 动态模板渲染系统
|
||
*/
|
||
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(); |