@flecblog/core-nuxt 0.1.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 (70) hide show
  1. package/app.vue +172 -0
  2. package/assets/css/base.scss +96 -0
  3. package/composables/api/article.ts +25 -0
  4. package/composables/api/category.ts +19 -0
  5. package/composables/api/comment.ts +25 -0
  6. package/composables/api/createApi.ts +242 -0
  7. package/composables/api/feedback.ts +14 -0
  8. package/composables/api/friend.ts +14 -0
  9. package/composables/api/moment.ts +10 -0
  10. package/composables/api/music.ts +39 -0
  11. package/composables/api/notification.ts +19 -0
  12. package/composables/api/stats.ts +14 -0
  13. package/composables/api/subscribe.ts +13 -0
  14. package/composables/api/sysconfig.ts +9 -0
  15. package/composables/api/tag.ts +19 -0
  16. package/composables/api/theme.ts +9 -0
  17. package/composables/api/upload.ts +13 -0
  18. package/composables/api/user.ts +77 -0
  19. package/composables/useArticle.ts +227 -0
  20. package/composables/useAuth.ts +180 -0
  21. package/composables/useComment.ts +143 -0
  22. package/composables/useDarkMode.ts +29 -0
  23. package/composables/useEmoji.ts +39 -0
  24. package/composables/useFeedback.ts +38 -0
  25. package/composables/useFriendList.ts +100 -0
  26. package/composables/useMermaid.ts +30 -0
  27. package/composables/useMoment.ts +86 -0
  28. package/composables/useMusic.ts +37 -0
  29. package/composables/useSearchState.ts +90 -0
  30. package/composables/useStores.ts +557 -0
  31. package/composables/useSubscribe.ts +13 -0
  32. package/composables/useTheme.ts +61 -0
  33. package/composables/useUser.ts +202 -0
  34. package/layouts/default.vue +1 -0
  35. package/nuxt.config.ts +170 -0
  36. package/package.json +58 -0
  37. package/pages/index.vue +1 -0
  38. package/plugins/console-banner.client.ts +11 -0
  39. package/plugins/custom-code.ts +121 -0
  40. package/plugins/syncThemeMeta.ts +28 -0
  41. package/plugins/tracker.client.ts +107 -0
  42. package/server/plugins/sitemap.ts +170 -0
  43. package/server/routes/atom.xml.ts +21 -0
  44. package/server/routes/manifest.json.ts +45 -0
  45. package/server/routes/rss.xml.ts +21 -0
  46. package/types/article.ts +48 -0
  47. package/types/auth.ts +8 -0
  48. package/types/category.ts +12 -0
  49. package/types/comment.ts +72 -0
  50. package/types/emoji.ts +10 -0
  51. package/types/feedback.ts +31 -0
  52. package/types/friend.ts +63 -0
  53. package/types/markdown.ts +7 -0
  54. package/types/moment.ts +85 -0
  55. package/types/notification.ts +56 -0
  56. package/types/request.ts +30 -0
  57. package/types/stats.ts +38 -0
  58. package/types/sysconfig.ts +2 -0
  59. package/types/tag.ts +11 -0
  60. package/types/theme.ts +26 -0
  61. package/types/upload.ts +5 -0
  62. package/types/user.ts +106 -0
  63. package/utils/avatar.ts +21 -0
  64. package/utils/date.ts +136 -0
  65. package/utils/download.ts +11 -0
  66. package/utils/emoji.ts +90 -0
  67. package/utils/format.ts +42 -0
  68. package/utils/markdown.ts +1458 -0
  69. package/utils/scroll.ts +31 -0
  70. package/utils/upload.ts +57 -0
