Web 端数据交互与 design2 布局修复
统一门户 JSON 契约:新增 GET /templates/{id},模板/云文件/保存/打开走虚拟 key;
layer 保存弹层替代 prompt,本地 .soon 已登录后台 POST 登记;修复保存静默失败与 design2 全宽布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var Form = function () { return window.AdminForm; };
|
||||
|
||||
var MODAL_OPTS = { area: ['480px', 'auto'], skin: 'soon-layer soon-layer--tpl', maxWidth: '94vw' };
|
||||
|
||||
function activeBadge(v) {
|
||||
var on = v === true || parseInt(v, 10) === 1;
|
||||
return I().badgeStatus(on ? 'active' : 'disabled');
|
||||
@@ -23,6 +25,99 @@
|
||||
return (n / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function getAdminBase() {
|
||||
return ((window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_admin_base) || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function revokeBlob(state) {
|
||||
if (state && state._blobUrl) {
|
||||
try { URL.revokeObjectURL(state._blobUrl); } catch (e) { /* ignore */ }
|
||||
state._blobUrl = '';
|
||||
}
|
||||
}
|
||||
|
||||
function nameFromFileName(fileName) {
|
||||
return String(fileName || '').replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function parseSoonMeta(text, fileName) {
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error('invalid_json');
|
||||
}
|
||||
if (!data || typeof data !== 'object') throw new Error('invalid_json');
|
||||
|
||||
var type = 1;
|
||||
if (Number(data.soonType) === 2 || data.backBlackPic) type = 2;
|
||||
|
||||
var thumbRaw = '';
|
||||
if (data.frontDisplayPic && typeof data.frontDisplayPic === 'string') {
|
||||
var candidate = data.frontDisplayPic.trim();
|
||||
if (candidate.indexOf('data:image/') === 0) thumbRaw = candidate;
|
||||
}
|
||||
|
||||
var name = '';
|
||||
if (typeof data.name === 'string' && data.name.trim()) name = data.name.trim();
|
||||
else if (typeof data.title === 'string' && data.title.trim()) name = data.title.trim();
|
||||
else name = nameFromFileName(fileName);
|
||||
if (!name) name = '未命名模板';
|
||||
|
||||
return { name: name, type: type, thumbRaw: thumbRaw, content: text };
|
||||
}
|
||||
|
||||
function shrinkDataUrl(dataUrl, maxW, quality, done) {
|
||||
if (!dataUrl) {
|
||||
done('');
|
||||
return;
|
||||
}
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
var w = img.naturalWidth || img.width;
|
||||
var h = img.naturalHeight || img.height;
|
||||
if (!w || !h) {
|
||||
done(dataUrl);
|
||||
return;
|
||||
}
|
||||
if (w <= maxW) {
|
||||
done(dataUrl);
|
||||
return;
|
||||
}
|
||||
var nh = Math.max(1, Math.round(h * (maxW / w)));
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = maxW;
|
||||
canvas.height = nh;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, maxW, nh);
|
||||
try {
|
||||
done(canvas.toDataURL('image/jpeg', quality));
|
||||
} catch (e) {
|
||||
done(dataUrl);
|
||||
}
|
||||
};
|
||||
img.onerror = function () { done(''); };
|
||||
img.src = dataUrl;
|
||||
}
|
||||
|
||||
function setPreviewImage(modalState, src, renderPreview) {
|
||||
modalState.thumbView = src || '';
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function applyThumbRaw(modalState, thumbRaw, renderPreview) {
|
||||
if (!thumbRaw) {
|
||||
setPreviewImage(modalState, '', renderPreview);
|
||||
return;
|
||||
}
|
||||
if (thumbRaw.length < 700000) {
|
||||
setPreviewImage(modalState, thumbRaw, renderPreview);
|
||||
return;
|
||||
}
|
||||
shrinkDataUrl(thumbRaw, 360, 0.82, function (small) {
|
||||
setPreviewImage(modalState, small || thumbRaw, renderPreview);
|
||||
});
|
||||
}
|
||||
|
||||
function readFileText(input) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var file = input && input.files && input.files[0];
|
||||
@@ -31,106 +126,195 @@
|
||||
return;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () { resolve(String(reader.result || '')); };
|
||||
reader.onload = function () { resolve({ text: String(reader.result || ''), fileName: file.name }); };
|
||||
reader.onerror = function () { reject(new Error('read_failed')); };
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
});
|
||||
}
|
||||
|
||||
function defaultNameFromFile(input) {
|
||||
var file = input && input.files && input.files[0];
|
||||
if (!file) return '';
|
||||
return file.name.replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim();
|
||||
function fetchAdminThumb(plan, modalState, renderPreview) {
|
||||
if (!plan || !plan.id || !Api().getToken()) {
|
||||
renderPreview();
|
||||
return;
|
||||
}
|
||||
revokeBlob(modalState);
|
||||
window.fetch(getAdminBase() + '/templates/' + plan.id + '/thumb?_=' + Date.now(), {
|
||||
headers: { Authorization: 'Bearer ' + Api().getToken() },
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error('thumb');
|
||||
return r.blob();
|
||||
}).then(function (blob) {
|
||||
modalState._blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewImage(modalState, modalState._blobUrl, renderPreview);
|
||||
}).catch(function () {
|
||||
renderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFields(required) {
|
||||
return Form().panel('模板文件',
|
||||
Form().field('选择 .soon 文件',
|
||||
'<input type="file" id="tplFile" class="soon-input soon-input--sm" accept=".soon,application/json">' +
|
||||
(required ? '' : '<p class="soon-admin-form-hint soon-admin-form-hint--tight">留空则不替换已有文件</p>'),
|
||||
required ? '须为有效的 .soon JSON 文件' : '')
|
||||
);
|
||||
function openThumbPreview(src, title) {
|
||||
if (!src || typeof layer === 'undefined') return;
|
||||
layer.photos({
|
||||
photos: {
|
||||
title: title || '模板预览',
|
||||
start: 0,
|
||||
data: [{ alt: title || '模板预览', src: src }],
|
||||
},
|
||||
anim: 5,
|
||||
shade: 0.88,
|
||||
});
|
||||
}
|
||||
|
||||
function metaFields(plan) {
|
||||
plan = plan || {};
|
||||
return Form().panel('展示设置',
|
||||
Form().field('名称', Form().input('tplName', { value: plan.name || '' })) +
|
||||
'<div class="soon-admin-form-grid">' +
|
||||
Form().field('排序', Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 })) +
|
||||
Form().field('类型', '<input class="soon-input soon-input--sm" value="' + I().esc(typeLabel(plan.type)) + '" disabled>') +
|
||||
'</div>' +
|
||||
'<label class="soon-admin-check"><input type="checkbox" id="tplActive"' +
|
||||
(plan.is_active !== false ? ' checked' : '') + '> 上架展示</label>'
|
||||
);
|
||||
function modalHtml(opts) {
|
||||
opts = opts || {};
|
||||
var isEdit = opts.mode === 'edit';
|
||||
var plan = opts.plan || {};
|
||||
var pickLabel = isEdit ? '替换文件' : '选择文件';
|
||||
|
||||
return '<div class="soon-admin-tpl-modal">' +
|
||||
'<button type="button" id="tplThumbBtn" class="soon-admin-tpl-card" disabled aria-label="预览缩略图">' +
|
||||
'<span class="soon-admin-tpl-card__media">' +
|
||||
'<img id="tplThumb" alt="" hidden>' +
|
||||
'<span id="tplThumbEmpty" class="soon-admin-tpl-card__placeholder">选择 .soon 后显示预览</span>' +
|
||||
'<span id="tplThumbHint" class="soon-admin-tpl-card__hint" hidden>点击放大</span></span>' +
|
||||
'<span class="soon-admin-tpl-card__info">' +
|
||||
'<span id="tplNameDisp" class="soon-admin-tpl-card__name">' + I().esc(isEdit ? (plan.name || '—') : '—') + '</span>' +
|
||||
'<span id="tplTypeDisp" class="soon-admin-tpl-card__type">' +
|
||||
(isEdit ? I().esc(typeLabel(plan.type)) : '—') + '</span></span></button>' +
|
||||
'<label class="soon-admin-tpl-picker">' +
|
||||
'<input type="file" id="tplFile" class="soon-admin-tpl-picker__input" accept=".soon,application/json">' +
|
||||
'<span class="soon-btn soon-btn--sm soon-btn--ghost">' + I().esc(pickLabel) + '</span>' +
|
||||
'<span id="tplFileLabel" class="soon-admin-tpl-picker__name">' +
|
||||
(isEdit ? I().esc(plan.name + '.soon') : '未选择') + '</span></label>' +
|
||||
'<div class="soon-admin-tpl-options">' +
|
||||
'<label class="soon-admin-tpl-option"><span>排序</span>' +
|
||||
Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 }) + '</label>' +
|
||||
'<label class="soon-admin-tpl-option soon-admin-tpl-option--check">' +
|
||||
'<input type="checkbox" id="tplActive"' + (plan.is_active !== false ? ' checked' : '') + '>' +
|
||||
'<span>首页上架</span></label></div>' +
|
||||
'<div class="soon-admin-tpl-modal__actions">' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">' +
|
||||
(isEdit ? '保存' : '上传') + '</button></div></div>';
|
||||
}
|
||||
|
||||
function bindTplModal(opts, modalState) {
|
||||
var fileEl = Ui().el('tplFile');
|
||||
var thumbBtn = Ui().el('tplThumbBtn');
|
||||
var thumbEl = Ui().el('tplThumb');
|
||||
var thumbEmptyEl = Ui().el('tplThumbEmpty');
|
||||
var thumbHintEl = Ui().el('tplThumbHint');
|
||||
var nameEl = Ui().el('tplNameDisp');
|
||||
var typeEl = Ui().el('tplTypeDisp');
|
||||
var labelEl = Ui().el('tplFileLabel');
|
||||
var plan = opts.plan;
|
||||
|
||||
function renderPreview() {
|
||||
var hasMeta = !!(modalState.name || modalState.type);
|
||||
var src = modalState.thumbView || '';
|
||||
nameEl.textContent = modalState.name || '—';
|
||||
typeEl.textContent = hasMeta ? typeLabel(modalState.type) : '—';
|
||||
|
||||
if (src) {
|
||||
thumbEl.src = src;
|
||||
thumbEl.hidden = false;
|
||||
thumbEmptyEl.hidden = true;
|
||||
thumbHintEl.hidden = false;
|
||||
thumbBtn.disabled = false;
|
||||
thumbBtn.classList.add('is-ready');
|
||||
} else {
|
||||
thumbEl.hidden = true;
|
||||
thumbEl.removeAttribute('src');
|
||||
thumbEmptyEl.hidden = false;
|
||||
thumbEmptyEl.textContent = hasMeta ? '无缩略图' : '选择 .soon 后显示预览';
|
||||
thumbHintEl.hidden = true;
|
||||
thumbBtn.disabled = true;
|
||||
thumbBtn.classList.remove('is-ready');
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbBtn) {
|
||||
thumbBtn.onclick = function () {
|
||||
if (!modalState.thumbView) return;
|
||||
openThumbPreview(modalState.thumbView, modalState.name);
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.mode === 'edit' && plan) {
|
||||
modalState.name = plan.name;
|
||||
modalState.type = plan.type;
|
||||
modalState.thumbView = '';
|
||||
modalState.content = null;
|
||||
renderPreview();
|
||||
fetchAdminThumb(plan, modalState, renderPreview);
|
||||
}
|
||||
|
||||
if (!fileEl) return;
|
||||
|
||||
fileEl.onchange = function () {
|
||||
var file = fileEl.files && fileEl.files[0];
|
||||
if (!file) return;
|
||||
labelEl.textContent = file.name;
|
||||
readFileText(fileEl).then(function (payload) {
|
||||
var meta = parseSoonMeta(payload.text, payload.fileName);
|
||||
modalState.name = meta.name;
|
||||
modalState.type = meta.type;
|
||||
modalState.content = meta.content;
|
||||
applyThumbRaw(modalState, meta.thumbRaw, renderPreview);
|
||||
}).catch(function (e) {
|
||||
fileEl.value = '';
|
||||
labelEl.textContent = opts.mode === 'edit' ? (plan.name + '.soon') : '未选择';
|
||||
if (e && e.message === 'invalid_json') soonToast('.soon 须为有效 JSON', 'warn');
|
||||
else soonToast('文件读取失败', 'error');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
var form = '<div class="soon-admin-form">' + uploadFields(true) + metaFields() +
|
||||
Form().actions('上传', 'adminModalSave') + '</div>';
|
||||
var modalState = { content: null, name: '', type: 1, thumbView: '', _blobUrl: '' };
|
||||
|
||||
Ui().openFormModal('上传模板', form, function () {
|
||||
var nameEl = Ui().el('tplName');
|
||||
var fileEl = Ui().el('tplFile');
|
||||
var name = nameEl.value.trim() || defaultNameFromFile(fileEl);
|
||||
if (!name) {
|
||||
soonToast('请填写名称或选择文件', 'warn');
|
||||
Ui().openFormModal('上传模板', modalHtml({ mode: 'create' }), function () {
|
||||
if (!modalState.content) {
|
||||
soonToast('请先选择 .soon 文件', 'warn');
|
||||
return false;
|
||||
}
|
||||
return readFileText(fileEl).then(function (content) {
|
||||
return Api().postJson('templates', {
|
||||
name: name,
|
||||
content: content,
|
||||
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
||||
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
||||
}).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('已上传', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}).catch(function (e) {
|
||||
if (e && e.message === 'no_file') soonToast('请选择 .soon 文件', 'warn');
|
||||
else soonToast('文件读取失败', 'error');
|
||||
return Api().postJson('templates', {
|
||||
name: modalState.name,
|
||||
content: modalState.content,
|
||||
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
||||
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
||||
}).then(function (r) {
|
||||
if (r) {
|
||||
revokeBlob(modalState);
|
||||
layer.closeAll();
|
||||
soonToast('已上传', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}, { area: ['480px', 'auto'] });
|
||||
}, Object.assign({}, MODAL_OPTS, {
|
||||
success: function () { bindTplModal({ mode: 'create' }, modalState); },
|
||||
}));
|
||||
}
|
||||
|
||||
function openEditModal(plan) {
|
||||
var form = '<div class="soon-admin-form">' + metaFields(plan) + uploadFields(false) +
|
||||
Form().actions('保存', 'adminModalSave') + '</div>';
|
||||
var modalState = { content: null, name: plan.name, type: plan.type, thumbView: '', _blobUrl: '' };
|
||||
|
||||
Ui().openFormModal('编辑模板 · ' + plan.name, form, function () {
|
||||
var name = Ui().el('tplName').value.trim();
|
||||
if (!name) {
|
||||
soonToast('名称不能为空', 'warn');
|
||||
return false;
|
||||
}
|
||||
Ui().openFormModal('编辑模板', modalHtml({ mode: 'edit', plan: plan }), function () {
|
||||
var body = {
|
||||
name: name,
|
||||
name: modalState.content ? modalState.name : plan.name,
|
||||
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
||||
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
||||
};
|
||||
var fileEl = Ui().el('tplFile');
|
||||
var hasFile = fileEl && fileEl.files && fileEl.files[0];
|
||||
var save = function (content) {
|
||||
if (content) body.content = content;
|
||||
return Api().putJson('templates/' + plan.id, body).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
};
|
||||
if (hasFile) {
|
||||
return readFileText(fileEl).then(save).catch(function () {
|
||||
soonToast('文件读取失败', 'error');
|
||||
});
|
||||
}
|
||||
return save(null);
|
||||
}, { area: ['480px', 'auto'] });
|
||||
if (modalState.content) body.content = modalState.content;
|
||||
return Api().putJson('templates/' + plan.id, body).then(function (r) {
|
||||
if (r) {
|
||||
revokeBlob(modalState);
|
||||
layer.closeAll();
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}, Object.assign({}, MODAL_OPTS, {
|
||||
success: function () { bindTplModal({ mode: 'edit', plan: plan }, modalState); },
|
||||
}));
|
||||
}
|
||||
|
||||
function deleteTemplate(plan) {
|
||||
@@ -148,7 +332,7 @@
|
||||
var panel = Ui().el('panel-templates');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
|
||||
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
|
||||
Ui().skeletonTable() + '</div>';
|
||||
|
||||
@@ -165,7 +349,7 @@
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
|
||||
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
|
||||
Ui().dataTable(
|
||||
['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'],
|
||||
|
||||
Reference in New Issue
Block a user