This commit is contained in:
eric
2026-03-18 00:53:28 -05:00
parent 4d4ca04167
commit 742c293e14
4 changed files with 153 additions and 20 deletions

View File

@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
const xiaohongshuUrl = String(formData.xiaohongshu_url || "").trim();
const xiaohongshuAvatarUrl = String(formData.xiaohongshu_avatar_url || formData.media || "").trim();
const gender = String(formData.gender || "").trim() || "\u65e0";
let intro = String(formData.intro || "").trim();
let intro = String(formData.intro || formData.reason || "").trim();
const firstTime = String(formData.first_time || "").trim();
const agreeRules = !!formData.agree_rules;
const status = String(formData.status || "").trim();
@@ -61,6 +61,24 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
if (!xiaohongshuUrl) {
return NextResponse.json(
{ ok: false, error: "请填写小红书主页链接或笔记链接" },
{ status: 400 }
);
}
if (!intro) {
return NextResponse.json(
{ ok: false, error: "请填写自我介绍" },
{ status: 400 }
);
}
if (intro.length < 20) {
return NextResponse.json(
{ ok: false, error: "自我介绍不少于20字" },
{ status: 400 }
);
}
const userId = getUserIdFromCookie(request) || generateAnonId();
@@ -68,6 +86,7 @@ export async function POST(request: NextRequest) {
/** occupation: 职业/城市reason: 仅自我介绍 */
const occupationValue = profession || city;
const userInfo = String(formData.userInfo || "").trim();
const record: Record<string, unknown> = {
user_id: userId,
nickname,
@@ -87,6 +106,7 @@ export async function POST(request: NextRequest) {
first_time: firstTime,
is_volunteer: isVolunteer,
qualify,
userInfo: userInfo || "",
};
if (phone.trim()) {
record.phone = phone.trim();
@@ -177,6 +197,7 @@ async function tryPocketBaseDirect(
is_approved: qualify,
age_range: record.age_range ?? "",
first_time: record.first_time ?? "",
userInfo: record.userInfo ?? "",
};
if (record.phone != null && String(record.phone).trim()) {
body.phone = record.phone;

View File

@@ -13,16 +13,15 @@ function getSalonApiBase(): string {
return resolvePaymentApiUrl();
}
/** 代理到 salonapi 获取小红书用户资料 */
/** 代理到 salonapi 获取小红书用户资料,支持主页链接、笔记链接 xhslink.com/o/ */
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const url = String(body?.url || "").trim();
const url = String(body?.url || body?.id || "").trim();
if (!url) {
return NextResponse.json({ ok: false, error: "请输入小红书链接" }, { status: 400 });
}
const apiBase = getSalonApiBase();
const res = await fetch(`${apiBase}/api/salon/xiaohongshu/profile`, {
method: "POST",

View File

@@ -1,28 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
/** 支持xiaohongshu.com/user/profile/xxx、xhslink.com/user/profile/xxx、xhslink.com/m/xxx 短链 */
/** 支持:主页 xiaohongshu.com/user/profile/xxx、xhslink.com/m/xxx笔记 xhslink.com/o/xxx、xiaohongshu.com/explore/xxx */
const XH_PROFILE_PATTERN = /^https?:\/\/(www\.)?(xiaohongshu\.com|xhslink\.com)\/user\/profile\/[a-zA-Z0-9_-]+/i;
const XH_SHORT_PATTERN = /^https?:\/\/xhslink\.com\/m\/[a-zA-Z0-9_-]+/i;
const XH_NOTE_PATTERN = /^https?:\/\/(xhslink\.com\/o\/|(www\.)?xiaohongshu\.com\/explore\/)[a-zA-Z0-9_-]+/i;
function isValidUrl(url: string): boolean {
return XH_PROFILE_PATTERN.test(url) || XH_SHORT_PATTERN.test(url);
const s = url.trim();
if (!s) return false;
if (XH_PROFILE_PATTERN.test(s) || XH_SHORT_PATTERN.test(s) || XH_NOTE_PATTERN.test(s)) return true;
return false;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const url = String(body?.url || "").trim();
const url = String(body?.url || body?.id || "").trim();
if (!url) {
return NextResponse.json({ valid: false, error: "请输入小红书链接" }, { status: 400 });
}
const valid = isValidUrl(url);
if (!valid) {
return NextResponse.json({ valid: false, error: "小红书link异常" }, { status: 200 });
if (!isValidUrl(url)) {
return NextResponse.json({ valid: false, error: "请输入有效的主页链接或笔记链接xhslink.com/m/xxx 或 xhslink.com/o/xxx" }, { status: 200 });
}
return NextResponse.json({ valid: true });
return NextResponse.json({ valid: true, url });
} catch {
return NextResponse.json({ valid: false, error: "校验失败" }, { status: 500 });
}

View File

@@ -19,7 +19,7 @@ const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
/** 微信号仅允许英文字母、数字、下划线、连字符 */
const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/;
/** 从粘贴文本中提取小红书 URL,如 "我在小红书收获了536次赞与收藏>> https://xhslink.com/m/9H50QYiVOVx" */
/** 从粘贴文本中提取小红书 URL(主页或笔记链接),清除前后空格 */
function extractXiaohongshuUrl(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
@@ -133,6 +133,7 @@ export default function JoinPage() {
const [checkingWechat, setCheckingWechat] = useState(false);
const [xiaohongshuUrl, setXiaohongshuUrl] = useState("");
const [intro, setIntro] = useState("");
const [profession, setProfession] = useState("");
const [gender, setGender] = useState("");
const [ageRange, setAgeRange] = useState("");
@@ -146,6 +147,14 @@ export default function JoinPage() {
ipLocation?: string;
avatarUrl?: string;
resolvedUrl?: string;
followers?: number;
followersText?: string;
following?: number;
followingText?: string;
likesAndCollects?: number;
likesAndCollectsText?: string;
redbookId?: string;
personalLink?: string;
} | null>(null);
const [validating, setValidating] = useState(false);
const [submitted, setSubmitted] = useState(false);
@@ -210,7 +219,7 @@ export default function JoinPage() {
const handleUrlBlur = async () => {
const extracted = extractXiaohongshuUrl(xiaohongshuUrl);
if (extracted !== xiaohongshuUrl) {
if (extracted !== xiaohongshuUrl.trim()) {
setXiaohongshuUrl(extracted);
}
const url = extracted;
@@ -230,7 +239,7 @@ export default function JoinPage() {
});
const validateData = await validateRes.json();
if (!validateData.valid) {
setUrlError(validateData.error || "小红书link异常");
setUrlError(validateData.error || "小红书链接异常");
return;
}
const profileRes = await fetch("/api/xiaohongshu/profile", {
@@ -248,6 +257,14 @@ export default function JoinPage() {
ipLocation: profileData.ipLocation,
avatarUrl: profileData.avatarUrl,
resolvedUrl: profileData.resolvedUrl,
followers: profileData.followers,
followersText: profileData.followersText,
following: profileData.following,
followingText: profileData.followingText,
likesAndCollects: profileData.likesAndCollects,
likesAndCollectsText: profileData.likesAndCollectsText,
redbookId: profileData.redbookId,
personalLink: profileData.personalLink,
});
} else {
setUrlError(profileData.error || "获取资料失败");
@@ -270,10 +287,22 @@ export default function JoinPage() {
setError("该微信号已报名,请勿重复提交");
return;
}
if (!xiaohongshuUrl.trim()) {
setError("请填写小红书主页链接或笔记链接");
return;
}
if (xiaohongshuUrl.trim() && urlError) {
setError("请先修正小红书链接");
return;
}
if (!intro.trim()) {
setError("请填写自我介绍");
return;
}
if (intro.trim().length < 20) {
setError("自我介绍不少于20字");
return;
}
if (!profession.trim()) {
setError("请填写职业");
return;
@@ -310,7 +339,7 @@ export default function JoinPage() {
const postCountOk = (profile?.postCount ?? 0) > 10;
const qualify = formGenderFemale && postCountOk;
const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无";
const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim() || "";
const submitXiaohongshuUrl = profile?.resolvedUrl || extractXiaohongshuUrl(xiaohongshuUrl) || "";
const res = await fetch("/api/join", {
method: "POST",
@@ -323,7 +352,27 @@ export default function JoinPage() {
xiaohongshu_url: submitXiaohongshuUrl,
xiaohongshu_avatar_url: profile?.avatarUrl || "",
gender: genderLabel,
intro: "",
intro: intro.trim(),
reason: intro.trim(),
userInfo: profile
? JSON.stringify({
昵称: profile.nickname,
性别: profile.gender,
笔记数: profile.postCount,
笔记数文案: profile.postCountText,
IP属地: profile.ipLocation,
头像链接: profile.avatarUrl,
主页链接: profile.resolvedUrl,
粉丝数: profile.followers,
粉丝数文案: profile.followersText,
关注数: profile.following,
关注数文案: profile.followingText,
获赞与收藏: profile.likesAndCollects,
获赞与收藏文案: profile.likesAndCollectsText,
小红书号: profile.redbookId,
个人链接: profile.personalLink,
})
: "",
age_range: ageRange,
city: "深圳",
session: fromVolunteer ? "volunteer" : "digital-nomad",
@@ -377,6 +426,7 @@ export default function JoinPage() {
setUserRecord(null);
setWechat("");
setXiaohongshuUrl("");
setIntro("");
setProfession("");
setGender("");
setAgeRange("");
@@ -813,19 +863,79 @@ export default function JoinPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📱 </label>
<p className="text-xs text-stone-500 dark:text-stone-400 mb-1.5">💡 </p>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📱 <span className="text-red-500">*</span></label>
<input
type="text"
value={xiaohongshuUrl}
onChange={(e) => { setXiaohongshuUrl(e.target.value); setUrlError(null); }}
onBlur={handleUrlBlur}
placeholder="https://www.xiaohongshu.com/user/profile/xxx 或粘贴分享文案"
required
className="form-input"
/>
{validating && <p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">🔄 </p>}
{urlError && !validating && <p className="mt-1.5 text-xs text-red-600 dark:text-red-400"> {urlError}</p>}
{profile && !urlError && <p className="mt-1.5 text-xs text-emerald-600 dark:text-emerald-400"></p>}
{profile && !urlError && (
<>
<p className="mt-1.5 text-xs text-emerald-600 dark:text-emerald-400"></p>
{process.env.NODE_ENV === "development" && (
<div className="mt-3 rounded-xl border border-stone-200 dark:border-stone-600 bg-stone-50 dark:bg-stone-800/50 p-4">
<div className="flex items-center gap-3">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt=""
className="h-14 w-14 rounded-full object-cover border border-stone-200 dark:border-stone-600"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40 text-2xl">
👤
</div>
)}
<div>
<p className="font-medium text-stone-800 dark:text-stone-100">{profile.nickname}</p>
{profile.gender && <p className="text-xs text-stone-500 dark:text-stone-400"> {profile.gender}</p>}
{profile.redbookId && <p className="text-xs text-stone-500 dark:text-stone-400"> {profile.redbookId}</p>}
{profile.ipLocation && <p className="text-xs text-stone-500 dark:text-stone-400">IP属地 {profile.ipLocation}</p>}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-stone-600 dark:text-stone-400">
<span> {profile.followingText ?? profile.following ?? "—"}</span>
<span> {profile.followersText ?? profile.followers ?? "—"}</span>
<span> {profile.likesAndCollectsText ?? profile.likesAndCollects ?? "—"}</span>
<span> {profile.postCountText ?? profile.postCount ?? "—"}</span>
</div>
{profile.personalLink && (
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400 truncate"> {profile.personalLink}</p>
)}
{profile.resolvedUrl && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500 truncate">Profile {profile.resolvedUrl}</p>
)}
</div>
)}
</>
)}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2"> <span className="text-red-500">*</span></label>
<div className="relative">
<textarea
rows={4}
maxLength={500}
minLength={20}
placeholder="不少于20字"
value={intro}
onChange={(e) => setIntro(e.target.value)}
required
className="form-input resize-y min-h-[80px] pr-14"
/>
<span
className={`absolute bottom-2 right-3 text-xs ${
intro.length < 20 ? "text-amber-600 dark:text-amber-400" : "text-stone-400 dark:text-stone-500"
}`}
>
{intro.length}/500
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">💼 <span className="text-red-500">*</span></label>