feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
70
frontend/src/utils/asideWidgets.ts
Normal file
70
frontend/src/utils/asideWidgets.ts
Normal 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 };
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
29
frontend/src/utils/bounty.ts
Normal file
29
frontend/src/utils/bounty.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
38
frontend/src/utils/friendLink.ts
Normal file
38
frontend/src/utils/friendLink.ts
Normal 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' };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
frontend/src/utils/sortOrder.ts
Normal file
69
frontend/src/utils/sortOrder.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user