@code2rich/jpage 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
package/public/js/api.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// API 请求封装:统一 fetch 封装,带鉴权 header 和错误处理
|
|
2
|
+
|
|
3
|
+
const API_BASE = '';
|
|
4
|
+
|
|
5
|
+
async function api(path, opts = {}) {
|
|
6
|
+
const url = API_BASE + path;
|
|
7
|
+
const headers = { 'Content-Type': 'application/json', ...opts.headers };
|
|
8
|
+
if (opts.body && typeof opts.body !== 'string' && !(opts.body instanceof FormData)) {
|
|
9
|
+
opts.body = JSON.stringify(opts.body);
|
|
10
|
+
}
|
|
11
|
+
const res = await fetch(url, { ...opts, headers, credentials: 'same-origin' });
|
|
12
|
+
if (res.status === 204) return null;
|
|
13
|
+
const data = await res.json().catch(() => ({}));
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
const err = new Error(data.error || `HTTP ${res.status}`);
|
|
16
|
+
err.status = res.status;
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { API_BASE, api };
|
package/public/js/app.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// 入口:路由初始化、全局状态、hash change 监听
|
|
2
|
+
//
|
|
3
|
+
// 路由级代码分割:各页面(landing/login/home/preview)用动态 import() 按需加载,
|
|
4
|
+
// 经 esbuild splitting 产出独立 chunk,首屏只下载当前路由所需代码。
|
|
5
|
+
|
|
6
|
+
import { api } from './api.js';
|
|
7
|
+
import { dialogModal } from './components/dialog.js';
|
|
8
|
+
import { toast } from './components/toast.js';
|
|
9
|
+
import { initTheme, setupThemeToggle } from './theme.js';
|
|
10
|
+
|
|
11
|
+
const state = {
|
|
12
|
+
currentUser: null,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function fetchCurrentUser() {
|
|
16
|
+
try {
|
|
17
|
+
const data = await api('/api/auth/me');
|
|
18
|
+
state.currentUser = data;
|
|
19
|
+
return data;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e.status === 401) {
|
|
22
|
+
state.currentUser = null;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function navigate(path) {
|
|
30
|
+
location.hash = path;
|
|
31
|
+
route();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 动态加载各路由模块(esbuild 据此做代码分割,产出独立 chunk)
|
|
35
|
+
async function loadHome() { const m = await import('./pages/home.js'); return m.renderHome; }
|
|
36
|
+
async function loadLogin() { const m = await import('./pages/login.js'); return m.renderLogin; }
|
|
37
|
+
async function loadLanding() { const m = await import('./pages/landing.js'); return m.renderLanding; }
|
|
38
|
+
async function loadPreview() { const m = await import('./pages/preview.js'); return m.renderPreview; }
|
|
39
|
+
|
|
40
|
+
function route() {
|
|
41
|
+
const hash = location.hash.replace('#', '') || '/';
|
|
42
|
+
const appEl = document.getElementById('app');
|
|
43
|
+
|
|
44
|
+
// 邮箱验证结果页(纯静态,无需加载页面模块)
|
|
45
|
+
if (hash === '/email-verified' || hash === '/email-verify-failed' || hash === '/email-verify-expired') {
|
|
46
|
+
const messages = {
|
|
47
|
+
'/email-verified': { title: '邮箱验证成功', desc: '你的邮箱已通过验证。', ok: true },
|
|
48
|
+
'/email-verify-failed': { title: '验证失败', desc: '验证链接无效,请重新发送验证邮件。', ok: false },
|
|
49
|
+
'/email-verify-expired': { title: '链接已过期', desc: '验证链接已过期,请重新发送验证邮件。', ok: false },
|
|
50
|
+
};
|
|
51
|
+
const msg = messages[hash];
|
|
52
|
+
appEl.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:16px">
|
|
53
|
+
<h2>${msg.title}</h2><p>${msg.desc}</p>
|
|
54
|
+
<a href="#/" class="btn btn-primary">${msg.ok ? '返回首页' : '返回'}</a>
|
|
55
|
+
</div>`;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 预览页(独立路由,动态加载)
|
|
60
|
+
if (hash.startsWith('/view/')) {
|
|
61
|
+
loadPreview().then((renderPreview) => {
|
|
62
|
+
renderPreview(appEl, hash);
|
|
63
|
+
setupThemeToggle(appEl);
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (state.currentUser) {
|
|
69
|
+
if (hash === '/login' || hash === '/register') { navigate('/'); return; }
|
|
70
|
+
loadHome().then((renderHome) => {
|
|
71
|
+
renderHome(appEl);
|
|
72
|
+
setupThemeToggle(appEl);
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hash === '/login') {
|
|
78
|
+
loadLogin().then((renderLogin) => { renderLogin(appEl, 'login'); setupThemeToggle(appEl); });
|
|
79
|
+
} else if (hash === '/register') {
|
|
80
|
+
loadLogin().then((renderLogin) => { renderLogin(appEl, 'register'); setupThemeToggle(appEl); });
|
|
81
|
+
} else {
|
|
82
|
+
loadLanding().then((renderLanding) => { renderLanding(appEl, null); setupThemeToggle(appEl); });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
window.addEventListener('hashchange', route);
|
|
87
|
+
window.addEventListener('load', async () => {
|
|
88
|
+
dialogModal.init();
|
|
89
|
+
initTheme();
|
|
90
|
+
await fetchCurrentUser();
|
|
91
|
+
route();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export { state, navigate };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// 弹窗系统:confirm / prompt / alert 对话框
|
|
2
|
+
|
|
3
|
+
const dialogModal = {
|
|
4
|
+
el: null, input: null, error: null, msg: null, field: null,
|
|
5
|
+
confirmBtn: null, cancelBtn: null, closeBtn: null,
|
|
6
|
+
_resolve: null, _mode: null, _validate: null, _escHandler: null,
|
|
7
|
+
|
|
8
|
+
init() {
|
|
9
|
+
this.el = document.getElementById('dialog-modal');
|
|
10
|
+
this.input = document.getElementById('dialog-modal-input');
|
|
11
|
+
this.error = document.getElementById('dialog-modal-error');
|
|
12
|
+
this.msg = document.getElementById('dialog-modal-message');
|
|
13
|
+
this.field = document.getElementById('dialog-modal-field');
|
|
14
|
+
this.confirmBtn = document.getElementById('dialog-modal-confirm');
|
|
15
|
+
this.cancelBtn = document.getElementById('dialog-modal-cancel');
|
|
16
|
+
this.closeBtn = document.getElementById('dialog-modal-close');
|
|
17
|
+
this.titleEl = document.getElementById('dialog-modal-title');
|
|
18
|
+
this.labelEl = document.getElementById('dialog-modal-label');
|
|
19
|
+
|
|
20
|
+
this.closeBtn.addEventListener('click', () => this._dismiss());
|
|
21
|
+
this.cancelBtn.addEventListener('click', () => this._dismiss());
|
|
22
|
+
this.el.addEventListener('click', e => { if (e.target === this.el) this._dismiss(); });
|
|
23
|
+
this.input.addEventListener('keydown', e => {
|
|
24
|
+
if (e.key === 'Enter') this._accept();
|
|
25
|
+
if (e.key === 'Escape') this._dismiss();
|
|
26
|
+
});
|
|
27
|
+
this.input.addEventListener('input', () => { this.error.hidden = true; });
|
|
28
|
+
this.confirmBtn.addEventListener('click', () => this._accept());
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
_open(mode, opts) {
|
|
32
|
+
this._mode = mode;
|
|
33
|
+
this._resolve = null;
|
|
34
|
+
this._validate = opts.validate || null;
|
|
35
|
+
this.error.hidden = true;
|
|
36
|
+
|
|
37
|
+
this.titleEl.textContent = opts.title || '';
|
|
38
|
+
this.msg.innerHTML = opts.message || '';
|
|
39
|
+
this.msg.hidden = !opts.message;
|
|
40
|
+
|
|
41
|
+
if (mode === 'prompt') {
|
|
42
|
+
this.field.hidden = false;
|
|
43
|
+
this.labelEl.textContent = opts.label || '';
|
|
44
|
+
this.input.value = opts.value || '';
|
|
45
|
+
this.input.placeholder = opts.placeholder || '';
|
|
46
|
+
} else {
|
|
47
|
+
this.field.hidden = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.confirmBtn.textContent = opts.confirmText || '确认';
|
|
51
|
+
this.confirmBtn.className = opts.danger ? 'btn btn-danger btn-small' : 'btn btn-primary btn-small';
|
|
52
|
+
this.confirmBtn.disabled = false;
|
|
53
|
+
this.cancelBtn.hidden = mode === 'alert';
|
|
54
|
+
this.cancelBtn.textContent = opts.cancelText || '取消';
|
|
55
|
+
|
|
56
|
+
this.el.hidden = false;
|
|
57
|
+
this.el.setAttribute('aria-hidden', 'false');
|
|
58
|
+
|
|
59
|
+
if (mode === 'prompt') {
|
|
60
|
+
this.input.focus();
|
|
61
|
+
this.input.select();
|
|
62
|
+
} else {
|
|
63
|
+
this.confirmBtn.focus();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this._escHandler = e => { if (e.key === 'Escape') this._dismiss(); };
|
|
67
|
+
document.addEventListener('keydown', this._escHandler);
|
|
68
|
+
|
|
69
|
+
return new Promise(resolve => { this._resolve = resolve; });
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
_accept() {
|
|
73
|
+
if (this._mode === 'prompt') {
|
|
74
|
+
const val = this.input.value.trim();
|
|
75
|
+
if (this._validate) {
|
|
76
|
+
const err = this._validate(val);
|
|
77
|
+
if (err) { this.error.textContent = err; this.error.hidden = false; return; }
|
|
78
|
+
}
|
|
79
|
+
this._close(val);
|
|
80
|
+
} else {
|
|
81
|
+
this._close(true);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
_dismiss() {
|
|
86
|
+
this._close(this._mode === 'prompt' ? null : false);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
_close(result) {
|
|
90
|
+
this.el.hidden = true;
|
|
91
|
+
this.el.setAttribute('aria-hidden', 'true');
|
|
92
|
+
if (this._escHandler) {
|
|
93
|
+
document.removeEventListener('keydown', this._escHandler);
|
|
94
|
+
this._escHandler = null;
|
|
95
|
+
}
|
|
96
|
+
const resolve = this._resolve;
|
|
97
|
+
this._resolve = null;
|
|
98
|
+
if (resolve) resolve(result);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
confirm(opts) { return this._open('confirm', opts); },
|
|
102
|
+
prompt(opts) { return this._open('prompt', opts); },
|
|
103
|
+
alert(opts) { return this._open('alert', opts); },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export { dialogModal };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Toast 通知组件:成功/错误/警告提示
|
|
2
|
+
|
|
3
|
+
function toast(msg, type = 'success') {
|
|
4
|
+
const el = document.createElement('div');
|
|
5
|
+
el.className = 'toast ' + type;
|
|
6
|
+
el.textContent = msg;
|
|
7
|
+
el.setAttribute('role', 'status');
|
|
8
|
+
el.setAttribute('aria-live', 'polite');
|
|
9
|
+
document.body.appendChild(el);
|
|
10
|
+
setTimeout(() => el.remove(), 3000);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { toast };
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// 内容模板市场:浏览、上传、详情
|
|
2
|
+
|
|
3
|
+
import { api, API_BASE } from '../api.js';
|
|
4
|
+
import { toast } from '../components/toast.js';
|
|
5
|
+
import { dialogModal } from '../components/dialog.js';
|
|
6
|
+
import { escapeHtml, relativeTime, openModal, closeModal } from '../utils.js';
|
|
7
|
+
import { state } from '../app.js';
|
|
8
|
+
|
|
9
|
+
const SCENE_LABELS = { dashboard: '仪表板', report: '报告', resume: '简历', landing: '落地页', note: '笔记', presentation: '演示', card: '卡片', email: '邮件', other: '其他' };
|
|
10
|
+
|
|
11
|
+
const ctState = { scene: '', keyword: '', page: 1, templates: [], pagination: { page: 1, limit: 12, total: 0, totalPages: 1 }, currentId: null, editing: false };
|
|
12
|
+
|
|
13
|
+
const loadedThumbs = new Set();
|
|
14
|
+
let activeThumbLoads = 0;
|
|
15
|
+
const MAX_CONCURRENT_THUMBS = 3;
|
|
16
|
+
const pendingThumbQueue = [];
|
|
17
|
+
const thumbObserver = new IntersectionObserver((entries) => {
|
|
18
|
+
entries.forEach(entry => {
|
|
19
|
+
if (!entry.isIntersecting) return;
|
|
20
|
+
const card = entry.target;
|
|
21
|
+
thumbObserver.unobserve(card);
|
|
22
|
+
enqueueThumbLoad(card);
|
|
23
|
+
});
|
|
24
|
+
}, { rootMargin: '200px' });
|
|
25
|
+
|
|
26
|
+
function enqueueThumbLoad(card) {
|
|
27
|
+
if (activeThumbLoads < MAX_CONCURRENT_THUMBS) {
|
|
28
|
+
activeThumbLoads++;
|
|
29
|
+
loadThumb(card).finally(() => {
|
|
30
|
+
activeThumbLoads--;
|
|
31
|
+
if (pendingThumbQueue.length > 0) {
|
|
32
|
+
enqueueThumbLoad(pendingThumbQueue.shift());
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
} else {
|
|
36
|
+
pendingThumbQueue.push(card);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function loadThumb(card) {
|
|
41
|
+
const id = parseInt(card.dataset.id);
|
|
42
|
+
if (loadedThumbs.has(id)) return;
|
|
43
|
+
loadedThumbs.add(id);
|
|
44
|
+
const loadingEl = card.querySelector('.ct-card-thumb-loading');
|
|
45
|
+
const iframe = card.querySelector('.ct-thumb-iframe');
|
|
46
|
+
if (!iframe) return;
|
|
47
|
+
try {
|
|
48
|
+
const data = await api(`/api/content-templates/${id}/content`);
|
|
49
|
+
if (data.file_type === 'markdown') {
|
|
50
|
+
iframe.srcdoc = `<pre style="padding:24px;font-size:14px;white-space:pre-wrap;word-break:break-word;margin:0">${escapeHtml(data.content)}</pre>`;
|
|
51
|
+
} else {
|
|
52
|
+
iframe.srcdoc = data.content;
|
|
53
|
+
}
|
|
54
|
+
iframe.onload = () => { if (loadingEl) loadingEl.remove(); };
|
|
55
|
+
} catch {
|
|
56
|
+
if (loadingEl) loadingEl.remove();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let marketEventsBound = false;
|
|
61
|
+
|
|
62
|
+
export function openContentTemplateMarket() {
|
|
63
|
+
const modal = document.getElementById('ct-market-modal');
|
|
64
|
+
if (!modal) return;
|
|
65
|
+
openModal(modal);
|
|
66
|
+
ctState.scene = '';
|
|
67
|
+
ctState.keyword = '';
|
|
68
|
+
ctState.page = 1;
|
|
69
|
+
loadedThumbs.clear();
|
|
70
|
+
if (!marketEventsBound) {
|
|
71
|
+
bindMarketEvents(modal);
|
|
72
|
+
marketEventsBound = true;
|
|
73
|
+
}
|
|
74
|
+
// 重置场景筛选 UI
|
|
75
|
+
const chips = modal.querySelectorAll('#ct-scene-chips .filter-chip');
|
|
76
|
+
chips.forEach(c => c.classList.toggle('active', !c.dataset.scene));
|
|
77
|
+
loadTemplates();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bindMarketEvents(modal) {
|
|
81
|
+
// 关闭
|
|
82
|
+
const close = () => { closeModal(modal); };
|
|
83
|
+
modal.querySelector('#ct-market-close').onclick = close;
|
|
84
|
+
modal.querySelector('#ct-market-dismiss').onclick = close;
|
|
85
|
+
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
|
86
|
+
|
|
87
|
+
// 场景筛选
|
|
88
|
+
const chips = modal.querySelectorAll('#ct-scene-chips .filter-chip');
|
|
89
|
+
chips.forEach(chip => {
|
|
90
|
+
chip.onclick = () => {
|
|
91
|
+
chips.forEach(c => c.classList.remove('active'));
|
|
92
|
+
chip.classList.add('active');
|
|
93
|
+
ctState.scene = chip.dataset.scene;
|
|
94
|
+
ctState.page = 1;
|
|
95
|
+
loadTemplates();
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 搜索
|
|
100
|
+
const searchInput = modal.querySelector('#ct-search');
|
|
101
|
+
let searchTimer;
|
|
102
|
+
searchInput.oninput = () => {
|
|
103
|
+
clearTimeout(searchTimer);
|
|
104
|
+
searchTimer = setTimeout(() => {
|
|
105
|
+
ctState.keyword = searchInput.value.trim();
|
|
106
|
+
ctState.page = 1;
|
|
107
|
+
loadTemplates();
|
|
108
|
+
}, 300);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// 上传按钮
|
|
112
|
+
modal.querySelector('#ct-market-upload').onclick = () => openUploadModal();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function loadTemplates() {
|
|
116
|
+
const grid = document.getElementById('ct-grid');
|
|
117
|
+
if (!grid) return;
|
|
118
|
+
loadedThumbs.clear();
|
|
119
|
+
grid.innerHTML = '<div class="ct-loading">加载中...</div>';
|
|
120
|
+
|
|
121
|
+
const params = new URLSearchParams();
|
|
122
|
+
params.set('page', ctState.page);
|
|
123
|
+
params.set('limit', '12');
|
|
124
|
+
if (ctState.scene) params.set('scene', ctState.scene);
|
|
125
|
+
if (ctState.keyword) params.set('keyword', ctState.keyword);
|
|
126
|
+
params.set('sort', 'use_count');
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const data = await api('/api/content-templates?' + params.toString());
|
|
130
|
+
ctState.templates = data.templates || [];
|
|
131
|
+
ctState.pagination = data.pagination || { page: 1, limit: 12, total: 0, totalPages: 1 };
|
|
132
|
+
renderGrid(grid);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
grid.innerHTML = '<div class="ct-empty">加载失败</div>';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderGrid(grid) {
|
|
139
|
+
if (ctState.templates.length === 0) {
|
|
140
|
+
grid.innerHTML = '<div class="ct-empty">暂无模板,点击右上角「+ 上传模板」添加</div>';
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
grid.innerHTML = ctState.templates.map(t => {
|
|
145
|
+
const sceneLabel = SCENE_LABELS[t.scene] || t.scene || '';
|
|
146
|
+
const typeClass = t.file_type === 'markdown' ? 'ct-badge-md' : 'ct-badge-html';
|
|
147
|
+
const typeLabel = t.file_type === 'markdown' ? 'MD' : 'HTML';
|
|
148
|
+
const isOwner = state.currentUser && (state.currentUser.id === t.uploaded_by || state.currentUser.role === 'admin');
|
|
149
|
+
return `<div class="ct-card" data-id="${t.id}" data-file-type="${t.file_type}">
|
|
150
|
+
<div class="ct-card-thumb">
|
|
151
|
+
<div class="ct-card-thumb-wrap"><iframe class="ct-thumb-iframe" sandbox="allow-scripts"></iframe></div>
|
|
152
|
+
<div class="ct-card-thumb-loading"></div>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="ct-card-header">
|
|
155
|
+
<span class="ct-card-title">${escapeHtml(t.title)}</span>
|
|
156
|
+
<span class="ct-badge ${typeClass}">${typeLabel}</span>
|
|
157
|
+
</div>
|
|
158
|
+
${sceneLabel ? `<span class="ct-badge ct-badge-scene">${sceneLabel}</span>` : ''}
|
|
159
|
+
<p class="ct-card-desc">${escapeHtml(t.description || '').slice(0, 100)}</p>
|
|
160
|
+
<div class="ct-card-footer">
|
|
161
|
+
<span class="ct-use-count">使用 ${t.use_count} 次</span>
|
|
162
|
+
<span class="ct-card-time">${relativeTime(t.created_at)}</span>
|
|
163
|
+
${isOwner ? '<span class="ct-owner-mark">我的</span>' : ''}
|
|
164
|
+
</div>
|
|
165
|
+
</div>`;
|
|
166
|
+
}).join('');
|
|
167
|
+
|
|
168
|
+
// 分页
|
|
169
|
+
const pg = ctState.pagination;
|
|
170
|
+
if (pg.totalPages > 1) {
|
|
171
|
+
grid.innerHTML += `<div class="ct-pagination">
|
|
172
|
+
<button class="btn btn-small" id="ct-prev" ${pg.page <= 1 ? 'disabled' : ''}>上一页</button>
|
|
173
|
+
<span class="ct-page-info">${pg.page} / ${pg.totalPages}</span>
|
|
174
|
+
<button class="btn btn-small" id="ct-next" ${pg.page >= pg.totalPages ? 'disabled' : ''}>下一页</button>
|
|
175
|
+
</div>`;
|
|
176
|
+
grid.querySelector('#ct-prev')?.addEventListener('click', () => { ctState.page = Math.max(1, ctState.page - 1); loadTemplates(); });
|
|
177
|
+
grid.querySelector('#ct-next')?.addEventListener('click', () => { ctState.page = Math.min(pg.totalPages, ctState.page + 1); loadTemplates(); });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 卡片点击
|
|
181
|
+
grid.querySelectorAll('.ct-card').forEach(card => {
|
|
182
|
+
card.onclick = () => openDetailModal(parseInt(card.dataset.id));
|
|
183
|
+
thumbObserver.observe(card);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function openUploadModal(prefill) {
|
|
188
|
+
const modal = document.getElementById('ct-upload-modal');
|
|
189
|
+
if (!modal) return;
|
|
190
|
+
openModal(modal);
|
|
191
|
+
|
|
192
|
+
const titleEl = modal.querySelector('#ct-upload-title');
|
|
193
|
+
const sceneEl = modal.querySelector('#ct-upload-scene');
|
|
194
|
+
const descEl = modal.querySelector('#ct-upload-desc');
|
|
195
|
+
const tagsEl = modal.querySelector('#ct-upload-tags');
|
|
196
|
+
const contentEl = modal.querySelector('#ct-upload-content');
|
|
197
|
+
const filetypeEl = modal.querySelector('#ct-upload-filetype');
|
|
198
|
+
|
|
199
|
+
if (prefill) {
|
|
200
|
+
titleEl.value = prefill.title || '';
|
|
201
|
+
sceneEl.value = prefill.scene || '';
|
|
202
|
+
descEl.value = prefill.description || '';
|
|
203
|
+
tagsEl.value = prefill.style_tags || '';
|
|
204
|
+
contentEl.value = prefill.content || '';
|
|
205
|
+
filetypeEl.value = prefill.file_type || 'html';
|
|
206
|
+
} else {
|
|
207
|
+
titleEl.value = '';
|
|
208
|
+
sceneEl.value = '';
|
|
209
|
+
descEl.value = '';
|
|
210
|
+
tagsEl.value = '';
|
|
211
|
+
contentEl.value = '';
|
|
212
|
+
filetypeEl.value = 'html';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const close = () => { closeModal(modal); };
|
|
216
|
+
modal.querySelector('#ct-upload-close').onclick = close;
|
|
217
|
+
modal.querySelector('#ct-upload-cancel').onclick = close;
|
|
218
|
+
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
|
219
|
+
|
|
220
|
+
modal.querySelector('#ct-upload-submit').onclick = async () => {
|
|
221
|
+
const title = titleEl.value.trim();
|
|
222
|
+
const content = contentEl.value;
|
|
223
|
+
if (!title) return toast('请填写模板标题', 'error');
|
|
224
|
+
if (!content) return toast('请填写样例内容', 'error');
|
|
225
|
+
if (Buffer_byteLength(content) > 512000) return toast('样例内容不能超过 500KB', 'error');
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const body = {
|
|
229
|
+
title,
|
|
230
|
+
description: descEl.value.trim() || undefined,
|
|
231
|
+
scene: sceneEl.value || undefined,
|
|
232
|
+
styleTags: tagsEl.value.trim() || undefined,
|
|
233
|
+
content,
|
|
234
|
+
fileType: filetypeEl.value,
|
|
235
|
+
isPublic: modal.querySelector('#ct-upload-public').checked,
|
|
236
|
+
};
|
|
237
|
+
if (prefill?.id) {
|
|
238
|
+
await api(`/api/content-templates/${prefill.id}`, { method: 'PUT', body });
|
|
239
|
+
} else {
|
|
240
|
+
await api('/api/content-templates', { method: 'POST', body });
|
|
241
|
+
}
|
|
242
|
+
toast('模板上传成功');
|
|
243
|
+
close();
|
|
244
|
+
loadTemplates();
|
|
245
|
+
} catch (e) {
|
|
246
|
+
toast(e.message || '上传失败', 'error');
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function Buffer_byteLength(str) {
|
|
252
|
+
return new TextEncoder().encode(str).length;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function openDetailModal(id) {
|
|
256
|
+
const modal = document.getElementById('ct-detail-modal');
|
|
257
|
+
if (!modal) return;
|
|
258
|
+
openModal(modal);
|
|
259
|
+
ctState.currentId = id;
|
|
260
|
+
ctState.editing = false;
|
|
261
|
+
|
|
262
|
+
const close = () => { closeModal(modal); };
|
|
263
|
+
modal.querySelector('#ct-detail-close').onclick = close;
|
|
264
|
+
modal.querySelector('#ct-detail-dismiss').onclick = close;
|
|
265
|
+
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const [meta, contentData] = await Promise.all([
|
|
269
|
+
api(`/api/content-templates/${id}`),
|
|
270
|
+
api(`/api/content-templates/${id}/content`),
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
modal.querySelector('#ct-detail-title').textContent = meta.title;
|
|
274
|
+
|
|
275
|
+
// 元数据
|
|
276
|
+
const metaEl = modal.querySelector('#ct-detail-meta');
|
|
277
|
+
const sceneLabel = SCENE_LABELS[meta.scene] || meta.scene || '';
|
|
278
|
+
const isOwner = state.currentUser && (state.currentUser.id === meta.uploaded_by || state.currentUser.role === 'admin');
|
|
279
|
+
metaEl.innerHTML = `
|
|
280
|
+
<div class="ct-meta-row">
|
|
281
|
+
${sceneLabel ? `<span class="ct-badge ct-badge-scene">${sceneLabel}</span>` : ''}
|
|
282
|
+
<span class="ct-badge ${meta.file_type === 'markdown' ? 'ct-badge-md' : 'ct-badge-html'}">${meta.file_type === 'markdown' ? 'Markdown' : 'HTML'}</span>
|
|
283
|
+
${meta.style_tags ? meta.style_tags.split(',').map(t => `<span class="ct-badge ct-badge-tag">${escapeHtml(t.trim())}</span>`).join('') : ''}
|
|
284
|
+
</div>
|
|
285
|
+
${meta.description ? `<p class="ct-desc">${escapeHtml(meta.description)}</p>` : ''}
|
|
286
|
+
<div class="ct-meta-info">使用 ${meta.use_count} 次 · ${meta.uploader_name || '系统内置'} · ${relativeTime(meta.created_at)}</div>
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
// 预览
|
|
290
|
+
const iframe = modal.querySelector('#ct-detail-iframe');
|
|
291
|
+
if (contentData.file_type === 'html') {
|
|
292
|
+
iframe.srcdoc = contentData.content;
|
|
293
|
+
} else {
|
|
294
|
+
iframe.srcdoc = `<pre style="padding:16px;font-size:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin:0">${escapeHtml(contentData.content)}</pre>`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 按钮权限
|
|
298
|
+
const editBtn = modal.querySelector('#ct-detail-edit');
|
|
299
|
+
const delBtn = modal.querySelector('#ct-detail-delete');
|
|
300
|
+
editBtn.style.display = isOwner ? '' : 'none';
|
|
301
|
+
delBtn.style.display = isOwner ? '' : 'none';
|
|
302
|
+
|
|
303
|
+
// 复制
|
|
304
|
+
modal.querySelector('#ct-detail-copy').onclick = () => {
|
|
305
|
+
navigator.clipboard.writeText(contentData.content).then(() => toast('已复制到剪贴板'));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// 编辑
|
|
309
|
+
editBtn.onclick = () => {
|
|
310
|
+
closeModal(modal);
|
|
311
|
+
openUploadModal({ ...meta, content: contentData.content });
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// 删除
|
|
315
|
+
delBtn.onclick = async () => {
|
|
316
|
+
const ok = await dialogModal('确定删除此模板?', '此操作不可撤销。', '删除');
|
|
317
|
+
if (!ok) return;
|
|
318
|
+
try {
|
|
319
|
+
await api(`/api/content-templates/${id}`, { method: 'DELETE' });
|
|
320
|
+
toast('模板已删除');
|
|
321
|
+
close();
|
|
322
|
+
loadTemplates();
|
|
323
|
+
} catch (e) {
|
|
324
|
+
toast('删除失败', 'error');
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
} catch (e) {
|
|
328
|
+
toast('加载模板详情失败', 'error');
|
|
329
|
+
}
|
|
330
|
+
}
|