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,276 @@
import { useEffect, useRef, useState } from 'react';
import { Link2, Upload } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../api/client';
import type { FriendLinkApply } from '../api/types';
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { resolveFriendLinkLogo } from '../utils/friendLink';
import FriendLinkSiteInfo from './FriendLinkSiteInfo';
type LinkPlacement = 'homepage' | 'custom';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editApply?: FriendLinkApply | null;
onSubmitted?: () => void;
}
/** 友链申请 / 修改弹窗 */
export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, onSubmitted }: Props) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [logo, setLogo] = useState('');
const [linkPlacement, setLinkPlacement] = useState<LinkPlacement>('homepage');
const [reciprocalPageURL, setReciprocalPageURL] = useState('');
const [submitting, setSubmitting] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const isEdit = !!editApply?.id;
const reset = () => {
setName('');
setUrl('');
setLogo('');
setLinkPlacement('homepage');
setReciprocalPageURL('');
};
useEffect(() => {
if (!open) return;
if (editApply) {
setName(editApply.name ?? '');
setUrl(editApply.url ?? '');
setLogo(editApply.logo ?? '');
const onHomepage = editApply.link_on_homepage !== false;
setLinkPlacement(onHomepage ? 'homepage' : 'custom');
setReciprocalPageURL(onHomepage ? '' : (editApply.reciprocal_page_url ?? ''));
} else {
reset();
}
}, [open, editApply]);
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const uploadLogo = async (file: File | undefined) => {
if (!file) return;
setUploadingLogo(true);
try {
const r = await api.uploadFriendLinkLogo(file);
setLogo(r.url);
notify.success('LOGO 已上传');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '上传失败');
} finally {
setUploadingLogo(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const submit = async () => {
const trimmedName = name.trim();
const trimmedURL = url.trim();
const trimmedLogo = logo.trim();
const trimmedReciprocal = reciprocalPageURL.trim();
if (!trimmedName || !trimmedURL) {
notify.warning('请填写网站名称与网站链接');
return;
}
if (!/^https?:\/\//i.test(trimmedURL)) {
notify.warning('网站链接需以 http:// 或 https:// 开头');
return;
}
if (!trimmedLogo) {
notify.warning('请填写或上传网站 LOGO');
return;
}
if (linkPlacement === 'custom') {
if (!trimmedReciprocal) {
notify.warning('请填写添加本站链接的页面地址');
return;
}
if (!/^https?:\/\//i.test(trimmedReciprocal)) {
notify.warning('回链页地址需以 http:// 或 https:// 开头');
return;
}
}
const linkOnHomepage = linkPlacement === 'homepage';
const body = {
name: trimmedName,
url: trimmedURL,
logo: trimmedLogo,
link_on_homepage: linkOnHomepage,
reciprocal_page_url: linkOnHomepage ? trimmedURL : trimmedReciprocal,
};
setSubmitting(true);
try {
if (isEdit) {
const r = await api.updateFriendLinkApply(editApply!.id, body);
notify.success(
editApply?.status === 'approved'
? `已更新并重新提交审核,友链已暂时从列表移除${r.message.includes('回链检测') ? ',回链检测将在后台进行' : ''}`
: r.message,
);
} else {
const r = await api.applyFriendLink(body);
notify.success(r.message);
}
onSubmitted?.();
handleOpenChange(false);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
};
const logoPreview = resolveFriendLinkLogo(logo.trim(), getCachedSiteBranding().site_url);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="friend-link-apply-dialog sm:max-w-[560px]">
<DialogHeader className="friend-link-apply-dialog__header">
<DialogTitle>{isEdit ? '修改友链申请' : '申请本页友情链接'}</DialogTitle>
</DialogHeader>
<FriendLinkSiteInfo />
<div className="friend-link-apply-form">
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-name"></Label>
<Input
id="friend-link-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="请输入您的网站名称"
maxLength={32}
/>
</div>
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-url"></Label>
<Input
id="friend-link-url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="请输入您的网站地址(以 http 开头)"
maxLength={512}
/>
</div>
<div className="friend-link-apply-field">
<Label></Label>
<div className="friend-link-apply-placement" role="radiogroup" aria-label="本站链接放置位置">
<button
type="button"
role="radio"
aria-checked={linkPlacement === 'homepage'}
className={cn(
'friend-link-apply-placement__option',
linkPlacement === 'homepage' && 'friend-link-apply-placement__option--active',
)}
onClick={() => setLinkPlacement('homepage')}
>
</button>
<button
type="button"
role="radio"
aria-checked={linkPlacement === 'custom'}
className={cn(
'friend-link-apply-placement__option',
linkPlacement === 'custom' && 'friend-link-apply-placement__option--active',
)}
onClick={() => setLinkPlacement('custom')}
>
</button>
</div>
</div>
{linkPlacement === 'custom' && (
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-reciprocal"></Label>
<Input
id="friend-link-reciprocal"
value={reciprocalPageURL}
onChange={e => setReciprocalPageURL(e.target.value)}
placeholder="如https://您的域名/link.htm"
maxLength={512}
/>
<p className="friend-link-apply-field__hint">
</p>
</div>
)}
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-logo"> LOGO</Label>
<div className="friend-link-apply-logo-row">
<div className="friend-link-apply-logo-preview" aria-label="LOGO 预览">
{logoPreview ? (
<img src={logoPreview} alt="" loading="lazy" decoding="async" />
) : (
<span></span>
)}
</div>
<Input
id="friend-link-logo"
className="friend-link-apply-logo-input"
value={logo}
onChange={e => setLogo(e.target.value)}
placeholder="LOGO 图片地址"
maxLength={512}
/>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="sr-only"
onChange={e => uploadLogo(e.target.files?.[0])}
/>
<Button
type="button"
variant="outline"
className="friend-link-apply-logo-upload"
disabled={uploadingLogo}
onClick={() => fileRef.current?.click()}
>
{uploadingLogo ? <Spinner size="sm" /> : <Upload size={15} aria-hidden />}
</Button>
</div>
</div>
</div>
<DialogFooter className="friend-link-apply-dialog__footer">
<p className="friend-link-apply-dialog__note">
<Link2 size={14} aria-hidden />
</p>
<div className="friend-link-apply-dialog__actions">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
</Button>
<Button type="button" disabled={submitting || uploadingLogo} onClick={submit}>
{submitting ? '提交中…' : isEdit ? '保存并提交' : '提交申请'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}