feat: update auto render
This commit is contained in:
180
web/static/js/api-client.js
Normal file
180
web/static/js/api-client.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* API客户端 - 处理所有AJAX请求
|
||||
*/
|
||||
class APIClient {
|
||||
constructor(baseURL = '/api') {
|
||||
this.baseURL = baseURL;
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 5 * 60 * 1000; // 5分钟缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有数据表列表
|
||||
*/
|
||||
async getTables() {
|
||||
const cacheKey = 'tables';
|
||||
|
||||
// 检查缓存
|
||||
if (this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}/tables`);
|
||||
if (!response.ok) throw new Error('Failed to fetch tables');
|
||||
|
||||
const data = await response.json();
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据表数据
|
||||
* @param {string} tableAlias - 表别名
|
||||
* @param {Object} params - 查询参数
|
||||
*/
|
||||
async getTableData(tableAlias, params = {}) {
|
||||
const url = new URL(`${this.baseURL}/data/${tableAlias}`, window.location.origin);
|
||||
|
||||
// 添加查询参数
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== undefined && params[key] !== '') {
|
||||
url.searchParams.append(key, params[key]);
|
||||
}
|
||||
});
|
||||
|
||||
const cacheKey = url.toString();
|
||||
|
||||
// 检查缓存(搜索参数相关的缓存)
|
||||
if (this.cache.has(cacheKey) && !params.search) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 AbortController 支持请求取消
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: this.defaultHeaders
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch table data');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 缓存非搜索请求的结果
|
||||
if (!params.search) {
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据详情
|
||||
* @param {string} tableAlias - 表别名
|
||||
* @param {string|number} id - 记录ID
|
||||
*/
|
||||
async getRecordDetail(tableAlias, id) {
|
||||
const cacheKey = `detail:${tableAlias}:${id}`;
|
||||
|
||||
// 检查缓存
|
||||
if (this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/data/${tableAlias}/detail/${id}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch record detail');
|
||||
|
||||
const data = await response.json();
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用模板列表
|
||||
* @param {string} tableName - 表名
|
||||
*/
|
||||
async getAvailableTemplates(tableName) {
|
||||
const cacheKey = `templates:${tableName}`;
|
||||
|
||||
// 检查缓存
|
||||
if (this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/templates/${tableName}/available`);
|
||||
if (!response.ok) throw new Error('Failed to fetch templates');
|
||||
|
||||
const data = await response.json();
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模板
|
||||
* @param {string} tableName - 表名
|
||||
* @param {string} templateType - 模板类型
|
||||
*/
|
||||
async validateTemplate(tableName, templateType) {
|
||||
const response = await fetch(`/api/templates/${tableName}/validate?type=${templateType}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板预览
|
||||
* @param {string} tableName - 表名
|
||||
* @param {string} templateType - 模板类型
|
||||
*/
|
||||
async getTemplatePreview(tableName, templateType) {
|
||||
const response = await fetch(`/api/templates/${tableName}/preview?type=${templateType}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch template preview');
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局API客户端实例
|
||||
window.apiClient = new APIClient();
|
||||
233
web/static/js/performance-monitor.js
Normal file
233
web/static/js/performance-monitor.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 性能监控器 - 监控和优化前端性能
|
||||
*/
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.metrics = new Map();
|
||||
this.observers = new Set();
|
||||
this.isEnabled = true;
|
||||
this.threshold = {
|
||||
renderTime: 1000, // 渲染时间阈值
|
||||
loadTime: 2000, // 加载时间阈值
|
||||
memoryUsage: 100 * 1024 * 1024 // 内存使用阈值
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化性能监控
|
||||
*/
|
||||
init() {
|
||||
if (typeof window !== 'undefined' && window.performance) {
|
||||
this.setupPerformanceObserver();
|
||||
this.setupMemoryMonitor();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置性能观察器
|
||||
*/
|
||||
setupPerformanceObserver() {
|
||||
if ('PerformanceObserver' in window) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
this.recordMetric(entry.name, {
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime,
|
||||
type: entry.entryType
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['measure', 'navigation', 'resource'] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置内存监控
|
||||
*/
|
||||
setupMemoryMonitor() {
|
||||
if ('memory' in performance) {
|
||||
setInterval(() => {
|
||||
const memory = performance.memory;
|
||||
this.recordMetric('memory', {
|
||||
used: memory.usedJSHeapSize,
|
||||
total: memory.totalJSHeapSize,
|
||||
limit: memory.jsHeapSizeLimit,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 检查内存使用是否超过阈值
|
||||
if (memory.usedJSHeapSize > this.threshold.memoryUsage) {
|
||||
this.triggerAlert('high_memory_usage', {
|
||||
used: memory.usedJSHeapSize,
|
||||
limit: this.threshold.memoryUsage
|
||||
});
|
||||
}
|
||||
}, 10000); // 每10秒检查一次
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录性能指标
|
||||
* @param {string} name - 指标名称
|
||||
* @param {Object} data - 指标数据
|
||||
*/
|
||||
recordMetric(name, data) {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
|
||||
const records = this.metrics.get(name);
|
||||
records.push({
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 限制记录数量
|
||||
if (records.length > 100) {
|
||||
records.shift();
|
||||
}
|
||||
|
||||
// 触发观察者通知
|
||||
this.notifyObservers(name, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始性能计时
|
||||
* @param {string} name - 计时器名称
|
||||
*/
|
||||
startTimer(name) {
|
||||
if (typeof window !== 'undefined' && window.performance) {
|
||||
performance.mark(`${name}_start`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束性能计时
|
||||
* @param {string} name - 计时器名称
|
||||
*/
|
||||
endTimer(name) {
|
||||
if (typeof window !== 'undefined' && window.performance) {
|
||||
performance.mark(`${name}_end`);
|
||||
performance.measure(name, `${name}_start`, `${name}_end`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能指标
|
||||
* @param {string} name - 指标名称
|
||||
*/
|
||||
getMetric(name) {
|
||||
return this.metrics.get(name) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平均渲染时间
|
||||
*/
|
||||
getAverageRenderTime() {
|
||||
const renders = this.getMetric('template-render');
|
||||
if (renders.length === 0) return 0;
|
||||
|
||||
const total = renders.reduce((sum, r) => sum + r.duration, 0);
|
||||
return total / renders.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加性能观察者
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
addObserver(callback) {
|
||||
this.observers.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除性能观察者
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
removeObserver(callback) {
|
||||
this.observers.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知观察者
|
||||
* @param {string} name - 指标名称
|
||||
* @param {Object} data - 指标数据
|
||||
*/
|
||||
notifyObservers(name, data) {
|
||||
this.observers.forEach(callback => {
|
||||
try {
|
||||
callback(name, data);
|
||||
} catch (error) {
|
||||
console.error('Performance observer error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发性能警报
|
||||
* @param {string} type - 警报类型
|
||||
* @param {Object} data - 警报数据
|
||||
*/
|
||||
triggerAlert(type, data) {
|
||||
console.warn(`Performance Alert: ${type}`, data);
|
||||
|
||||
// 可以在这里集成错误报告服务
|
||||
if (window.console && window.console.warn) {
|
||||
console.warn(`[Performance] ${type}:`, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能报告
|
||||
*/
|
||||
getReport() {
|
||||
const report = {
|
||||
timestamp: Date.now(),
|
||||
metrics: {},
|
||||
summary: {
|
||||
totalRequests: 0,
|
||||
averageRenderTime: this.getAverageRenderTime(),
|
||||
cacheHitRate: 0
|
||||
}
|
||||
};
|
||||
|
||||
// 汇总指标
|
||||
for (const [name, records] of this.metrics) {
|
||||
report.metrics[name] = {
|
||||
count: records.length,
|
||||
average: records.reduce((sum, r) => sum + (r.duration || 0), 0) / records.length,
|
||||
min: Math.min(...records.map(r => r.duration || 0)),
|
||||
max: Math.max(...records.map(r => r.duration || 0)),
|
||||
last: records[records.length - 1]
|
||||
};
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空性能数据
|
||||
*/
|
||||
clear() {
|
||||
this.metrics.clear();
|
||||
this.observers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用性能监控
|
||||
* @param {boolean} enabled - 是否启用
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this.isEnabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局性能监控器实例
|
||||
window.performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// 集成到模板引擎和API客户端中
|
||||
// 在template-engine.js和api-client.js中使用performanceMonitor.startTimer/endTimer
|
||||
460
web/static/js/template-engine.js
Normal file
460
web/static/js/template-engine.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 模板引擎 - 动态模板渲染系统
|
||||
*/
|
||||
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();
|
||||
515
web/static/js/ui-controller.js
Normal file
515
web/static/js/ui-controller.js
Normal file
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* UI控制器 - 管理模板切换和用户界面交互
|
||||
*/
|
||||
class UIController {
|
||||
constructor() {
|
||||
this.currentTable = null;
|
||||
this.currentTemplate = 'list';
|
||||
this.availableTemplates = [];
|
||||
this.isLoading = false;
|
||||
this.searchTimeout = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化UI控制器
|
||||
*/
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.setupKeyboardShortcuts();
|
||||
this.setupResponsiveUI();
|
||||
this.loadFromURL();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件监听器
|
||||
*/
|
||||
bindEvents() {
|
||||
// 表选择器
|
||||
const tableSelector = document.getElementById('tableSelector');
|
||||
if (tableSelector) {
|
||||
tableSelector.addEventListener('change', (e) => {
|
||||
this.changeTable(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索框
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('pagination-link')) {
|
||||
e.preventDefault();
|
||||
const page = parseInt(e.target.dataset.page);
|
||||
this.goToPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
// 排序
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('sort-header')) {
|
||||
e.preventDefault();
|
||||
const field = e.target.dataset.field;
|
||||
this.toggleSort(field);
|
||||
}
|
||||
});
|
||||
|
||||
// 模板切换
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('template-switch')) {
|
||||
e.preventDefault();
|
||||
const template = e.target.dataset.template;
|
||||
this.switchTemplate(template);
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新按钮
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.refreshData();
|
||||
});
|
||||
}
|
||||
|
||||
// 浏览器历史记录
|
||||
window.addEventListener('popstate', () => {
|
||||
this.loadFromURL();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置键盘快捷键
|
||||
*/
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl/Cmd + R: 刷新
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
this.refreshData();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + K: 聚焦搜索
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) searchInput.focus();
|
||||
}
|
||||
|
||||
// Escape: 关闭详情弹窗
|
||||
if (e.key === 'Escape') {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置响应式UI
|
||||
*/
|
||||
setupResponsiveUI() {
|
||||
// 监听窗口大小变化
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.adjustUILayout();
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整UI布局
|
||||
*/
|
||||
adjustUILayout() {
|
||||
const width = window.innerWidth;
|
||||
const container = document.getElementById('mainContent');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (width < 768) {
|
||||
container.classList.add('mobile-layout');
|
||||
container.classList.remove('desktop-layout');
|
||||
} else {
|
||||
container.classList.add('desktop-layout');
|
||||
container.classList.remove('mobile-layout');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL加载配置
|
||||
*/
|
||||
loadFromURL() {
|
||||
const url = new URL(window.location);
|
||||
const table = url.searchParams.get('table');
|
||||
const template = url.searchParams.get('template') || 'list';
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
|
||||
if (table) {
|
||||
this.currentTable = table;
|
||||
this.currentTemplate = template;
|
||||
this.loadTableData(table, { page, search, template });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换数据表
|
||||
* @param {string} tableName - 表名
|
||||
*/
|
||||
async changeTable(tableName) {
|
||||
if (this.isLoading) return;
|
||||
|
||||
this.currentTable = tableName;
|
||||
this.currentTemplate = 'list'; // 重置为默认模板
|
||||
|
||||
try {
|
||||
// 并行获取可用模板和预加载模板
|
||||
const [templates] = await Promise.all([
|
||||
apiClient.getAvailableTemplates(tableName),
|
||||
templateEngine.preloadTemplates(tableName, ['list', 'card'])
|
||||
]);
|
||||
|
||||
this.availableTemplates = templates.templates;
|
||||
this.renderTemplateSelector();
|
||||
|
||||
// 加载数据
|
||||
await this.loadTableData(tableName);
|
||||
|
||||
// 更新URL
|
||||
this.updateURL();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to change table:', error);
|
||||
this.showError('切换数据表失败:' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换模板
|
||||
* @param {string} templateName - 模板名称
|
||||
*/
|
||||
async switchTemplate(templateName) {
|
||||
if (this.isLoading || !this.currentTable) return;
|
||||
|
||||
this.currentTemplate = templateName;
|
||||
|
||||
try {
|
||||
await templateEngine.switchTemplate(templateName);
|
||||
this.updateURL();
|
||||
this.updateTemplateSelector(templateName);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to switch template:', error);
|
||||
this.showError('切换模板失败:' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载数据表数据
|
||||
* @param {string} tableName - 表名
|
||||
* @param {Object} options - 选项
|
||||
*/
|
||||
async loadTableData(tableName, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
search = '',
|
||||
sort = '',
|
||||
order = 'desc',
|
||||
template = this.currentTemplate
|
||||
} = options;
|
||||
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
const rendered = await templateEngine.renderTable(tableName, {
|
||||
page,
|
||||
perPage,
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
template
|
||||
});
|
||||
|
||||
this.updateContent(rendered);
|
||||
this.renderPagination({ page, perPage, total: templateEngine.currentData?.total || 0 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load table data:', error);
|
||||
this.showError('加载数据失败:' + error.message);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板选择器
|
||||
*/
|
||||
renderTemplateSelector() {
|
||||
const container = document.getElementById('templateSelector');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-600">视图:</span>
|
||||
${this.availableTemplates.map(template => `
|
||||
<button
|
||||
class="template-switch px-3 py-1 text-sm rounded ${
|
||||
template === this.currentTemplate
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}"
|
||||
data-template="${template}"
|
||||
>
|
||||
${this.getTemplateDisplayName(template)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板选择器状态
|
||||
* @param {string} activeTemplate - 当前激活的模板
|
||||
*/
|
||||
updateTemplateSelector(activeTemplate) {
|
||||
const buttons = document.querySelectorAll('.template-switch');
|
||||
buttons.forEach(btn => {
|
||||
if (btn.dataset.template === activeTemplate) {
|
||||
btn.className = 'template-switch px-3 py-1 text-sm rounded bg-blue-500 text-white';
|
||||
} else {
|
||||
btn.className = 'template-switch px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板显示名称
|
||||
* @param {string} template - 模板名称
|
||||
*/
|
||||
getTemplateDisplayName(template) {
|
||||
const names = {
|
||||
'list': '列表',
|
||||
'card': '卡片',
|
||||
'timeline': '时间轴'
|
||||
};
|
||||
return names[template] || template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染分页
|
||||
* @param {Object} pagination - 分页信息
|
||||
*/
|
||||
renderPagination({ page, perPage, total }) {
|
||||
const container = document.getElementById('pagination');
|
||||
if (!container) return;
|
||||
|
||||
const pages = Math.ceil(total / perPage);
|
||||
const startPage = Math.max(1, page - 2);
|
||||
const endPage = Math.min(pages, page + 2);
|
||||
|
||||
let html = `
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700">
|
||||
共 ${total} 条记录,第 ${page} / ${pages} 页
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
`;
|
||||
|
||||
if (page > 1) {
|
||||
html += `
|
||||
<button class="pagination-link px-3 py-1 text-sm border rounded hover:bg-gray-50" data-page="${page - 1}">
|
||||
上一页
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
html += `
|
||||
<button class="pagination-link px-3 py-1 text-sm border rounded ${
|
||||
i === page ? 'bg-blue-500 text-white' : 'hover:bg-gray-50'
|
||||
}" data-page="${i}">
|
||||
${i}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (page < pages) {
|
||||
html += `
|
||||
<button class="pagination-link px-3 py-1 text-sm border rounded hover:bg-gray-50" data-page="${page + 1}">
|
||||
下一页
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
* @param {string} query - 搜索关键词
|
||||
*/
|
||||
handleSearch(query) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
if (this.currentTable) {
|
||||
this.loadTableData(this.currentTable, { search: query, page: 1 });
|
||||
this.updateURL();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定页面
|
||||
* @param {number} page - 页码
|
||||
*/
|
||||
goToPage(page) {
|
||||
if (this.currentTable) {
|
||||
this.loadTableData(this.currentTable, { page });
|
||||
this.updateURL();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换排序
|
||||
* @param {string} field - 排序字段
|
||||
*/
|
||||
toggleSort(field) {
|
||||
const url = new URL(window.location);
|
||||
const currentSort = url.searchParams.get('sort');
|
||||
const currentOrder = url.searchParams.get('order') || 'asc';
|
||||
|
||||
let newOrder = 'asc';
|
||||
if (currentSort === field && currentOrder === 'asc') {
|
||||
newOrder = 'desc';
|
||||
}
|
||||
|
||||
if (this.currentTable) {
|
||||
this.loadTableData(this.currentTable, {
|
||||
sort: field,
|
||||
order: newOrder,
|
||||
page: 1
|
||||
});
|
||||
this.updateURL();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
async refreshData() {
|
||||
if (this.currentTable) {
|
||||
templateEngine.clearCache();
|
||||
await this.loadTableData(this.currentTable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新URL参数
|
||||
*/
|
||||
updateURL() {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('table', this.currentTable);
|
||||
url.searchParams.set('template', this.currentTemplate);
|
||||
|
||||
const currentOptions = this.getCurrentOptions();
|
||||
if (currentOptions.page > 1) {
|
||||
url.searchParams.set('page', currentOptions.page);
|
||||
} else {
|
||||
url.searchParams.delete('page');
|
||||
}
|
||||
|
||||
if (currentOptions.search) {
|
||||
url.searchParams.set('search', currentOptions.search);
|
||||
} else {
|
||||
url.searchParams.delete('search');
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示加载状态
|
||||
*/
|
||||
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 - 错误消息
|
||||
*/
|
||||
showError(message) {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭模态框
|
||||
*/
|
||||
closeModal() {
|
||||
const modal = document.getElementById('detailModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局UI控制器实例
|
||||
window.uiController = new UIController();
|
||||
53
web/templates/builtin/complete.html
Normal file
53
web/templates/builtin/complete.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.TableAlias}} - 数据管理</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">{{.TableAlias}}</h1>
|
||||
|
||||
{{if gt (len .Data) 0}}
|
||||
<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>
|
||||
{{range .Columns}}
|
||||
{{if .ShowInList}}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
{{.Alias}}
|
||||
</th>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{range $row := .Data}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
{{range $col := $.Columns}}
|
||||
{{if $col.ShowInList}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{index $row $col.Name}}
|
||||
</td>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-gray-500">暂无数据</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="mt-4 text-sm text-gray-600">
|
||||
总计: {{.Total}} 条记录
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
27
web/templates/builtin/debug.html
Normal file
27
web/templates/builtin/debug.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<h1>Debug Information</h1>
|
||||
<pre>Table: {{.Table}}
|
||||
TableAlias: {{.TableAlias}}
|
||||
Total: {{.Total}}
|
||||
Page: {{.Page}}
|
||||
Pages: {{.Pages}}
|
||||
Columns: {{json .Columns}}
|
||||
Data Type: {{printf "%T" .Data}}
|
||||
Data Length: {{len .Data}}
|
||||
First Row: {{json (index .Data 0)}}
|
||||
</pre>
|
||||
<div>
|
||||
<h2>Columns</h2>
|
||||
<ul>
|
||||
{{range .Columns}}
|
||||
<li>{{.Name}} ({{.Alias}}) - Show: {{.ShowInList}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Data</h2>
|
||||
{{range $i, $row := .Data}}
|
||||
<div>Row {{$i}}: {{json $row}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
3
web/templates/builtin/field/category.html
Normal file
3
web/templates/builtin/field/category.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{{define "field-category"}}
|
||||
<span class="text-gray-900">{{.Value}}</span>
|
||||
{{end}}
|
||||
9
web/templates/builtin/field/markdown.html
Normal file
9
web/templates/builtin/field/markdown.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{define "field-markdown"}}
|
||||
{{if .Value}}
|
||||
<div class="prose prose-sm max-w-none">
|
||||
{{.Value}}
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="text-gray-400">-</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
3
web/templates/builtin/field/raw.html
Normal file
3
web/templates/builtin/field/raw.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{{define "field-raw"}}
|
||||
<span class="text-gray-900">{{.Value}}</span>
|
||||
{{end}}
|
||||
14
web/templates/builtin/field/tag.html
Normal file
14
web/templates/builtin/field/tag.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{{define "field-tag"}}
|
||||
{{if .Value}}
|
||||
{{$tag := index .Column.values .Value}}
|
||||
{{if $tag}}
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full" style="background-color: {{$tag.color}}; color: white;">
|
||||
{{$tag.label}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="text-gray-900">{{.Value}}</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="text-gray-400">-</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
17
web/templates/builtin/field/text.html
Normal file
17
web/templates/builtin/field/text.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{{define "field-text"}}
|
||||
<span class="text-gray-900">{{.Value}}</span>
|
||||
{{end}}
|
||||
|
||||
{{define "field"}}
|
||||
{{if eq .Type "time"}}
|
||||
{{template "field-time" .}}
|
||||
{{else if eq .Type "tag"}}
|
||||
{{template "field-tag" .}}
|
||||
{{else if eq .Type "markdown"}}
|
||||
{{template "field-markdown" .}}
|
||||
{{else if eq .Type "raw"}}
|
||||
{{template "field-raw" .}}
|
||||
{{else}}
|
||||
{{template "field-text" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
9
web/templates/builtin/field/time.html
Normal file
9
web/templates/builtin/field/time.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{define "field-time"}}
|
||||
<span class="text-gray-900">
|
||||
{{if .Value}}
|
||||
{{.Value}}
|
||||
{{else}}
|
||||
-
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
162
web/templates/builtin/layout.html
Normal file
162
web/templates/builtin/layout.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - 数据管理</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
.tag {
|
||||
@apply inline-block px-2 py-1 text-xs font-medium rounded-full;
|
||||
}
|
||||
.modal {
|
||||
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden;
|
||||
}
|
||||
.modal-content {
|
||||
@apply bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
.loading {
|
||||
@apply animate-pulse bg-gray-200 rounded;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 表选择器 -->
|
||||
<div class="mb-6">
|
||||
<select id="tableSelector" class="border rounded px-3 py-2" onchange="changeTable(this.value)">
|
||||
<option value="">选择数据表...</option>
|
||||
{{range .Tables}}
|
||||
<option value="{{.Alias}}" {{if eq .Alias $.CurrentTable}}selected{{end}}>
|
||||
{{.Alias}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div id="mainContent">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<div id="detailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium mb-4">详情信息</h3>
|
||||
<div id="detailContent"></div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button onclick="closeModal()" class="px-4 py-2 bg-gray-300 rounded">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通用JavaScript -->
|
||||
<script>
|
||||
// 全局配置
|
||||
const CONFIG = {
|
||||
apiBase: '/api',
|
||||
templates: {
|
||||
builtin: '/templates/builtin',
|
||||
custom: '/templates/custom'
|
||||
}
|
||||
};
|
||||
|
||||
// 模板系统核心
|
||||
class TemplateSystem {
|
||||
static async loadTemplate(path) {
|
||||
const response = await fetch(path);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
static async render(data, templateName, tableName) {
|
||||
// 优先使用自定义模板,回退到默认模板
|
||||
const customPath = `${CONFIG.templates.custom}/${tableName}/${templateName}.html`;
|
||||
const defaultPath = `${CONFIG.templates.builtin}/${templateName}.html`;
|
||||
|
||||
try {
|
||||
return await this.loadTemplate(customPath);
|
||||
} catch {
|
||||
return await this.loadTemplate(defaultPath);
|
||||
}
|
||||
}
|
||||
|
||||
static async renderField(value, type, column) {
|
||||
const fieldMap = {
|
||||
'raw': v => v,
|
||||
'time': v => new Date(v).toLocaleString('zh-CN'),
|
||||
'tag': (v, col) => {
|
||||
const tag = col.values[v];
|
||||
return tag ? `<span class="tag" style="background-color: ${tag.color}; color: white;">${tag.label}</span>` : v;
|
||||
},
|
||||
'markdown': v => marked.parse(v || ''),
|
||||
'category': v => v
|
||||
};
|
||||
|
||||
return fieldMap[type]?.(value, column) || value;
|
||||
}
|
||||
}
|
||||
|
||||
// 数据加载器
|
||||
class DataLoader {
|
||||
static async loadTableData(tableAlias, page = 1, perPage = 20, search = '', sort = '', order = 'desc') {
|
||||
const params = new URLSearchParams({
|
||||
page,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort,
|
||||
order
|
||||
});
|
||||
|
||||
const response = await fetch(`${CONFIG.apiBase}/data/${tableAlias}?${params}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static async loadTableDetail(tableAlias, id) {
|
||||
const response = await fetch(`${CONFIG.apiBase}/data/${tableAlias}/detail/${id}`);
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// 通用函数
|
||||
function changeTable(tableAlias) {
|
||||
if (!tableAlias) return;
|
||||
window.location.href = `/?table=${tableAlias}&page=1`;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
document.getElementById('mainContent').innerHTML =
|
||||
'<div class="text-center py-8"><div class="loading h-4 w-32 mx-auto"></div></div>';
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 绑定全局事件
|
||||
window.closeModal = closeModal;
|
||||
window.changeTable = changeTable;
|
||||
window.TemplateSystem = TemplateSystem;
|
||||
window.DataLoader = DataLoader;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 页面特定内容占位符 -->
|
||||
{{template "scripts" .}}
|
||||
|
||||
<!-- 性能监控JS -->
|
||||
<script src="/static/js/performance-monitor.js"></script>
|
||||
<script src="/static/js/api-client.js"></script>
|
||||
<script src="/static/js/template-engine.js"></script>
|
||||
<script src="/static/js/ui-controller.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
128
web/templates/builtin/list.html
Normal file
128
web/templates/builtin/list.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{{define "content"}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
{{if .Data}}
|
||||
{{if eq .TemplateType "table"}}
|
||||
{{template "table-view" .}}
|
||||
{{else if eq .TemplateType "card"}}
|
||||
{{template "card-view" .}}
|
||||
{{else}}
|
||||
{{template "table-view" .}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
暂无数据
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700">
|
||||
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{{if gt .Page 1}}
|
||||
<a href="?table={{.Table}}&page={{sub .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
上一页
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{range until .Pages}}
|
||||
{{$page := add . 1}}
|
||||
{{if eq $page $.Page}}
|
||||
<span class="px-3 py-1 bg-blue-500 text-white rounded text-sm">{{$page}}</span>
|
||||
{{else}}
|
||||
<a href="?table={{$.Table}}&page={{$page}}&per_page={{$.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
{{$page}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if lt .Page .Pages}}
|
||||
<a href="?table={{$.Table}}&page={{add $.Page 1}}&per_page={{$.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
下一页
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "table-view"}}
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{{range .Columns}}
|
||||
{{if .ShowInList}}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{.Alias}}
|
||||
{{if .Sortable}}
|
||||
<button onclick="sortBy('{{.Name}}')" class="ml-1 text-gray-400 hover:text-gray-600">
|
||||
⇅
|
||||
</button>
|
||||
{{end}}
|
||||
</th>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<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">
|
||||
{{range $row := .Data}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
{{range $col := $.Columns}}
|
||||
{{if $col.ShowInList}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}}
|
||||
</td>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button onclick="showDetail('{{index $row "id"}}')"
|
||||
class="text-blue-600 hover:text-blue-900">
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{define "card-view"}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||
{{range $row := .Data}}
|
||||
<div class="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
{{range $col := $.Columns}}
|
||||
{{if $col.ShowInList}}
|
||||
<div class="mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">{{$col.Alias}}</label>
|
||||
<div class="mt-1 text-sm text-gray-900">
|
||||
{{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="mt-4">
|
||||
<button onclick="showDetail('{{index $row "id"}}')"
|
||||
class="text-blue-600 hover:text-blue-900 text-sm">
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<!-- 列表页面特定脚本 -->
|
||||
<script>
|
||||
// 列表页面特定功能可以在这里添加
|
||||
console.log('List template loaded for:', '{{.Table}}');
|
||||
</script>
|
||||
{{end}}
|
||||
33
web/templates/builtin/simple.html
Normal file
33
web/templates/builtin/simple.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "content"}}
|
||||
<div class="p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">{{.TableAlias}} - 简易视图</h1>
|
||||
<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>
|
||||
{{range .Columns}}
|
||||
{{if .ShowInList}}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{.Alias}}
|
||||
</th>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{range $row := .Data}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
{{range $col := $.Columns}}
|
||||
{{if $col.ShowInList}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{index $row $col.Name}}
|
||||
</td>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
46
web/templates/custom/_default/card.html
Normal file
46
web/templates/custom/_default/card.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{{define "content"}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||
{{range .Data}}
|
||||
<div class="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
{{range $.Columns}}
|
||||
{{if .ShowInList}}
|
||||
<div class="mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">{{.Alias}}</label>
|
||||
<div class="mt-1 text-sm text-gray-900">
|
||||
{{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<div class="mt-4">
|
||||
<button onclick="showDetail('{{.id}}')"
|
||||
class="text-blue-600 hover:text-blue-900 text-sm">
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700">
|
||||
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{{if gt .Page 1}}
|
||||
<a href="?table={{.CurrentTable}}&page={{sub .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
上一页
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if lt .Page .Pages}}
|
||||
<a href="?table={{.CurrentTable}}&page={{add .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
下一页
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
67
web/templates/custom/_default/list.html
Normal file
67
web/templates/custom/_default/list.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{{define "content"}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
{{if .Data}}
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{{range .Columns}}
|
||||
{{if .ShowInList}}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{.Alias}}
|
||||
</th>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<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">
|
||||
{{range .Data}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
{{range $.Columns}}
|
||||
{{if .ShowInList}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}}
|
||||
</td>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button onclick="showDetail('{{.id}}')"
|
||||
class="text-blue-600 hover:text-blue-900">
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
暂无数据
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700">
|
||||
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{{if gt .Page 1}}
|
||||
<a href="?table={{.CurrentTable}}&page={{sub .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
上一页
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if lt .Page .Pages}}
|
||||
<a href="?table={{.CurrentTable}}&page={{add .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
|
||||
下一页
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user