@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.
Files changed (143) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +56 -0
  4. package/.github/workflows/ci.yml +43 -0
  5. package/CLAUDE.md +280 -0
  6. package/Dockerfile +44 -0
  7. package/LICENSE +21 -0
  8. package/README.md +433 -0
  9. package/README_EN.md +399 -0
  10. package/bin/args.js +64 -0
  11. package/bin/client.js +93 -0
  12. package/bin/commands/_shared.js +54 -0
  13. package/bin/commands/cat.js +23 -0
  14. package/bin/commands/ls.js +44 -0
  15. package/bin/commands/mv.js +20 -0
  16. package/bin/commands/rm.js +22 -0
  17. package/bin/commands/skills.js +70 -0
  18. package/bin/commands/star.js +23 -0
  19. package/bin/commands/tags.js +97 -0
  20. package/bin/commands/upload.js +84 -0
  21. package/bin/commands/url.js +25 -0
  22. package/bin/commands/whoami.js +29 -0
  23. package/bin/config.js +85 -0
  24. package/bin/jpage.js +168 -0
  25. package/build.js +112 -0
  26. package/docker-compose.yml +26 -0
  27. package/docs/api.md +438 -0
  28. package/docs/design/005-custom-modal.md +296 -0
  29. package/docs/design/013-file-version-history.md +324 -0
  30. package/docs/design/billing-system.md +600 -0
  31. package/docs/design/db-index-and-healthcheck.md +176 -0
  32. package/docs/design/loading-states.md +209 -0
  33. package/docs/virtual-hosting-feasibility.md +453 -0
  34. package/eslint.config.mjs +172 -0
  35. package/lib/auth-state.js +15 -0
  36. package/lib/categories.js +20 -0
  37. package/lib/crypto.js +85 -0
  38. package/lib/csp.js +66 -0
  39. package/lib/db.js +53 -0
  40. package/lib/dispatch.js +103 -0
  41. package/lib/fts.js +81 -0
  42. package/lib/middleware/auth.js +114 -0
  43. package/lib/middleware/files.js +42 -0
  44. package/lib/paths.js +9 -0
  45. package/lib/render-cache.js +48 -0
  46. package/lib/render.js +157 -0
  47. package/lib/templates.js +149 -0
  48. package/lib/util.js +66 -0
  49. package/lib/view-counts.js +59 -0
  50. package/lib/zip.js +192 -0
  51. package/logger.js +16 -0
  52. package/mailer.js +34 -0
  53. package/mcp/constants.js +16 -0
  54. package/mcp/resources.js +74 -0
  55. package/mcp/server.js +43 -0
  56. package/mcp/tools-categories.js +56 -0
  57. package/mcp/tools-content-templates.js +59 -0
  58. package/mcp/tools-files.js +245 -0
  59. package/mcp/tools-tags.js +41 -0
  60. package/mcp/tools-versions.js +57 -0
  61. package/mcp/transport.js +183 -0
  62. package/mcp/util.js +63 -0
  63. package/mcp-server.js +20 -0
  64. package/migrations/001_init_schema.js +25 -0
  65. package/migrations/002_add_share_key.js +33 -0
  66. package/migrations/003_add_roles_and_tokens.js +28 -0
  67. package/migrations/004_add_version_history.js +32 -0
  68. package/migrations/005_tags_starred_categories.js +49 -0
  69. package/migrations/006_zip_bundle.js +17 -0
  70. package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
  71. package/migrations/008_add_fts5.js +6 -0
  72. package/migrations/009_add_link_visits.js +20 -0
  73. package/migrations/010_add_templates_system.js +34 -0
  74. package/migrations/011_content_templates.js +233 -0
  75. package/migrations/012_add_email_and_verification.js +35 -0
  76. package/migrations/013_add_token_encrypted.js +14 -0
  77. package/migrations.js +65 -0
  78. package/package.json +63 -0
  79. package/public/css/style.css +2915 -0
  80. package/public/index.html +855 -0
  81. package/public/js/api.js +22 -0
  82. package/public/js/app.js +94 -0
  83. package/public/js/components/dialog.js +106 -0
  84. package/public/js/components/toast.js +13 -0
  85. package/public/js/pages/content-templates.js +330 -0
  86. package/public/js/pages/home.js +1903 -0
  87. package/public/js/pages/landing.js +158 -0
  88. package/public/js/pages/login.js +175 -0
  89. package/public/js/pages/preview.js +713 -0
  90. package/public/js/theme.js +44 -0
  91. package/public/js/utils.js +67 -0
  92. package/routes/admin.js +136 -0
  93. package/routes/auth.js +365 -0
  94. package/routes/categories.js +90 -0
  95. package/routes/content-templates.js +215 -0
  96. package/routes/files/_shared.js +112 -0
  97. package/routes/files/associations.js +94 -0
  98. package/routes/files/crud.js +139 -0
  99. package/routes/files/detail-serve.js +178 -0
  100. package/routes/files/index.js +38 -0
  101. package/routes/files/list.js +200 -0
  102. package/routes/files/overwrite.js +114 -0
  103. package/routes/files/upload.js +204 -0
  104. package/routes/files/versions.js +166 -0
  105. package/routes/files.js +16 -0
  106. package/routes/skills.js +93 -0
  107. package/routes/tags.js +65 -0
  108. package/routes/tokens.js +110 -0
  109. package/routes/users.js +120 -0
  110. package/server.js +372 -0
  111. package/skills/jpage-content-template/SKILL.md +98 -0
  112. package/skills/jpage-upload/SKILL.md +247 -0
  113. package/skills-registry.js +135 -0
  114. package/templates/academic.html +41 -0
  115. package/templates/dark-pro.html +41 -0
  116. package/templates/default.html +56 -0
  117. package/templates/github.html +67 -0
  118. package/test/browser-harness.js +125 -0
  119. package/test/dispatch-bench.js +74 -0
  120. package/test/helpers/setup.js +45 -0
  121. package/test/integration/admin.test.js +108 -0
  122. package/test/integration/auth.test.js +93 -0
  123. package/test/integration/categories.test.js +103 -0
  124. package/test/integration/cli.test.js +310 -0
  125. package/test/integration/content-templates.test.js +147 -0
  126. package/test/integration/files-security.test.js +248 -0
  127. package/test/integration/files.test.js +139 -0
  128. package/test/integration/share.test.js +79 -0
  129. package/test/integration/skills.test.js +104 -0
  130. package/test/integration/tags.test.js +84 -0
  131. package/test/integration/tokens.test.js +89 -0
  132. package/test/integration/users.test.js +138 -0
  133. package/test/mcp-harness.js +152 -0
  134. package/test/perf-bench.js +108 -0
  135. package/test/perf-harness.js +198 -0
  136. package/test/run-server.sh +15 -0
  137. package/test/unit/cli-args.test.js +88 -0
  138. package/test/unit/cli-config.test.js +89 -0
  139. package/test/unit/crypto.test.js +100 -0
  140. package/test/unit/fts.test.js +52 -0
  141. package/test/unit/render-cache.test.js +76 -0
  142. package/test/unit/util.test.js +81 -0
  143. package/test/unit/zip.test.js +164 -0
@@ -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 };
@@ -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
+ }