@@ -0,0 +1,1458 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import type { TocItem } from '@@/types/markdown';
3
+ import anchor from 'markdown-it-anchor';
4
+ // @ts-expect-error 该第三方库缺少 TypeScript 类型定义文件
5
+ import taskLists from 'markdown-it-task-lists';
6
+ // @ts-expect-error 该第三方库缺少 TypeScript 类型定义文件
7
+ import mark from 'markdown-it-mark';
8
+ // @ts-expect-error 该第三方库缺少 TypeScript 类型定义文件
9
+ import linkAttributes from 'markdown-it-link-attributes';
10
+ import kbd from 'markdown-it-kbd';
11
+ // @ts-expect-error 该第三方库缺少 TypeScript 类型定义文件
12
+ import sub from 'markdown-it-sub';
13
+ // @ts-expect-error 该第三方库缺少 TypeScript 类型定义文件
14
+ import sup from 'markdown-it-sup';
15
+ // @ts-expect-error 该第三方库缺少 TypeScript 类型定义文件
16
+ import underline from 'markdown-it-plugin-underline';
17
+ import katex from '@traptitech/markdown-it-katex';
18
+
19
+ import DOMPurify from 'isomorphic-dompurify';
20
+ import {
21
+ loadEmojiMap,
22
+ loadAllEmojiGroups,
23
+ getEmojiMapSync,
24
+ replaceEmojisInText,
25
+ } from '@/utils/emoji';
26
+
27
+ // highlight.js 按需加载
28
+ import hljs from 'highlight.js/lib/core';
29
+ // 核心语言
30
+ import javascript from 'highlight.js/lib/languages/javascript';
31
+ import typescript from 'highlight.js/lib/languages/typescript';
32
+ import python from 'highlight.js/lib/languages/python';
33
+ import go from 'highlight.js/lib/languages/go';
34
+ import java from 'highlight.js/lib/languages/java';
35
+ import sql from 'highlight.js/lib/languages/sql';
36
+ // Web 相关
37
+ import xml from 'highlight.js/lib/languages/xml'; // 包含 HTML
38
+ import css from 'highlight.js/lib/languages/css';
39
+ import json from 'highlight.js/lib/languages/json';
40
+ import yaml from 'highlight.js/lib/languages/yaml';
41
+ import markdown from 'highlight.js/lib/languages/markdown';
42
+ // DevOps / Shell
43
+ import bash from 'highlight.js/lib/languages/bash';
44
+ import shell from 'highlight.js/lib/languages/shell';
45
+ import dockerfile from 'highlight.js/lib/languages/dockerfile';
46
+ // 其他
47
+ import diff from 'highlight.js/lib/languages/diff';
48
+
49
+ // Meting-API URL(从系统配置动态读取)
50
+ let metingApiUrl = 'https://meting.flec.top/api';
51
+
52
+ // 注册语言
53
+ hljs.registerLanguage('javascript', javascript);
54
+ hljs.registerLanguage('js', javascript);
55
+ hljs.registerLanguage('typescript', typescript);
56
+ hljs.registerLanguage('ts', typescript);
57
+ hljs.registerLanguage('python', python);
58
+ hljs.registerLanguage('py', python);
59
+ hljs.registerLanguage('go', go);
60
+ hljs.registerLanguage('java', java);
61
+ hljs.registerLanguage('sql', sql);
62
+ hljs.registerLanguage('xml', xml);
63
+ hljs.registerLanguage('html', xml);
64
+ hljs.registerLanguage('css', css);
65
+ hljs.registerLanguage('json', json);
66
+ hljs.registerLanguage('yaml', yaml);
67
+ hljs.registerLanguage('yml', yaml);
68
+ hljs.registerLanguage('markdown', markdown);
69
+ hljs.registerLanguage('md', markdown);
70
+ hljs.registerLanguage('bash', bash);
71
+ hljs.registerLanguage('sh', bash);
72
+ hljs.registerLanguage('shell', shell);
73
+ hljs.registerLanguage('dockerfile', dockerfile);
74
+ hljs.registerLanguage('docker', dockerfile);
75
+ hljs.registerLanguage('diff', diff);
76
+
77
+ // ========== 属性解析函数 ==========
78
+
79
+ /**
80
+ * 提取标签名和参数
81
+ * @param line - 完整的标签行,格式:`:::标签名 参数1 参数2 ...`
82
+ * @returns 标签名和参数数组
83
+ */
84
+ function extractTagAndParams(line: string): { tag: string; params: string[] } {
85
+ const match = line.match(/^:::(\w+)(.*)$/);
86
+ if (!match) return { tag: '', params: [] };
87
+ const tag = match[1] || '';
88
+ const paramsString = match[2]?.trim() || '';
89
+
90
+ // 简单按空格分割参数
91
+ const params = paramsString ? paramsString.split(/\s+/).filter(p => p && p !== ':::') : [];
92
+
93
+ return { tag, params };
94
+ }
95
+
96
+ /**
97
+ * 检查是否为自闭合标签
98
+ * @param line - 标签行
99
+ * @returns 是否为自闭合标签
100
+ */
101
+ function isSelfClosing(line: string): boolean {
102
+ return /:::$/.test(line.trim());
103
+ }
104
+
105
+ // 简单哈希函数(确定性)
106
+ function simpleHash(str: string): string {
107
+ let hash = 0;
108
+ for (let i = 0; i < str.length; i++) {
109
+ hash = (hash << 5) - hash + str.charCodeAt(i);
110
+ hash |= 0;
111
+ }
112
+ return Math.abs(hash).toString(36);
113
+ }
114
+
115
+ // 生成标题 ID(支持中文)
116
+ function generateHeadingId(text: string): string {
117
+ const id = text
118
+ .toLowerCase()
119
+ .replace(/[^\u4e00-\u9fa5a-z0-9]+/g, '-')
120
+ .replace(/^-+|-+$/g, '');
121
+ return id || `heading-${simpleHash(text)}`;
122
+ }
123
+
124
+ // ========== 自定义块渲染函数 ==========
125
+
126
+ /**
127
+ * 渲染提示框
128
+ * @param content - 内容
129
+ * @param params - [类型, 标题(可选)]
130
+ */
131
+ function renderNote(content: string, params: string[]): string {
132
+ const type = params[0] || 'info';
133
+ const title = params[1] || '';
134
+
135
+ const titleHtml = title ? `<div class="custom-note-title">${title}</div>` : '';
136
+
137
+ return `<div class="custom-note custom-note-${type}">${titleHtml}<div class="custom-note-content">${content}</div></div>`;
138
+ }
139
+
140
+ /**
141
+ * 渲染标签页
142
+ * @param tabsData - 标签数据
143
+ * @param params - [默认标签名(可选)]
144
+ */
145
+ function renderTabs(tabsData: Array<{ name: string; content: string }>, params: string[]): string {
146
+ if (tabsData.length === 0) return '';
147
+
148
+ const tabsId = `tabs-${simpleHash(tabsData.map(t => t.name).join('-'))}`;
149
+ const activeTab = params[0] || tabsData[0]?.name || '';
150
+
151
+ // 生成标签头
152
+ const tabHeaders = tabsData
153
+ .map(tab => {
154
+ const isActive = tab.name === activeTab ? 'active' : '';
155
+ return `<button class="custom-tab-btn ${isActive}" onclick="switchTab('${tabsId}', '${tab.name}')">${tab.name}</button>`;
156
+ })
157
+ .join('');
158
+
159
+ // 生成标签内容
160
+ const tabContents = tabsData
161
+ .map(tab => {
162
+ const isActive = tab.name === activeTab ? 'active' : '';
163
+ return `<div class="custom-tab-panel ${isActive}" data-tab="${tab.name}">${tab.content}</div>`;
164
+ })
165
+ .join('');
166
+
167
+ return `<div class="custom-tabs" id="${tabsId}"><div class="custom-tabs-header">${tabHeaders}</div><div class="custom-tabs-content">${tabContents}</div></div>`;
168
+ }
169
+
170
+ /**
171
+ * 渲染折叠面板
172
+ * @param content - 内容
173
+ * @param params - [标题, open(可选)]
174
+ */
175
+ function renderFold(content: string, params: string[]): string {
176
+ const title = params[0] || '点击展开';
177
+ const open = params[1] === 'true' || params[1] === 'open';
178
+ const foldId = `fold-${simpleHash(title + content.slice(0, 50))}`;
179
+ const openClass = open ? 'open' : '';
180
+
181
+ return `<div class="custom-fold ${openClass}" id="${foldId}"><div class="custom-fold-header" onclick="toggleFold('${foldId}')"><i class="ri-arrow-right-s-line"></i><span>${title}</span></div><div class="custom-fold-content"><div>${content}</div></div></div>`;
182
+ }
183
+
184
+ /**
185
+ * 渲染链接卡片
186
+ * @param params - [标题, 链接, 描述(可包含空格)]
187
+ */
188
+ function renderLinkCard(params: string[]): string {
189
+ const title = params[0] || '';
190
+ const link = params[1] || '';
191
+ const description = params.slice(2).join(' ');
192
+
193
+ if (!link) return '';
194
+
195
+ // 判断是否为外部链接
196
+ const isExternal = link.startsWith('http://') || link.startsWith('https://');
197
+ const linkType = isExternal ? '引用站外链接' : '站内链接';
198
+ const linkTypeClass = isExternal ? 'external' : 'internal';
199
+
200
+ return `<div class="custom-link-card ${linkTypeClass}">
201
+ <div class="custom-link-type">${linkType}</div>
202
+ <a href="${link}" class="custom-link-main" target="${isExternal ? '_blank' : '_self'}" rel="${isExternal ? 'noopener noreferrer' : ''}">
203
+ <div class="custom-link-icon">
204
+ <i class="ri-global-line"></i>
205
+ </div>
206
+ <div class="custom-link-info">
207
+ <div class="custom-link-title">${title}</div>
208
+ <div class="custom-link-desc">${description || link}</div>
209
+ </div>
210
+ <div class="custom-link-arrow">
211
+ <i class="ri-arrow-right-up-line"></i>
212
+ </div>
213
+ </a>
214
+ </div>`;
215
+ }
216
+
217
+ /**
218
+ * 渲染在线音乐/音频
219
+ * @param params - [标题, 音频URL]
220
+ */
221
+ function renderAudio(params: string[]): string {
222
+ const title = params[0] || '';
223
+ const audioUrl = params[1] || '';
224
+
225
+ if (!title || (!audioUrl.startsWith('http://') && !audioUrl.startsWith('https://'))) {
226
+ return '';
227
+ }
228
+
229
+ const audioId = `audio-${simpleHash(audioUrl + title)}`;
230
+
231
+ return `<div class="custom-audio" data-audio-id="${audioId}">
232
+ <div class="custom-audio-type">播放音频</div>
233
+ <div class="custom-audio-main">
234
+ <div class="custom-audio-icon">
235
+ <i class="ri-music-line"></i>
236
+ <button class="custom-audio-btn" onclick="toggleAudioPlay('${audioId}')">
237
+ <i class="ri-play-fill"></i>
238
+ </button>
239
+ </div>
240
+ <div class="custom-audio-content">
241
+ <div class="custom-audio-info">${title}</div>
242
+ <div class="custom-audio-controls">
243
+ <div class="custom-audio-progress" onclick="seekAudio('${audioId}', event)">
244
+ <div class="custom-audio-progress-bar"></div>
245
+ </div>
246
+ <div class="custom-audio-time"><span class="custom-audio-current">0:00</span> / <span class="custom-audio-duration">0:00</span></div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ <audio src="${audioUrl}" preload="auto" style="display:none;"></audio>
251
+ </div>`;
252
+ }
253
+
254
+ /**
255
+ * 渲染在线音乐
256
+ * @param params - [平台, 音乐ID]
257
+ */
258
+ function renderMusic(params: string[]): string {
259
+ if (params.length < 2) return '';
260
+
261
+ const server = params[0] || '';
262
+ const musicId = params[1] || '';
263
+
264
+ if (!server || !musicId) return '';
265
+
266
+ const audioId = `audio-${simpleHash(server + musicId)}`;
267
+ const embedUrl = `${metingApiUrl}?server=${server}&type=song&id=${musicId}`;
268
+
269
+ return `<div class="custom-audio" data-audio-id="${audioId}" data-music-id="${musicId}">
270
+ <div class="custom-audio-type">播放在线音乐</div>
271
+ <div class="custom-audio-main">
272
+ <div class="custom-audio-icon">
273
+ <i class="ri-music-line"></i>
274
+ <button class="custom-audio-btn" onclick="toggleMusicPlay('${audioId}', '${server}', '${musicId}')">
275
+ <i class="ri-play-fill"></i>
276
+ </button>
277
+ </div>
278
+ <div class="custom-audio-content">
279
+ <div class="custom-audio-info" data-music-info="${audioId}">加载中...</div>
280
+ <div class="custom-audio-controls">
281
+ <div class="custom-audio-progress" onclick="seekMusic('${audioId}', event)">
282
+ <div class="custom-audio-progress-bar"></div>
283
+ </div>
284
+ <div class="custom-audio-time"><span class="custom-audio-current">0:00</span> / <span class="custom-audio-duration">0:00</span></div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ <div class="custom-audio-source" data-embed-url="${embedUrl}" style="display:none;"></div>
289
+ </div>`;
290
+ }
291
+
292
+ /**
293
+ * 渲染在线视频
294
+ * @param params - [平台或URL, 视频ID(可选)]
295
+ * 支持格式:
296
+ * - :::video bilibili BV1xxx :::
297
+ * - :::video youtube dQw4w9WgXcQ :::
298
+ * - :::video https://example.com/video.mp4 :::
299
+ */
300
+ function renderVideo(params: string[]): string {
301
+ if (params.length === 0) return '';
302
+
303
+ const platformOrUrl = params[0] || '';
304
+ const videoId = params[1] || '';
305
+
306
+ // B站视频
307
+ if (platformOrUrl === 'bilibili' && videoId) {
308
+ return `<div class="custom-video">
309
+ <iframe
310
+ src="//player.bilibili.com/player.html?bvid=${videoId}&autoplay=0"
311
+ scrolling="no"
312
+ border="0"
313
+ frameborder="no"
314
+ framespacing="0"
315
+ allowfullscreen="true"
316
+ sandbox="allow-scripts allow-same-origin allow-popups"
317
+ referrerpolicy="strict-origin-when-cross-origin">
318
+ </iframe>
319
+ </div>`;
320
+ }
321
+
322
+ // YouTube视频
323
+ if (platformOrUrl === 'youtube' && videoId) {
324
+ return `<div class="custom-video">
325
+ <iframe
326
+ src="https://www.youtube.com/embed/${videoId}"
327
+ frameborder="0"
328
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
329
+ allowfullscreen
330
+ sandbox="allow-scripts allow-same-origin allow-popups"
331
+ referrerpolicy="strict-origin-when-cross-origin">
332
+ </iframe>
333
+ </div>`;
334
+ }
335
+
336
+ // 本地/在线视频URL
337
+ if (
338
+ platformOrUrl.startsWith('http://') ||
339
+ platformOrUrl.startsWith('https://') ||
340
+ platformOrUrl.startsWith('/')
341
+ ) {
342
+ return `<div class="custom-video">
343
+ <video src="${platformOrUrl}" controls preload="metadata"></video>
344
+ </div>`;
345
+ }
346
+
347
+ return '';
348
+ }
349
+
350
+ // 创建 markdown-it 实例
351
+ const md = new MarkdownIt({
352
+ html: false,
353
+ breaks: true,
354
+ linkify: true,
355
+ });
356
+
357
+ // 自定义代码块渲染规则
358
+ md.renderer.rules.fence = (tokens, idx) => {
359
+ const token = tokens[idx];
360
+ if (!token) return '';
361
+
362
+ const code = token.content;
363
+ const lang = token.info.trim();
364
+
365
+ // 特殊处理 Mermaid 代码块(不进行代码高亮)
366
+ if (lang === 'mermaid') {
367
+ return `<pre class="mermaid"><code>${md.utils.escapeHtml(code)}</code></pre>`;
368
+ }
369
+
370
+ // 高亮代码
371
+ let highlightedCode = '';
372
+ const displayLang = (lang || 'text').toUpperCase();
373
+
374
+ if (lang && hljs.getLanguage(lang)) {
375
+ try {
376
+ highlightedCode = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
377
+ } catch {
378
+ highlightedCode = md.utils.escapeHtml(code);
379
+ }
380
+ } else {
381
+ highlightedCode = md.utils.escapeHtml(code);
382
+ }
383
+
384
+ // 添加行号(移除末尾换行符避免空行)
385
+ const numberedLines = highlightedCode
386
+ .replace(/\n$/, '')
387
+ .split('\n')
388
+ .map(
389
+ (line, index) =>
390
+ `<span class="line-number" data-line="${index + 1}"></span><span class="line-content">${line}</span>`
391
+ )
392
+ .join('\n');
393
+
394
+ // 将原始代码内容存储在 data 属性中,用于复制功能
395
+ const escapedCode = md.utils.escapeHtml(code.replace(/\n$/, ''));
396
+
397
+ // 返回完整结构
398
+ return `<div class="code-block-container" data-code-content="${escapedCode}"><div class="code-toolbar"><button class="code-fold-btn" onclick="this.closest('.code-block-container').classList.toggle('collapsed')" title="折叠/展开"><i class="ri-arrow-down-s-line"></i></button><span class="code-lang">${displayLang}</span><button class="code-copy-btn" onclick="copyCodeBlock(this)" title="复制代码"><i class="ri-file-copy-fill"></i></button></div><pre><code>${numberedLines}</code></pre></div>`;
399
+ };
400
+
401
+ // 使用 anchor 插件生成标题 ID
402
+ md.use(anchor, {
403
+ slugify: generateHeadingId,
404
+ permalink: false,
405
+ level: [1, 2, 3, 4, 5, 6],
406
+ });
407
+
408
+ // 使用任务列表插件
409
+ md.use(taskLists, {
410
+ enabled: true,
411
+ label: true,
412
+ labelAfter: false,
413
+ });
414
+
415
+ // 使用高亮文本插件
416
+ md.use(mark);
417
+
418
+ // 使用链接属性插件(外部链接在新窗口打开)
419
+ md.use(linkAttributes, {
420
+ matcher(href: string) {
421
+ return href.startsWith('http://') || href.startsWith('https://');
422
+ },
423
+ attrs: {
424
+ target: '_blank',
425
+ rel: 'noopener noreferrer',
426
+ },
427
+ });
428
+
429
+ // 使用键盘按键插件(支持 [[Ctrl]] 语法)
430
+ md.use(kbd);
431
+
432
+ // 使用上标插件(支持 ^上标^ 语法)
433
+ md.use(sup);
434
+
435
+ // 使用下标插件(支持 ~下标~ 语法)
436
+ md.use(sub);
437
+
438
+ // 使用下划线插件(支持 ++下划线++ 语法)
439
+ md.use(underline);
440
+
441
+ // 使用 KaTeX 插件(支持 LaTeX 公式)
442
+ md.use(katex, { throwOnError: false, errorColor: '#cc0000' });
443
+
444
+ // 自定义表格渲染规则 - 添加滚动容器包裹
445
+ const defaultTableOpen = md.renderer.rules.table_open || (() => '<table>\n');
446
+ const defaultTableClose = md.renderer.rules.table_close || (() => '</table>\n');
447
+
448
+ md.renderer.rules.table_open = function (tokens, idx, options, env, self) {
449
+ return '<div class="table-wrapper">' + defaultTableOpen(tokens, idx, options, env, self);
450
+ };
451
+
452
+ md.renderer.rules.table_close = function (tokens, idx, options, env, self) {
453
+ return defaultTableClose(tokens, idx, options, env, self) + '</div>';
454
+ };
455
+
456
+ // ========== 自定义块插件 ==========
457
+
458
+ /**
459
+ * 自定义块插件
460
+ */
461
+ function customBlocksPlugin(md: MarkdownIt) {
462
+ // 块级规则
463
+ md.block.ruler.before('fence', 'custom_blocks', (state, startLine, endLine, silent) => {
464
+ const pos = (state.bMarks[startLine] ?? 0) + (state.tShift[startLine] ?? 0);
465
+ const max = state.eMarks[startLine] ?? 0;
466
+ const lineText = state.src.slice(pos, max).trim();
467
+
468
+ // 检查是否为自定义块起始标签
469
+ if (!lineText.startsWith(':::')) {
470
+ return false;
471
+ }
472
+
473
+ // 检查是否为自闭合标签
474
+ if (isSelfClosing(lineText)) {
475
+ if (silent) return true;
476
+
477
+ const { tag, params } = extractTagAndParams(lineText);
478
+
479
+ // 处理自闭合标签
480
+ let html = '';
481
+ if (tag === 'link') {
482
+ html = renderLinkCard(params);
483
+ } else if (tag === 'video') {
484
+ html = renderVideo(params);
485
+ } else if (tag === 'audio') {
486
+ html = renderAudio(params);
487
+ } else if (tag === 'music') {
488
+ html = renderMusic(params);
489
+ }
490
+
491
+ if (html) {
492
+ const token = state.push('html_block', '', 0);
493
+ token.content = html;
494
+ token.map = [startLine, startLine + 1];
495
+ state.line = startLine + 1;
496
+ return true;
497
+ }
498
+
499
+ return false;
500
+ }
501
+
502
+ // 处理块级标签
503
+ const { tag, params } = extractTagAndParams(lineText);
504
+ if (!tag) return false;
505
+
506
+ // 查找结束标签
507
+ const endTagFull = `end${tag}`;
508
+ let nextLine = startLine + 1;
509
+ let foundEnd = false;
510
+ const contentLines: string[] = [];
511
+
512
+ // 特殊处理 tabs
513
+ if (tag === 'tabs') {
514
+ const tabsData: Array<{ name: string; content: string }> = [];
515
+ let currentTab: { name: string; content: string } | null = null;
516
+
517
+ while (nextLine < endLine) {
518
+ const linePos = state.bMarks[nextLine] ?? 0;
519
+ const lineMax = state.eMarks[nextLine] ?? 0;
520
+ const line = state.src.slice(linePos, lineMax).trim();
521
+
522
+ if (line.startsWith(':::endtabs')) {
523
+ foundEnd = true;
524
+ break;
525
+ }
526
+
527
+ if (line.startsWith(':::tab')) {
528
+ // 保存上一个 tab
529
+ if (currentTab) {
530
+ tabsData.push(currentTab);
531
+ }
532
+ // 开始新 tab
533
+ const tabParams = extractTagAndParams(line).params;
534
+ currentTab = { name: tabParams[0] || `Tab ${tabsData.length + 1}`, content: '' };
535
+ } else if (line.startsWith(':::endtab')) {
536
+ // tab 结束,不做处理
537
+ } else {
538
+ // tab 内容
539
+ if (currentTab) {
540
+ currentTab.content += state.src.slice(linePos, lineMax) + '\n';
541
+ }
542
+ }
543
+
544
+ nextLine++;
545
+ }
546
+
547
+ // 保存最后一个 tab
548
+ if (currentTab) {
549
+ tabsData.push(currentTab);
550
+ }
551
+
552
+ if (foundEnd && tabsData.length > 0) {
553
+ if (silent) return true;
554
+
555
+ // 渲染每个 tab 的内容
556
+ const renderedTabs = tabsData.map(tab => ({
557
+ name: tab.name,
558
+ content: md.render(tab.content),
559
+ }));
560
+
561
+ const html = renderTabs(renderedTabs, params);
562
+
563
+ const token = state.push('html_block', '', 0);
564
+ token.content = html;
565
+ token.map = [startLine, nextLine + 1];
566
+ state.line = nextLine + 1;
567
+ return true;
568
+ }
569
+
570
+ return false;
571
+ }
572
+
573
+ // 特殊处理 photo
574
+ if (tag === 'photo') {
575
+ const rows: string[][] = [];
576
+ let currentRow: string[] = [];
577
+
578
+ while (nextLine < endLine) {
579
+ const linePos = (state.bMarks[nextLine] ?? 0) + (state.tShift[nextLine] ?? 0);
580
+ const lineMax = state.eMarks[nextLine] ?? 0;
581
+ const line = state.src.slice(linePos, lineMax).trim();
582
+
583
+ if (line === ':::endphoto') {
584
+ foundEnd = true;
585
+ break;
586
+ }
587
+
588
+ // 检查是否为换行标记 :::n
589
+ if (line === ':::n') {
590
+ // 保存当前行并开始新行
591
+ if (currentRow.length > 0) {
592
+ rows.push(currentRow);
593
+ currentRow = [];
594
+ }
595
+ } else {
596
+ // 解析图片(支持多个图片用空格分隔)
597
+ const images = line.split(/\s+/).filter(img => img.trim());
598
+ currentRow.push(...images);
599
+ }
600
+
601
+ nextLine++;
602
+ }
603
+
604
+ // 保存最后一行
605
+ if (currentRow.length > 0) {
606
+ rows.push(currentRow);
607
+ }
608
+
609
+ if (foundEnd && rows.length > 0) {
610
+ if (silent) return true;
611
+
612
+ const html = renderPhotoWall(rows, startLine);
613
+
614
+ const token = state.push('html_block', '', 0);
615
+ token.content = html;
616
+ token.map = [startLine, nextLine + 1];
617
+ state.line = nextLine + 1;
618
+ return true;
619
+ }
620
+
621
+ return false;
622
+ }
623
+
624
+ // 处理其他块级标签(note, fold)
625
+ while (nextLine < endLine) {
626
+ const linePos = state.bMarks[nextLine] ?? 0;
627
+ const lineMax = state.eMarks[nextLine] ?? 0;
628
+ const line = state.src.slice(linePos, lineMax).trim();
629
+
630
+ if (line === `:::${endTagFull}`) {
631
+ foundEnd = true;
632
+ break;
633
+ }
634
+
635
+ contentLines.push(state.src.slice(linePos, lineMax));
636
+ nextLine++;
637
+ }
638
+
639
+ if (!foundEnd) return false;
640
+ if (silent) return true;
641
+
642
+ // 渲染内容
643
+ const content = md.render(contentLines.join('\n'));
644
+
645
+ let html = '';
646
+ if (tag === 'note') {
647
+ html = renderNote(content, params);
648
+ } else if (tag === 'fold') {
649
+ html = renderFold(content, params);
650
+ }
651
+
652
+ if (html) {
653
+ const token = state.push('html_block', '', 0);
654
+ token.content = html;
655
+ token.map = [startLine, nextLine + 1];
656
+ state.line = nextLine + 1;
657
+ return true;
658
+ }
659
+
660
+ return false;
661
+ });
662
+ }
663
+
664
+ // 使用自定义块插件
665
+ md.use(customBlocksPlugin);
666
+
667
+ // 初始化渲染器(从系统配置读取 Meting-API 和表情包 URL)
668
+ export function initMarkdownRenderer(): void {
669
+ try {
670
+ const { basicConfig } = useSysConfig();
671
+ if (basicConfig.value.meting_api) {
672
+ metingApiUrl = basicConfig.value.meting_api;
673
+ }
674
+ if (basicConfig.value.emojis) {
675
+ loadEmojiMap(basicConfig.value.emojis);
676
+ loadAllEmojiGroups(basicConfig.value.emojis);
677
+ }
678
+ } catch {
679
+ // useSysConfig 可能在非 Nuxt 上下文中调用,使用默认值
680
+ }
681
+ }
682
+
683
+ // 渲染 Markdown 为 HTML
684
+ export function renderMarkdown(markdown: string): string {
685
+ if (!markdown) return '';
686
+
687
+ const rawHtml = md.render(markdown);
688
+
689
+ // 替换表情占位符为 img 标签
690
+ const emojiMap = getEmojiMapSync();
691
+ let processedHtml = rawHtml;
692
+ if (emojiMap && emojiMap.size > 0) {
693
+ processedHtml = replaceEmojisInText(rawHtml, emojiMap);
694
+ }
695
+
696
+ return DOMPurify.sanitize(processedHtml, {
697
+ ALLOWED_TAGS: [
698
+ 'h1',
699
+ 'h2',
700
+ 'h3',
701
+ 'h4',
702
+ 'h5',
703
+ 'h6',
704
+ 'p',
705
+ 'br',
706
+ 'hr',
707
+ 'strong',
708
+ 'em',
709
+ 'u',
710
+ 's',
711
+ 'del',
712
+ 'ins',
713
+ 'mark',
714
+ 'code',
715
+ 'pre',
716
+ 'ul',
717
+ 'ol',
718
+ 'li',
719
+ 'blockquote',
720
+ 'cite',
721
+ 'footer',
722
+ 'a',
723
+ 'img',
724
+ 'table',
725
+ 'thead',
726
+ 'tbody',
727
+ 'tr',
728
+ 'th',
729
+ 'td',
730
+ 'div',
731
+ 'span',
732
+ 'sup',
733
+ 'sub',
734
+ 'kbd',
735
+ 'abbr',
736
+ 'input',
737
+ 'label',
738
+ 'button',
739
+ 'i',
740
+ 'section',
741
+ 'svg',
742
+ 'path',
743
+ 'g',
744
+ 'rect',
745
+ 'circle',
746
+ 'ellipse',
747
+ 'line',
748
+ 'polygon',
749
+ 'polyline',
750
+ 'text',
751
+ 'foreignObject',
752
+ 'video',
753
+ 'iframe',
754
+ 'audio',
755
+ 'source',
756
+ // KaTeX / MathML 标签
757
+ 'math',
758
+ 'mrow',
759
+ 'mi',
760
+ 'mo',
761
+ 'mn',
762
+ 'msup',
763
+ 'msub',
764
+ 'msubsup',
765
+ 'mfrac',
766
+ 'msqrt',
767
+ 'mroot',
768
+ 'mover',
769
+ 'munder',
770
+ 'munderover',
771
+ 'mtable',
772
+ 'mtr',
773
+ 'mtd',
774
+ 'mtext',
775
+ 'mspace',
776
+ 'mpadded',
777
+ 'menclose',
778
+ 'mstyle',
779
+ 'merror',
780
+ 'mfenced',
781
+ 'mphantom',
782
+ 'annotation',
783
+ 'semantics',
784
+ ],
785
+ ALLOWED_ATTR: [
786
+ 'href',
787
+ 'title',
788
+ 'target',
789
+ 'rel',
790
+ 'src',
791
+ 'alt',
792
+ 'width',
793
+ 'height',
794
+ 'class',
795
+ 'id',
796
+ 'colspan',
797
+ 'rowspan',
798
+ 'align',
799
+ 'type',
800
+ 'checked',
801
+ 'disabled',
802
+ 'for',
803
+ 'onclick',
804
+ 'start',
805
+ 'd',
806
+ 'fill',
807
+ 'stroke',
808
+ 'stroke-width',
809
+ 'x',
810
+ 'y',
811
+ 'cx',
812
+ 'cy',
813
+ 'r',
814
+ 'rx',
815
+ 'ry',
816
+ 'x1',
817
+ 'y1',
818
+ 'x2',
819
+ 'y2',
820
+ 'points',
821
+ 'transform',
822
+ 'viewBox',
823
+ 'xmlns',
824
+ 'text-anchor',
825
+ 'font-size',
826
+ 'font-family',
827
+ 'dominant-baseline',
828
+ 'data-processed',
829
+ 'controls',
830
+ 'preload',
831
+ 'autoplay',
832
+ 'loop',
833
+ 'muted',
834
+ 'poster',
835
+ 'allowfullscreen',
836
+ 'scrolling',
837
+ 'border',
838
+ 'frameborder',
839
+ 'framespacing',
840
+ 'allow',
841
+ 'sandbox',
842
+ 'referrerpolicy',
843
+ 'data-server',
844
+ 'data-type',
845
+ 'data-id',
846
+ 'data-code-content',
847
+ // KaTeX / MathML 属性
848
+ 'style',
849
+ 'mathvariant',
850
+ 'mathcolor',
851
+ 'mathbackground',
852
+ 'mathsize',
853
+ 'displaystyle',
854
+ 'scriptlevel',
855
+ 'linethickness',
856
+ 'lspace',
857
+ 'rspace',
858
+ 'stretchy',
859
+ 'symmetric',
860
+ 'largeop',
861
+ 'movablelimits',
862
+ 'accent',
863
+ 'minsize',
864
+ 'maxsize',
865
+ 'open',
866
+ 'close',
867
+ 'separators',
868
+ 'notation',
869
+ 'encoding',
870
+ 'definitionurl',
871
+ 'display',
872
+ 'xmlns:xlink',
873
+ 'height',
874
+ 'depth',
875
+ 'voffset',
876
+ 'width',
877
+ 'lspace',
878
+ 'width',
879
+ 'columnalign',
880
+ 'rowalign',
881
+ 'columnspacing',
882
+ 'rowspacing',
883
+ ],
884
+ ALLOW_DATA_ATTR: true,
885
+ ADD_ATTR: ['target', 'onclick', 'allowfullscreen'],
886
+ });
887
+ }
888
+
889
+ // 计算字数
890
+ export function countWords(markdown: string): number {
891
+ if (!markdown) return 0;
892
+
893
+ // 服务端渲染时,直接从 markdown 统计
894
+ if (!import.meta.client) {
895
+ const text = markdown
896
+ .replace(/```[\s\S]*?```/g, '') // 移除代码块
897
+ .replace(/`[^`]+`/g, '') // 移除行内代码
898
+ .replace(/!\[.*?\]\(.*?\)/g, '') // 移除图片
899
+ .replace(/\[.*?\]\(.*?\)/g, '') // 移除链接
900
+ .replace(/[#*_~`|]/g, '') // 移除 markdown 符号
901
+ .trim();
902
+
903
+ const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
904
+ const englishWords = text.match(/[a-zA-Z]+/g) || [];
905
+ return chineseChars.length + englishWords.length;
906
+ }
907
+
908
+ // 客户端:使用 DOM 提取文本
909
+ const html = md.render(markdown);
910
+ const temp = document.createElement('div');
911
+ temp.innerHTML = html;
912
+
913
+ // 移除代码块(不统计代码)
914
+ temp.querySelectorAll('pre, code').forEach(el => el.remove());
915
+
916
+ // 提取纯文本
917
+ const text = temp.textContent?.trim() || '';
918
+
919
+ // 统计中英文字数
920
+ const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
921
+ const englishWords = text.match(/[a-zA-Z]+/g) || [];
922
+ return chineseChars.length + englishWords.length;
923
+ }
924
+
925
+ // 计算阅读时长(分钟)
926
+ export function estimateReadingTime(markdown: string, wordsPerMinute = 300): number {
927
+ return Math.ceil(countWords(markdown) / wordsPerMinute);
928
+ }
929
+
930
+ // 提取目录
931
+ export function extractToc(markdown: string): TocItem[] {
932
+ if (!markdown) return [];
933
+
934
+ // 移除代码块
935
+ let cleanedMarkdown = markdown
936
+ .replace(/```[\s\S]*?```/g, '')
937
+ .replace(/~~~[\s\S]*?~~~\s*/g, '')
938
+ .replace(/^( {4}|\t).+$/gm, '');
939
+
940
+ // 处理单行自定义块
941
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::link\s+.*?:::$/gm, '');
942
+ // 处理多行自定义块
943
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::note[\s\S]*?^:::endnote$/gm, '');
944
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::tabs[\s\S]*?^:::endtabs$/gm, '');
945
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::fold[\s\S]*?^:::endfold$/gm, '');
946
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::photo[\s\S]*?^:::endphoto$/gm, '');
947
+ // 处理视频自定义块
948
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::video\s+.*?:::$/gm, '');
949
+ // 处理音频自定义块
950
+ cleanedMarkdown = cleanedMarkdown.replace(/^:::audio\s+.*?:::$/gm, '');
951
+
952
+ const headings: TocItem[] = [];
953
+
954
+ for (const line of cleanedMarkdown.split('\n')) {
955
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
956
+ if (match?.[1] && match[2]) {
957
+ headings.push({
958
+ id: generateHeadingId(match[2].trim()),
959
+ level: match[1].length,
960
+ text: match[2].trim(),
961
+ });
962
+ }
963
+ }
964
+
965
+ return headings;
966
+ }
967
+
968
+ /**
969
+ * 渲染照片展示墙
970
+ * @param rows - 每行的图片数组
971
+ * @param lineNum - 源码行号(可选,用于滚动同步)
972
+ */
973
+ function renderPhotoWall(rows: string[][], lineNum?: number): string {
974
+ if (rows.length === 0) return '';
975
+
976
+ const lineAttr = lineNum !== undefined ? ` data-source-line="${lineNum}"` : '';
977
+
978
+ // 生成每一行的图片
979
+ const rowsHtml = rows
980
+ .map(row => {
981
+ const imagesHtml = row
982
+ .map(img => {
983
+ // 处理图片语法:支持 markdown 图片语法和直接 URL
984
+ let imgSrc = img;
985
+ let imgAlt = '';
986
+
987
+ // 检查是否为 markdown 图片语法 ![alt](url)
988
+ const imgMatch = img.match(/^!\[(.*?)\]\((.*?)\)$/);
989
+ if (imgMatch) {
990
+ imgAlt = imgMatch[1] || '';
991
+ imgSrc = imgMatch[2] || img;
992
+ }
993
+
994
+ return `<div class="custom-photo-wall-item"><img src="${imgSrc}" alt="${imgAlt || '图片'}" loading="lazy" /></div>`;
995
+ })
996
+ .join('');
997
+
998
+ return `<div class="custom-photo-wall-row">${imagesHtml}</div>`;
999
+ })
1000
+ .join('');
1001
+
1002
+ return `<div class="custom-photo-wall"${lineAttr}><div class="custom-photo-wall-container">${rowsHtml}</div></div>`;
1003
+ }
1004
+
1005
+ // 简单 Markdown 渲染(用于评论)
1006
+ export function renderSimpleMarkdown(markdown: string): string {
1007
+ if (!markdown) return '';
1008
+
1009
+ const simpleMd = new MarkdownIt({
1010
+ html: false,
1011
+ breaks: true,
1012
+ linkify: true,
1013
+ });
1014
+
1015
+ // 先渲染 Markdown
1016
+ let simpleHtml = simpleMd.render(markdown);
1017
+
1018
+ // 然后替换表情占位符(在 HTML 中替换)
1019
+ const emojiMap = getEmojiMapSync();
1020
+ if (emojiMap && emojiMap.size > 0) {
1021
+ simpleHtml = replaceEmojisInText(simpleHtml, emojiMap);
1022
+ }
1023
+
1024
+ return DOMPurify.sanitize(simpleHtml, {
1025
+ ALLOWED_TAGS: [
1026
+ 'p',
1027
+ 'br',
1028
+ 'strong',
1029
+ 'em',
1030
+ 'code',
1031
+ 'pre',
1032
+ 'ul',
1033
+ 'ol',
1034
+ 'li',
1035
+ 'blockquote',
1036
+ 'a',
1037
+ 'img',
1038
+ ],
1039
+ ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'width', 'height', 'class'],
1040
+ ALLOWED_URI_REGEXP:
1041
+ /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
1042
+ ALLOW_DATA_ATTR: false,
1043
+ });
1044
+ }
1045
+
1046
+ // 复制代码块功能
1047
+ export function copyCodeBlock(button: HTMLElement): void {
1048
+ const container = button.closest('.code-block-container');
1049
+ if (!container) return;
1050
+
1051
+ const codeContent = (container as HTMLElement).dataset.codeContent;
1052
+ if (!codeContent) return;
1053
+
1054
+ // HTML 解码
1055
+ const textarea = document.createElement('textarea');
1056
+ textarea.innerHTML = codeContent;
1057
+ const codeText = textarea.value;
1058
+
1059
+ // 复制到剪贴板
1060
+ navigator.clipboard
1061
+ .writeText(codeText)
1062
+ .then(() => {
1063
+ const icon = button.querySelector('i');
1064
+ if (icon) {
1065
+ icon.className = 'ri-check-line';
1066
+ button.classList.add('copied');
1067
+ }
1068
+
1069
+ setTimeout(() => {
1070
+ if (icon) {
1071
+ icon.className = 'ri-file-copy-fill';
1072
+ button.classList.remove('copied');
1073
+ }
1074
+ }, 2000);
1075
+ })
1076
+ .catch(err => {
1077
+ console.error('复制失败:', err);
1078
+ });
1079
+ }
1080
+
1081
+ // 标签页切换功能
1082
+ export function switchTab(tabsId: string, tabName: string): void {
1083
+ const tabsContainer = document.getElementById(tabsId);
1084
+ if (!tabsContainer) return;
1085
+
1086
+ // 更新标签按钮状态
1087
+ const buttons = tabsContainer.querySelectorAll('.custom-tab-btn');
1088
+ buttons.forEach(btn => {
1089
+ if (btn.textContent === tabName) {
1090
+ btn.classList.add('active');
1091
+ } else {
1092
+ btn.classList.remove('active');
1093
+ }
1094
+ });
1095
+
1096
+ // 更新内容面板状态
1097
+ const panels = tabsContainer.querySelectorAll('.custom-tab-panel');
1098
+ panels.forEach(panel => {
1099
+ const panelElement = panel as HTMLElement;
1100
+ if (panelElement.dataset.tab === tabName) {
1101
+ panel.classList.add('active');
1102
+ } else {
1103
+ panel.classList.remove('active');
1104
+ }
1105
+ });
1106
+ }
1107
+
1108
+ // 折叠面板切换功能
1109
+ export function toggleFold(foldId: string): void {
1110
+ const foldContainer = document.getElementById(foldId);
1111
+ if (!foldContainer) return;
1112
+
1113
+ foldContainer.classList.toggle('open');
1114
+ }
1115
+
1116
+ export function toggleAudioPlay(audioId: string): void {
1117
+ const container = document.querySelector(`[data-audio-id="${audioId}"]`);
1118
+ if (!container) return;
1119
+
1120
+ const audio = container.querySelector('audio') as HTMLAudioElement;
1121
+ const btn = container.querySelector('.custom-audio-btn i');
1122
+ const durationTime = container.querySelector('.custom-audio-duration');
1123
+ if (!audio || !btn) return;
1124
+
1125
+ if (!(container as HTMLElement).dataset.audioInitialized) {
1126
+ initAudioEvents(container);
1127
+ (container as HTMLElement).dataset.audioInitialized = 'true';
1128
+ }
1129
+
1130
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1131
+ const mins = Math.floor(audio.duration / 60);
1132
+ const secs = Math.floor(audio.duration % 60);
1133
+ durationTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
1134
+ }
1135
+
1136
+ if (audio.paused) {
1137
+ audio.play();
1138
+ btn.className = 'ri-pause-fill';
1139
+ } else {
1140
+ audio.pause();
1141
+ btn.className = 'ri-play-fill';
1142
+ }
1143
+ }
1144
+
1145
+ export function seekAudio(audioId: string, event: MouseEvent): void {
1146
+ const container = document.querySelector(`[data-audio-id="${audioId}"]`);
1147
+ if (!container) return;
1148
+
1149
+ const audio = container.querySelector('audio') as HTMLAudioElement;
1150
+ const progressBar = container.querySelector('.custom-audio-progress');
1151
+ const durationTime = container.querySelector('.custom-audio-duration');
1152
+ if (!audio || !progressBar) return;
1153
+
1154
+ if (!(container as HTMLElement).dataset.audioInitialized) {
1155
+ initAudioEvents(container);
1156
+ (container as HTMLElement).dataset.audioInitialized = 'true';
1157
+ }
1158
+
1159
+ if (durationTime && (!audio.duration || !isFinite(audio.duration))) {
1160
+ audio.preload = 'metadata';
1161
+ audio.addEventListener(
1162
+ 'loadedmetadata',
1163
+ () => {
1164
+ const mins = Math.floor(audio.duration / 60);
1165
+ const secs = Math.floor(audio.duration % 60);
1166
+ durationTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
1167
+ },
1168
+ { once: true }
1169
+ );
1170
+ }
1171
+
1172
+ const rect = progressBar.getBoundingClientRect();
1173
+ const percent = (event.clientX - rect.left) / rect.width;
1174
+ audio.currentTime = percent * audio.duration;
1175
+ }
1176
+
1177
+ export function toggleMusicPlay(audioId: string, _server: string, _musicId: string): void {
1178
+ const container = document.querySelector(`[data-audio-id="${audioId}"]`);
1179
+ if (!container) return;
1180
+
1181
+ const btn = container.querySelector('.custom-audio-btn i');
1182
+ const musicInfoEl = container.querySelector(`[data-music-info="${audioId}"]`);
1183
+ const durationTime = container.querySelector('.custom-audio-duration');
1184
+ if (!btn) return;
1185
+
1186
+ let audio = container.querySelector('audio') as HTMLAudioElement;
1187
+ const musicSource = container.querySelector('.custom-audio-source') as HTMLElement;
1188
+
1189
+ if (!audio && musicSource) {
1190
+ const embedUrl = musicSource.dataset.embedUrl || '';
1191
+
1192
+ fetch(embedUrl)
1193
+ .then(res => res.json())
1194
+ .then(data => {
1195
+ if (data && data.length > 0) {
1196
+ const info = data[0];
1197
+ if (musicInfoEl) {
1198
+ musicInfoEl.textContent = `${info.name || '未知歌曲'} - ${info.artist || info.author || '未知艺术家'}`;
1199
+ }
1200
+
1201
+ audio = container.querySelector('audio') as HTMLAudioElement;
1202
+ if (!audio && info.url) {
1203
+ const newAudio = document.createElement('audio');
1204
+ newAudio.src = info.url;
1205
+ newAudio.preload = 'auto';
1206
+ container.appendChild(newAudio);
1207
+ audio = newAudio;
1208
+ } else if (audio) {
1209
+ audio.src = info.url;
1210
+ }
1211
+
1212
+ if (audio && durationTime) {
1213
+ audio.addEventListener(
1214
+ 'loadedmetadata',
1215
+ () => {
1216
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1217
+ const mins = Math.floor(audio.duration / 60);
1218
+ const secs = Math.floor(audio.duration % 60);
1219
+ durationTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
1220
+ }
1221
+ },
1222
+ { once: true }
1223
+ );
1224
+ }
1225
+
1226
+ initMusicEvents(container);
1227
+ audio.play();
1228
+ btn.className = 'ri-pause-fill';
1229
+ }
1230
+ })
1231
+ .catch(err => {
1232
+ console.error('Failed to load music:', err);
1233
+ if (musicInfoEl) {
1234
+ musicInfoEl.textContent = '加载失败';
1235
+ }
1236
+ });
1237
+ return;
1238
+ }
1239
+
1240
+ if (audio) {
1241
+ if (audio.paused) {
1242
+ audio.play();
1243
+ btn.className = 'ri-pause-fill';
1244
+ } else {
1245
+ audio.pause();
1246
+ btn.className = 'ri-play-fill';
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ export function seekMusic(audioId: string, event: MouseEvent): void {
1252
+ const container = document.querySelector(`[data-audio-id="${audioId}"]`);
1253
+ if (!container) return;
1254
+
1255
+ const audio = container.querySelector('audio') as HTMLAudioElement;
1256
+ const progressBar = container.querySelector('.custom-audio-progress');
1257
+ const durationTime = container.querySelector('.custom-audio-duration');
1258
+ if (!audio || !progressBar) return;
1259
+
1260
+ initMusicEvents(container);
1261
+
1262
+ if (durationTime && (!audio.duration || !isFinite(audio.duration))) {
1263
+ audio.preload = 'metadata';
1264
+ audio.addEventListener(
1265
+ 'loadedmetadata',
1266
+ () => {
1267
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1268
+ const mins = Math.floor(audio.duration / 60);
1269
+ const secs = Math.floor(audio.duration % 60);
1270
+ durationTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
1271
+ }
1272
+ },
1273
+ { once: true }
1274
+ );
1275
+ }
1276
+
1277
+ const rect = progressBar.getBoundingClientRect();
1278
+ const percent = (event.clientX - rect.left) / rect.width;
1279
+ audio.currentTime = percent * audio.duration;
1280
+ }
1281
+
1282
+ function initMusicEvents(container: Element): void {
1283
+ if ((container as HTMLElement).dataset.musicInitialized) return;
1284
+ (container as HTMLElement).dataset.musicInitialized = 'true';
1285
+
1286
+ const audio = container.querySelector('audio') as HTMLAudioElement;
1287
+ const progressBar = container.querySelector('.custom-audio-progress-bar') as HTMLElement;
1288
+ const currentTimeEl = container.querySelector('.custom-audio-current') as HTMLElement;
1289
+ const durationTime = container.querySelector('.custom-audio-duration') as HTMLElement;
1290
+
1291
+ if (!audio) return;
1292
+
1293
+ audio.load();
1294
+
1295
+ const updateDuration = () => {
1296
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1297
+ durationTime.textContent = formatDuration(audio.duration);
1298
+ }
1299
+ };
1300
+
1301
+ audio.addEventListener('loadedmetadata', updateDuration);
1302
+ audio.addEventListener('canplay', updateDuration);
1303
+
1304
+ audio.addEventListener('timeupdate', () => {
1305
+ if (currentTimeEl) {
1306
+ currentTimeEl.textContent = formatDuration(audio.currentTime);
1307
+ }
1308
+ if (progressBar && audio.duration && isFinite(audio.duration)) {
1309
+ progressBar.style.width = `${(audio.currentTime / audio.duration) * 100}%`;
1310
+ }
1311
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1312
+ durationTime.textContent = formatDuration(audio.duration);
1313
+ }
1314
+ });
1315
+
1316
+ audio.addEventListener('ended', () => {
1317
+ const btnEl = container.querySelector('.custom-audio-btn i') as HTMLElement;
1318
+ if (btnEl) btnEl.className = 'ri-play-fill';
1319
+ if (progressBar) progressBar.style.width = '0%';
1320
+ if (currentTimeEl) currentTimeEl.textContent = '0:00';
1321
+ });
1322
+ }
1323
+
1324
+ function initAudioEvents(container: Element): void {
1325
+ const audio = container.querySelector('audio') as HTMLAudioElement;
1326
+ const progressBar = container.querySelector('.custom-audio-progress-bar') as HTMLElement;
1327
+ const currentTimeEl = container.querySelector('.custom-audio-current') as HTMLElement;
1328
+ const durationTime = container.querySelector('.custom-audio-duration') as HTMLElement;
1329
+
1330
+ if (!audio) return;
1331
+ if ((container as HTMLElement).dataset.audioInitialized) return;
1332
+ (container as HTMLElement).dataset.audioInitialized = 'true';
1333
+
1334
+ audio.load();
1335
+
1336
+ const updateDuration = () => {
1337
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1338
+ durationTime.textContent = formatDuration(audio.duration);
1339
+ }
1340
+ };
1341
+
1342
+ audio.addEventListener('loadedmetadata', updateDuration);
1343
+ audio.addEventListener('canplay', updateDuration);
1344
+
1345
+ audio.addEventListener('timeupdate', () => {
1346
+ if (progressBar && audio.duration && isFinite(audio.duration)) {
1347
+ progressBar.style.width = `${(audio.currentTime / audio.duration) * 100}%`;
1348
+ }
1349
+ if (durationTime && audio.duration && isFinite(audio.duration)) {
1350
+ durationTime.textContent = formatDuration(audio.duration);
1351
+ }
1352
+ if (currentTimeEl) currentTimeEl.textContent = formatDuration(audio.currentTime);
1353
+ });
1354
+
1355
+ audio.addEventListener('ended', () => {
1356
+ const btn = container.querySelector('.custom-audio-btn i') as HTMLElement;
1357
+ if (btn) btn.className = 'ri-play-fill';
1358
+ if (progressBar) progressBar.style.width = '0%';
1359
+ if (currentTimeEl) currentTimeEl.textContent = '0:00';
1360
+ });
1361
+ }
1362
+
1363
+ function observeAudioPlayers(): void {
1364
+ const observer = new MutationObserver(mutations => {
1365
+ for (const mutation of mutations) {
1366
+ for (const node of mutation.addedNodes) {
1367
+ if (node instanceof Element) {
1368
+ const audioContainers = node.matches?.('[data-audio-id]')
1369
+ ? [node]
1370
+ : node.querySelectorAll?.('[data-audio-id]');
1371
+ audioContainers?.forEach(container => {
1372
+ initAudioEvents(container);
1373
+ initMusicCard(container);
1374
+ });
1375
+ }
1376
+ }
1377
+ }
1378
+ });
1379
+
1380
+ observer.observe(document.body, { childList: true, subtree: true });
1381
+
1382
+ document.querySelectorAll('.custom-audio[data-audio-id]').forEach(container => {
1383
+ initAudioEvents(container);
1384
+ initMusicCard(container);
1385
+ });
1386
+ }
1387
+
1388
+ // 初始化音乐卡片 - 预加载音乐信息
1389
+ function initMusicCard(container: Element): void {
1390
+ const musicSource = container.querySelector('.custom-audio-source') as HTMLElement;
1391
+ const musicInfoEl = container.querySelector('.custom-audio-info') as HTMLElement;
1392
+ if (!musicSource || musicInfoEl?.dataset.initialized) return;
1393
+
1394
+ const embedUrl = musicSource.dataset.embedUrl;
1395
+ if (!embedUrl) return;
1396
+
1397
+ musicInfoEl.dataset.initialized = 'true';
1398
+
1399
+ fetch(embedUrl)
1400
+ .then(res => res.json())
1401
+ .then(data => {
1402
+ if (data && data.length > 0) {
1403
+ const info = data[0];
1404
+ if (musicInfoEl) {
1405
+ musicInfoEl.textContent = `${info.name || '未知歌曲'} - ${info.artist || info.author || '未知艺术家'}`;
1406
+ }
1407
+ const existingAudio = container.querySelector('audio');
1408
+ if (!existingAudio && info.url) {
1409
+ const audio = document.createElement('audio');
1410
+ audio.src = info.url;
1411
+ audio.preload = 'auto';
1412
+ audio.style.display = 'none';
1413
+ container.appendChild(audio);
1414
+ initMusicEvents(container);
1415
+ }
1416
+ }
1417
+ })
1418
+ .catch(() => {
1419
+ if (musicInfoEl) {
1420
+ musicInfoEl.textContent = '加载失败';
1421
+ }
1422
+ });
1423
+ }
1424
+
1425
+ // 挂载全局函数供内联 onclick 使用
1426
+ if (typeof window !== 'undefined') {
1427
+ const win = window as unknown as Window & {
1428
+ copyCodeBlock: typeof copyCodeBlock;
1429
+ switchTab: typeof switchTab;
1430
+ toggleFold: typeof toggleFold;
1431
+ toggleAudioPlay: typeof toggleAudioPlay;
1432
+ seekAudio: typeof seekAudio;
1433
+ toggleMusicPlay: typeof toggleMusicPlay;
1434
+ seekMusic: typeof seekMusic;
1435
+ };
1436
+ win.copyCodeBlock = copyCodeBlock;
1437
+ win.switchTab = switchTab;
1438
+ win.toggleFold = toggleFold;
1439
+ win.toggleAudioPlay = toggleAudioPlay;
1440
+ win.seekAudio = seekAudio;
1441
+ win.toggleMusicPlay = toggleMusicPlay;
1442
+ win.seekMusic = seekMusic;
1443
+
1444
+ if (document.readyState === 'loading') {
1445
+ document.addEventListener('DOMContentLoaded', observeAudioPlayers);
1446
+ } else {
1447
+ observeAudioPlayers();
1448
+ }
1449
+ }
1450
+
1451
+ export default {
1452
+ render: renderMarkdown,
1453
+ renderSimple: renderSimpleMarkdown,
1454
+ countWords,
1455
+ estimateReadingTime,
1456
+ extractToc,
1457
+ copyCodeBlock,
1458
+ };