feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View File

@@ -0,0 +1,70 @@
import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
const ASIDE_WIDGET_IDS: AsideWidgetId[] = ['tag_cloud', 'recent_comments', 'friend_links'];
/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
export function resolveAsideWidgets(
limits: Pick<ForumLimitsPublic, 'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'>,
): AsideWidget[] {
if (limits.aside_widgets?.length) {
return normalizeAsideWidgets(limits.aside_widgets);
}
return [
{ id: 'tag_cloud', enabled: limits.aside_show_tag_cloud },
{ id: 'recent_comments', enabled: limits.aside_show_recent_comments },
{ id: 'friend_links', enabled: limits.aside_show_friend_links },
];
}
/** 校验并补全右侧栏组件列表 */
export function normalizeAsideWidgets(widgets: AsideWidget[]): AsideWidget[] {
const seen = new Set<AsideWidgetId>();
const out: AsideWidget[] = [];
for (const w of widgets) {
if (!ASIDE_WIDGET_IDS.includes(w.id) || seen.has(w.id)) continue;
seen.add(w.id);
out.push({ id: w.id, enabled: !!w.enabled });
}
for (const id of ASIDE_WIDGET_IDS) {
if (!seen.has(id)) out.push({ id, enabled: false });
}
return out;
}
/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<ForumLimits, 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'> {
const normalized = normalizeAsideWidgets(widgets);
return {
aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
};
}
/** 合并右侧栏组件到论坛限制(保存 API 时使用) */
export function mergeForumLimitsWithAsideWidgets(limits: ForumLimits, widgets: AsideWidget[]): ForumLimits {
const normalized = normalizeAsideWidgets(widgets);
return {
...limits,
aside_widgets: normalized,
...syncAsideBoolsFromWidgets(normalized),
};
}
/** 保存后优先采用服务端返回的 aside_widgets缺失时保留本次提交值 */
export function resolveSavedAsideWidgets(saved: AsideWidget[], response?: AsideWidget[] | null): AsideWidget[] {
if (response?.length) return normalizeAsideWidgets(response);
return normalizeAsideWidgets(saved);
}
export function isAsideWidgetEnabled(widgets: AsideWidget[], id: AsideWidgetId): boolean {
return resolveAsideWidgets({
aside_widgets: widgets,
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: false,
}).find(w => w.id === id)?.enabled ?? false;
}
export { DEFAULT_ASIDE_WIDGETS };

View File

