初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
7
frontend/src/utils/admin.ts
Normal file
7
frontend/src/utils/admin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** 服务端管理后台地址(非 SPA 路由) */
|
||||
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
|
||||
|
||||
/** 跳转到系统管理后台 */
|
||||
export function openAdminDashboard() {
|
||||
window.location.href = ADMIN_DASHBOARD_URL;
|
||||
}
|
||||
10
frontend/src/utils/board.ts
Normal file
10
frontend/src/utils/board.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** 板块图标背景色 */
|
||||
const BOARD_COLORS = ['#2d8a55', '#3498db', '#9b59b6', '#e67e22', '#1abc9c', '#e74c3c', '#34495e'];
|
||||
|
||||
export function boardColor(id: number) {
|
||||
return BOARD_COLORS[id % BOARD_COLORS.length];
|
||||
}
|
||||
|
||||
export function boardInitial(name: string) {
|
||||
return (name?.trim()?.[0] || '?').toUpperCase();
|
||||
}
|
||||
55
frontend/src/utils/comment.ts
Normal file
55
frontend/src/utils/comment.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Comment } from '../api/types';
|
||||
|
||||
export interface CommentNode {
|
||||
comment: Comment;
|
||||
children: CommentNode[];
|
||||
}
|
||||
|
||||
/** 评论显示昵称 */
|
||||
export function commentNick(c: Comment): string {
|
||||
if (c.user?.nickname) return c.user.nickname;
|
||||
if (c.guest_nick) return c.guest_nick;
|
||||
return '游客';
|
||||
}
|
||||
|
||||
/** 评论头像首字 */
|
||||
export function commentInitial(c: Comment): string {
|
||||
return commentNick(c)[0] || '?';
|
||||
}
|
||||
|
||||
/** 是否为游客评论 */
|
||||
export function isGuestComment(c: Comment): boolean {
|
||||
return !c.user_id || c.user_id === 0;
|
||||
}
|
||||
|
||||
/** 构建嵌套评论树(按 reply_to) */
|
||||
export function buildCommentTree(comments: Comment[]): CommentNode[] {
|
||||
const map = new Map<number, CommentNode>();
|
||||
const roots: CommentNode[] = [];
|
||||
|
||||
for (const c of comments) {
|
||||
map.set(c.id, { comment: c, children: [] });
|
||||
}
|
||||
|
||||
for (const c of comments) {
|
||||
const node = map.get(c.id)!;
|
||||
if (c.reply_to && map.has(c.reply_to)) {
|
||||
map.get(c.reply_to)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** 评论日期:当年显示 MM月DD日 */
|
||||
export function formatCommentDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
if (d.getFullYear() === now.getFullYear()) {
|
||||
return `${pad(d.getMonth() + 1)}月${pad(d.getDate())}日`;
|
||||
}
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
}
|
||||
20
frontend/src/utils/content.ts
Normal file
20
frontend/src/utils/content.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
|
||||
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// innerHTML 解析会吞掉换行,需转为 <br>
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
|
||||
}
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||
if (diff < 60) return '刚刚';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
|
||||
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
21
frontend/src/utils/emojis.ts
Normal file
21
frontend/src/utils/emojis.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** 评论表情列表(OwO 面板) */
|
||||
export const EMOJI_LIST = [
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
||||
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
|
||||
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
|
||||
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '🙁',
|
||||
'😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩',
|
||||
'🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '🥴',
|
||||
'😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇',
|
||||
'🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐', '🤓', '😈',
|
||||
'👻', '💀', '☠️', '👽', '👾', '🤖', '🎃', '😺', '😸', '😹',
|
||||
'😻', '😼', '😽', '🙀', '😿', '😾', '👋', '🤚', '🖐', '✋',
|
||||
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
|
||||
'👆', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏',
|
||||
'🙌', '👐', '🤲', '🤝', '🙏', '💪', '🦾', '🦿', '🦵', '🦶',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
|
||||
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️',
|
||||
'✨', '⭐', '🌟', '💫', '🔥', '💥', '💯', '✅', '❌', '❓',
|
||||
'❗', '💢', '💤', '💦', '🎉', '🎊', '🎁', '🏆', '🥇', '🥈',
|
||||
];
|
||||
43
frontend/src/utils/guest.ts
Normal file
43
frontend/src/utils/guest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
const GUEST_NICK_KEY = 'j13_guest_nick';
|
||||
const GUEST_EMAIL_KEY = 'j13_guest_email';
|
||||
const GUEST_URL_KEY = 'j13_guest_url';
|
||||
const MY_COMMENT_IDS_KEY = 'j13_my_comment_ids';
|
||||
|
||||
export interface GuestInfo {
|
||||
nick: string;
|
||||
email: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function loadGuestInfo(): GuestInfo {
|
||||
return {
|
||||
nick: localStorage.getItem(GUEST_NICK_KEY) || '',
|
||||
email: localStorage.getItem(GUEST_EMAIL_KEY) || '',
|
||||
url: localStorage.getItem(GUEST_URL_KEY) || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function saveGuestInfo(info: GuestInfo) {
|
||||
localStorage.setItem(GUEST_NICK_KEY, info.nick);
|
||||
localStorage.setItem(GUEST_EMAIL_KEY, info.email);
|
||||
localStorage.setItem(GUEST_URL_KEY, info.url);
|
||||
}
|
||||
|
||||
export function loadMyCommentIds(): number[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(MY_COMMENT_IDS_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr.filter((n) => typeof n === 'number') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addMyCommentId(id: number) {
|
||||
const ids = loadMyCommentIds();
|
||||
if (!ids.includes(id)) {
|
||||
ids.push(id);
|
||||
localStorage.setItem(MY_COMMENT_IDS_KEY, JSON.stringify(ids.slice(-200)));
|
||||
}
|
||||
}
|
||||
39
frontend/src/utils/layoutCache.ts
Normal file
39
frontend/src/utils/layoutCache.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
|
||||
const BOARDS_KEY = 'j13-cache-boards';
|
||||
const STATS_KEY = 'j13-cache-stats';
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key: string, value: unknown) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// sessionStorage 不可用时忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取缓存的板块列表,用于刷新时立即还原布局 */
|
||||
export function getCachedBoards(): Board[] {
|
||||
return readJson<Board[]>(BOARDS_KEY) ?? [];
|
||||
}
|
||||
|
||||
/** 读取缓存的论坛统计,用于刷新时立即还原 FeedHeader 高度 */
|
||||
export function getCachedStats(): ForumStats | null {
|
||||
return readJson<ForumStats>(STATS_KEY);
|
||||
}
|
||||
|
||||
export function setCachedBoards(boards: Board[]) {
|
||||
writeJson(BOARDS_KEY, boards);
|
||||
}
|
||||
|
||||
export function setCachedStats(stats: ForumStats) {
|
||||
writeJson(STATS_KEY, stats);
|
||||
}
|
||||
130
frontend/src/utils/markdown.ts
Normal file
130
frontend/src/utils/markdown.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
const MEMBERS_BLOCK_RE = /:::members[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*:::/g;
|
||||
|
||||
type MdPart = { type: 'text' | 'members'; content: string };
|
||||
|
||||
/** 拆分普通 Markdown 与 :::members 区块 */
|
||||
function splitMembersBlocks(md: string): MdPart[] {
|
||||
const parts: MdPart[] = [];
|
||||
let lastIndex = 0;
|
||||
const re = new RegExp(MEMBERS_BLOCK_RE.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(md)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: md.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ type: 'members', content: match[1] });
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < md.length) {
|
||||
parts.push({ type: 'text', content: md.slice(lastIndex) });
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push({ type: 'text', content: md });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Markdown 转 HTML,用于预览与发布 */
|
||||
export function markdownToHtml(md: string): string {
|
||||
const parts = splitMembersBlocks(md);
|
||||
const html = parts.map(part => {
|
||||
if (part.type === 'members') {
|
||||
const inner = marked.parse(part.content.trim()) as string;
|
||||
return `<members-only>${inner}</members-only>`;
|
||||
}
|
||||
return marked.parse(part.content) as string;
|
||||
}).join('');
|
||||
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** HTML 转 Markdown,用于编辑已有帖子时回填编辑器 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const walk = (node: Node): string => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const inner = () => Array.from(el.childNodes).map(walk).join('');
|
||||
|
||||
switch (tag) {
|
||||
case 'members-only': {
|
||||
if (el.getAttribute('data-locked') === 'true') return '';
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const content = body ? Array.from(body.childNodes).map(walk).join('') : inner();
|
||||
return `:::members\n${content.trim()}\n:::\n\n`;
|
||||
}
|
||||
case 'br': return '\n';
|
||||
case 'p': return inner().trimEnd() + '\n\n';
|
||||
case 'h1': return `# ${inner().trim()}\n\n`;
|
||||
case 'h2': return `## ${inner().trim()}\n\n`;
|
||||
case 'h3': return `### ${inner().trim()}\n\n`;
|
||||
case 'h4': return `#### ${inner().trim()}\n\n`;
|
||||
case 'h5': return `##### ${inner().trim()}\n\n`;
|
||||
case 'h6': return `###### ${inner().trim()}\n\n`;
|
||||
case 'strong':
|
||||
case 'b': return `**${inner()}**`;
|
||||
case 'em':
|
||||
case 'i': return `*${inner()}*`;
|
||||
case 'code': return `\`${inner()}\``;
|
||||
case 'pre': return `\`\`\`\n${el.textContent ?? ''}\n\`\`\`\n\n`;
|
||||
case 'a': return `[${inner()}](${el.getAttribute('href') ?? ''})`;
|
||||
case 'img': return ` ?? ''})`;
|
||||
case 'ul':
|
||||
return Array.from(el.children)
|
||||
.map(li => `- ${walk(li).trim()}`)
|
||||
.join('\n') + '\n\n';
|
||||
case 'ol':
|
||||
return Array.from(el.children)
|
||||
.map((li, i) => `${i + 1}. ${walk(li).trim()}`)
|
||||
.join('\n') + '\n\n';
|
||||
case 'li': return inner();
|
||||
case 'blockquote': {
|
||||
const text = inner().trim().replace(/\n/g, '\n> ');
|
||||
return `> ${text}\n\n`;
|
||||
}
|
||||
case 'hr': return '---\n\n';
|
||||
case 'div':
|
||||
if (el.classList.contains('post-members-only__badge')
|
||||
|| el.classList.contains('post-members-only__gate')
|
||||
|| el.classList.contains('post-members-only__gate-icon')) {
|
||||
return '';
|
||||
}
|
||||
if (el.classList.contains('post-members-only__body')) {
|
||||
return inner();
|
||||
}
|
||||
return inner();
|
||||
default: return inner();
|
||||
}
|
||||
};
|
||||
|
||||
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/** 统计正文字数(不含空白) */
|
||||
export function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
if (!t) return 0;
|
||||
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
|
||||
return cjk + en;
|
||||
}
|
||||
|
||||
/** 编辑器插入用的会员专属区块模板 */
|
||||
export const MEMBERS_ONLY_TEMPLATE = ':::members\n在此输入仅登录用户可见的内容…\n:::';
|
||||
85
frontend/src/utils/postContent.ts
Normal file
85
frontend/src/utils/postContent.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/** DOMPurify 配置:允许会员专属自定义标签 */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_ATTR: ['data-locked', 'data-length'],
|
||||
};
|
||||
|
||||
const VISIBLE_BADGE_HTML = `
|
||||
<div class="post-members-only__badge">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
</div>`;
|
||||
|
||||
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
||||
|
||||
/** 游客看到的锁定区块:模糊占位 + 登录引导 */
|
||||
function buildLockedGateHtml(charLength: number): string {
|
||||
const lineCount = charLength > 0
|
||||
? Math.min(6, Math.max(3, Math.ceil(charLength / 42)))
|
||||
: 4;
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => {
|
||||
const mod = i % 3;
|
||||
const widthClass = mod === 1 ? ' post-members-only__preview-line--medium'
|
||||
: mod === 2 ? ' post-members-only__preview-line--short' : '';
|
||||
return `<div class="post-members-only__preview-line${widthClass}"></div>`;
|
||||
}).join('');
|
||||
|
||||
const lengthHint = charLength > 0
|
||||
? `约 ${charLength} 字的`
|
||||
: '一段';
|
||||
|
||||
return `
|
||||
<div class="post-members-only__locked-wrap">
|
||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<span>登录可见</span>
|
||||
</div>
|
||||
<div class="post-members-only__preview" aria-hidden="true">
|
||||
${lines}
|
||||
</div>
|
||||
<div class="post-members-only__gate">
|
||||
<div class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</div>
|
||||
<p class="post-members-only__gate-title">此处有${lengthHint}专属内容</p>
|
||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 根据登录状态渲染帖子正文 HTML */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
doc.querySelectorAll('members-only').forEach(el => {
|
||||
const locked = el.getAttribute('data-locked') === 'true' || !isLoggedIn;
|
||||
|
||||
if (locked) {
|
||||
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
|
||||
el.setAttribute('data-locked', 'true');
|
||||
el.className = 'post-members-only post-members-only--locked';
|
||||
el.innerHTML = buildLockedGateHtml(charLength);
|
||||
return;
|
||||
}
|
||||
|
||||
const innerHtml = el.querySelector('.post-members-only__body')?.innerHTML
|
||||
?? Array.from(el.childNodes)
|
||||
.filter(n => !(n instanceof Element && n.classList.contains('post-members-only__badge')))
|
||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||
.join('');
|
||||
|
||||
el.className = 'post-members-only post-members-only--visible';
|
||||
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
16
frontend/src/utils/theme.ts
Normal file
16
frontend/src/utils/theme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'j13-theme';
|
||||
|
||||
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
|
||||
export function getStoredTheme(): Theme {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', theme === 'dark');
|
||||
root.style.colorScheme = theme;
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
Reference in New Issue
Block a user