初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/** 服务端管理后台地址(非 SPA 路由) */
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
/** 跳转到系统管理后台 */
export function openAdminDashboard() {
window.location.href = ADMIN_DASHBOARD_URL;
}

View 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();
}

View 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()}`;
}

View File

@@ -0,0 +1,20 @@
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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')}`;
}

View File

@@ -0,0 +1,21 @@
/** 评论表情列表OwO 面板) */
export const EMOJI_LIST = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '🙁',
'😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩',
'🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '🥴',
'😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇',
'🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐', '🤓', '😈',
'👻', '💀', '☠️', '👽', '👾', '🤖', '🎃', '😺', '😸', '😹',
'😻', '😼', '😽', '🙀', '😿', '😾', '👋', '🤚', '🖐', '✋',
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏',
'🙌', '👐', '🤲', '🤝', '🙏', '💪', '🦾', '🦿', '🦵', '🦶',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️',
'✨', '⭐', '🌟', '💫', '🔥', '💥', '💯', '✅', '❌', '❓',
'❗', '💢', '💤', '💦', '🎉', '🎊', '🎁', '🏆', '🥇', '🥈',
];

View 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)));
}
}

View 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);
}

View 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 `![${el.getAttribute('alt') ?? ''}](${el.getAttribute('src') ?? ''})`;
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:::';

View 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;
}

View 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);
}