@@ -1,3 +1,5 @@
import type { Board } from '../api/types';
/** 板块图标背景色 */
const BOARD_COLORS = ['#2d8a55', '#3498db', '#9b59b6', '#e67e22', '#1abc9c', '#e74c3c', '#34495e'];
@@ -8,3 +10,30 @@ export function boardColor(id: number) {
export function boardInitial(name: string) {
return (name?.trim()?.[0] || '?').toUpperCase();
}
/** 是否为公告类板块(名称含「公告」或 megaphone 图标) */
function isAnnouncementBoard(board: Board): boolean {
if (board.name.includes('公告')) return true;
return (board.icon || '').trim() === 'megaphone';
}
/** 是否为闲聊类板块(名称含「闲聊」) */
function isCasualBoard(board: Board): boolean {
return board.name.includes('闲聊');
}
/**
* 发帖页板块排序:闲聊置顶、公告置底,其余保持 API 原序。
* 仅用于发帖选择器,不影响侧栏/导航排序。
*/
export function sortBoardsForCompose(boards: Board[]): Board[] {
const casual: Board[] = [];
const middle: Board[] = [];
const announcement: Board[] = [];
for (const b of boards) {
if (isCasualBoard(b)) casual.push(b);
else if (isAnnouncementBoard(b)) announcement.push(b);
else middle.push(b);
}
return [...casual, ...middle, ...announcement];
}

View File

@@ -0,0 +1,29 @@
import type { Comment } from '../api/types';
import type { CommentNode } from '../utils/comment';
/** 评论树中是否包含指定评论 ID */
export function commentTreeContains(node: CommentNode, commentId: number): boolean {
if (node.comment.id === commentId) return true;
return node.children.some(child => commentTreeContains(child, commentId));
}
/** 将含被采纳评论的根楼层置顶 */
export function pinAwardedCommentTree(
tree: CommentNode[],
awardedCommentId?: number,
): CommentNode[] {
if (!awardedCommentId) return tree;
const idx = tree.findIndex(node => commentTreeContains(node, awardedCommentId));
if (idx <= 0) return tree;
const next = [...tree];
const [node] = next.splice(idx, 1);
next.unshift(node);
return next;
}
/** 根据评论 ID 查找楼层号 */
export function findCommentFloor(comments: Comment[], commentId?: number): number | null {
if (!commentId) return null;
const hit = comments.find(c => c.id === commentId);
return hit?.floor ?? null;
}

View File

@@ -1,30 +1,59 @@
/** 新建帖本地草稿localStorage */
const STORAGE_KEY = 'j13-compose-draft-v1';
const STORAGE_KEY = 'j13-compose-draft-v2';
export type ComposeDraftPostType = 'normal' | 'question' | 'poll' | 'bounty' | 'lottery';
export type ComposeDraft = {
title: string;
tags: string;
content: string;
boardId: string;
postType: 'normal' | 'question';
postType: ComposeDraftPostType;
pollOptions?: string[];
pollMulti?: boolean;
pollMaxChoices?: number;
pollEndsAt?: string;
pollNoEndTime?: boolean;
savedAt: number;
};
const VALID_POST_TYPES: ComposeDraftPostType[] = ['normal', 'question', 'poll', 'bounty', 'lottery'];
function normalizePostType(value: unknown): ComposeDraftPostType {
if (typeof value === 'string' && VALID_POST_TYPES.includes(value as ComposeDraftPostType)) {
return value as ComposeDraftPostType;
}
return 'normal';
}
export function loadComposeDraft(): ComposeDraft | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw) as Partial<ComposeDraft>;
if (!data || typeof data !== 'object') return null;
return {
const postType = normalizePostType(data.postType);
const draft: ComposeDraft = {
title: typeof data.title === 'string' ? data.title : '',
tags: typeof data.tags === 'string' ? data.tags : '',
content: typeof data.content === 'string' ? data.content : '',
boardId: typeof data.boardId === 'string' ? data.boardId : '',
postType: data.postType === 'question' ? 'question' : 'normal',
postType,
savedAt: typeof data.savedAt === 'number' ? data.savedAt : Date.now(),
};
if (postType === 'poll') {
if (Array.isArray(data.pollOptions) && data.pollOptions.every(o => typeof o === 'string')) {
draft.pollOptions = data.pollOptions.length >= 2 ? data.pollOptions : ['', ''];
}
if (typeof data.pollMulti === 'boolean') draft.pollMulti = data.pollMulti;
if (typeof data.pollMaxChoices === 'number' && data.pollMaxChoices > 0) {
draft.pollMaxChoices = data.pollMaxChoices;
}
if (typeof data.pollEndsAt === 'string') draft.pollEndsAt = data.pollEndsAt;
if (typeof data.pollNoEndTime === 'boolean') draft.pollNoEndTime = data.pollNoEndTime;
}
return draft;
} catch {
return null;
}
@@ -42,6 +71,8 @@ export function saveComposeDraft(draft: Omit<ComposeDraft, 'savedAt'>): void {
export function clearComposeDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
// 清理旧版草稿键
localStorage.removeItem('j13-compose-draft-v1');
} catch {
// ignore
}
@@ -50,10 +81,14 @@ export function clearComposeDraft(): void {
/** 草稿是否有实质内容 */
export function composeDraftHasContent(d: ComposeDraft | null | undefined): boolean {
if (!d) return false;
const hasPollOptions = d.postType === 'poll'
&& Array.isArray(d.pollOptions)
&& d.pollOptions.some(o => o.trim());
return !!(
d.title.trim()
|| d.tags.trim()
|| d.content.trim()
|| (d.content && d.content.replace(/<[^>]*>/g, '').trim())
|| hasPollOptions
);
}

View File

@@ -0,0 +1,38 @@
import type { FriendLinkApply } from '../api/types';
/** 解析友链 LOGO 为可加载的 URL相对路径补全为当前站点 origin */
export function resolveFriendLinkLogo(logo?: string, siteURL?: string): string {
const raw = logo?.trim() || '';
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.startsWith('/')) {
const base = siteURL?.trim() || (typeof window !== 'undefined' ? window.location.origin : '');
return base ? `${base.replace(/\/$/, '')}${raw}` : raw;
}
return raw;
}
/** 回链检测是否仍在后台进行中 */
export function isReciprocalChecking(apply: FriendLinkApply): boolean {
if (apply.status !== 'pending') return false;
if (apply.reciprocal_checked_at) return false;
if (apply.reciprocal_verified) return false;
if (apply.reciprocal_check_note?.trim()) return false;
return true;
}
export function reciprocalStatusLabel(apply: FriendLinkApply): {
text: string;
variant: 'green' | 'orange' | 'secondary';
} {
if (isReciprocalChecking(apply)) {
return { text: '检测中…', variant: 'secondary' };
}
if (apply.reciprocal_verified) {
return { text: '回链已检测到', variant: 'green' };
}
if (apply.reciprocal_check_note?.trim()) {
return { text: apply.reciprocal_check_note, variant: 'orange' };
}
return { text: '未检测', variant: 'secondary' };
}

View File

@@ -1,8 +1,7 @@
import type { Board, ForumStats, RecentComment, PostItem, TagCount } from '../api/types';
import type { Board, ForumStats, RecentComment, TagCount } from '../api/types';
const BOARDS_KEY = 'j13-cache-boards';
const STATS_KEY = 'j13-cache-stats';
const HOT_KEY = 'j13-cache-hot';
const RECENT_COMMENTS_KEY = 'j13-cache-recent-comments';
const TAGS_KEY = 'j13-cache-tags';
@@ -33,12 +32,6 @@ export function getCachedStats(): ForumStats | null {
return readJson<ForumStats>(STATS_KEY);
}
/** 读取缓存的热门帖子,避免右栏/抽屉首屏高度跳动 */
export function getCachedHot(): PostItem[] {
const list = readJson<PostItem[]>(HOT_KEY);
return Array.isArray(list) ? list : [];
}
/** 读取缓存的最新评论,避免右栏/抽屉首屏高度跳动 */
export function getCachedRecentComments(): RecentComment[] {
const list = readJson<RecentComment[]>(RECENT_COMMENTS_KEY);
@@ -54,7 +47,7 @@ export function getCachedTags(): TagCount[] {
/** 右栏是否已有可展示的 session 缓存(含空列表) */
export function hasCachedAside(): boolean {
try {
return sessionStorage.getItem(HOT_KEY) != null || sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
return sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
} catch {
return false;
}
@@ -68,10 +61,6 @@ export function setCachedStats(stats: ForumStats) {
writeJson(STATS_KEY, stats);
}
export function setCachedHot(posts: PostItem[]) {
writeJson(HOT_KEY, posts);
}
export function setCachedRecentComments(list: RecentComment[]) {
writeJson(RECENT_COMMENTS_KEY, list);
}

View File

@@ -30,6 +30,25 @@ export function userPath(id: number | string, opts?: PermalinkOpts): string {
return `/user/${id}${suffix(opts)}`;
}
/** 板块规范路径:/board/123 或 /board/123.html */
export function boardPath(id: number | string, opts?: PermalinkOpts): string {
return `/board/${id}${suffix(opts)}`;
}
/** 自定义单页规范路径 */
export function pagePath(slug: string, opts?: PermalinkOpts): string {
const s = slug.trim().toLowerCase();
if (!s) return '/';
return `/page/${s}${suffix(opts)}`;
}
/** 从 slug 路由参数解析(兼容 about / about.html */
export function parsePermalinkSlug(raw: string | undefined): string {
if (!raw) return '';
const m = String(raw).match(/^([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])(?:\.[A-Za-z0-9]{1,16})?$/i);
return m ? m[1].toLowerCase() : '';
}
/** 从路由参数解析数字 ID兼容 123 / 123.html */
export function parsePermalinkID(raw: string | undefined): number {
if (!raw) return NaN;
@@ -39,13 +58,17 @@ export function parsePermalinkID(raw: string | undefined): number {
/** 客户端:若当前 URL 非规范伪静态路径则返回应跳转的目标 */
export function canonicalRedirectPath(
kind: 'post' | 'user',
kind: 'post' | 'user' | 'board',
id: number,
currentPathname: string,
opts?: PermalinkOpts,
): string | null {
if (!id || Number.isNaN(id)) return null;
const target = kind === 'post' ? postPath(id, opts) : userPath(id, opts);
const target = kind === 'post'
? postPath(id, opts)
: kind === 'user'
? userPath(id, opts)
: boardPath(id, opts);
const cur = currentPathname.replace(/\/$/, '') || '/';
const want = target.replace(/\/$/, '') || '/';
return cur === want ? null : target;

View File

@@ -0,0 +1,69 @@
import { arrayMove } from '@dnd-kit/sortable';
export const ADMIN_SORTABLE_MOVE_BUTTONS_THRESHOLD = 8;
export function reorderItems<T>(items: T[], from: number, to: number): T[] {
if (from < 0 || to < 0 || from >= items.length || to >= items.length || from === to) {
return items;
}
return arrayMove(items, from, to);
}
export function assignSortOrders<T extends { sort_order?: number }>(items: T[]): T[] {
return items.map((item, index) => ({ ...item, sort_order: index + 1 }));
}
export function diffSortOrderChanges<T extends { id: number; sort_order?: number }>(
before: T[],
after: T[],
): Array<{ id: number; sort_order: number }> {
const beforeMap = new Map(before.map(item => [item.id, item.sort_order ?? 0]));
return after
.filter(item => beforeMap.get(item.id) !== item.sort_order)
.map(item => ({ id: item.id, sort_order: item.sort_order ?? 0 }));
}
/** 将子集重排结果合并回完整列表,并重算 sort_order */
export function mergeReorderedSubset<T extends { id: number; sort_order?: number }>(
all: T[],
subsetBefore: T[],
subsetAfter: T[],
): T[] {
const sorted = [...all].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id);
const subsetIds = new Set(subsetBefore.map(item => item.id));
const result: T[] = [];
let subsetIdx = 0;
for (const item of sorted) {
if (subsetIds.has(item.id)) {
if (subsetIdx < subsetAfter.length) {
result.push(subsetAfter[subsetIdx++]);
}
} else {
result.push(item);
}
}
return assignSortOrders(result);
}
export async function persistSortOrderChanges<T extends { id: number; sort_order?: number }>(
before: T[],
reordered: T[],
updateItem: (item: T) => Promise<void>,
): Promise<T[]> {
const after = assignSortOrders(reordered);
const changes = diffSortOrderChanges(before, after);
for (const change of changes) {
const item = after.find(row => row.id === change.id);
if (item) await updateItem(item);
}
return after;
}
export function shouldShowSortableMoveButtons(
count: number,
mode: boolean | 'auto' = 'auto',
): boolean {
if (mode === true) return true;
if (mode === false) return false;
return count > ADMIN_SORTABLE_MOVE_BUTTONS_THRESHOLD;
}