Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/app/api/profile/update/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ProfileUpdateResponse = {
activityVisibility: UserProfileVisibility
introductionVisibility: UserProfileVisibility
points: number
avatarPointCost: number
}

function toProfileUpdateResponse(input: {
Expand All @@ -49,7 +50,7 @@ function toProfileUpdateResponse(input: {
activityVisibility: UserProfileVisibility
introductionVisibility: UserProfileVisibility
points: number
}): ProfileUpdateResponse {
}, avatarPointCost = 0): ProfileUpdateResponse {
return {
username: input.username,
nickname: input.nickname ?? "",
Expand All @@ -68,6 +69,7 @@ function toProfileUpdateResponse(input: {
activityVisibility: input.activityVisibility,
introductionVisibility: input.introductionVisibility,
points: input.points,
avatarPointCost,
}
}

Expand Down Expand Up @@ -340,6 +342,7 @@ export const POST = createUserRouteHandler<ProfileUpdateResponse>(async ({ reque
userId: currentUser.id,
})
: { scopeKey: "AVATAR_CHANGE" as const, baseDelta: 0, finalDelta: 0, appliedRules: [] }
const avatarPointCost = Math.max(0, -avatarCostDelta.finalDelta)
const totalRequiredPoints = [
nicknameCostDelta.finalDelta,
introductionCostDelta.finalDelta,
Expand Down Expand Up @@ -585,7 +588,7 @@ export const POST = createUserRouteHandler<ProfileUpdateResponse>(async ({ reque
searchParams: requestUrl.searchParams,
})

return apiSuccess(toProfileUpdateResponse(updatedProfile), messageParts.join(","))
return apiSuccess(toProfileUpdateResponse(updatedProfile, avatarPointCost), messageParts.join(","))


}, {
Expand All @@ -594,4 +597,3 @@ export const POST = createUserRouteHandler<ProfileUpdateResponse>(async ({ reque
unauthorizedMessage: "请先登录",
allowStatuses: ["ACTIVE", "MUTED"],
})

34 changes: 23 additions & 11 deletions src/components/profile/avatar-crop-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,31 +143,44 @@ export function AvatarCropModal({
}
}

async function handleUploadOriginal() {
if (!onUploadOriginal) {
return
}

setSaving(true)

try {
await onUploadOriginal()
} catch {
// Parent handles user-facing error messages.
} finally {
setSaving(false)
}
}

return (
<Modal
open={open}
onClose={onClose}
title="裁剪头像"
title="剪裁头像"
hideHeaderCloseButtonOnMobile
description="先裁剪成正方形头像,再上传到预览区。上传后还需要在资料页点击“确认保存头像”才会正式生效。"
description="可以直接使用原图,或调整取景后剪裁保存。提交成功后头像立即生效。"
size="xl"
closeDisabled={saving}
footer={(
<div className="flex flex-col gap-3 sm:items-end">
<p className="text-xs text-muted-foreground">上传只是生成预览,最后一步请回到资料页确认保存。</p>
<div className="flex flex-wrap justify-end gap-3">
<div className="flex flex-wrap justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
取消
</Button>
{onUploadOriginal ? (
<Button type="button" variant="outline" onClick={onUploadOriginal} disabled={saving}>
{saving ? "上传中..." : "不裁剪,上传到预览"}
<Button type="button" variant="outline" onClick={handleUploadOriginal} disabled={saving}>
{saving ? "保存中..." : "不剪裁提交保存"}
</Button>
) : null}
<Button type="button" onClick={handleConfirm} disabled={saving || !croppedAreaPixels}>
{saving ? "上传中..." : "裁剪并上传到预览"}
{saving ? "保存中..." : "剪裁并提交保存"}
</Button>
</div>
</div>
)}
>
Expand Down Expand Up @@ -220,7 +233,7 @@ export function AvatarCropModal({
<div className="space-y-4">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-sm font-medium">裁剪结果预览</p>
<p className="mt-1 text-xs text-muted-foreground">上传保存前,先看一下三种尺寸下的显示效果。</p>
<p className="mt-1 text-xs text-muted-foreground">提交保存前,先看一下三种尺寸下的显示效果。</p>
<div className="mt-4 space-y-4">
<PreviewSize label="大尺寸" size="lg" avatarPath={previewUrl} name={previewName} loading={previewLoading} />
<PreviewSize label="中尺寸" size="md" avatarPath={previewUrl} name={previewName} loading={previewLoading} />
Expand All @@ -231,7 +244,6 @@ export function AvatarCropModal({
<div className="rounded-xl border border-dashed border-border bg-background/70 p-4 text-xs leading-6 text-muted-foreground">
<p>1. 头像会按你当前裁剪结果导出为正方形图片。</p>
<p>2. 建议把主体放在圆形框中央,避免小尺寸下边缘被裁掉。</p>
<p>3. 点击“裁剪并上传到预览”后,仍需回到资料页点击“确认保存头像”才会正式写入个人资料。</p>
</div>
</div>
</div>
Expand Down
91 changes: 42 additions & 49 deletions src/components/profile/profile-edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import dynamic from "next/dynamic"
import { useRouter } from "next/navigation"
import { useEffect, useMemo, useRef, useState } from "react"
import { AlertCircle, Camera, CheckCircle2, LoaderCircle, Mail, PencilLine, Smartphone, UserRound } from "lucide-react"
import { Camera, LoaderCircle, Mail, PencilLine, Smartphone, UserRound } from "lucide-react"

import { PasswordChangeForm } from "@/components/profile/password-change-form"
import { Modal } from "@/components/ui/modal"
Expand Down Expand Up @@ -207,18 +207,6 @@ export function ProfileEditForm({
const normalizedSavedAvatarPath = savedAvatarPath.trim()
const normalizedPendingAvatarPath = pendingAvatarPath.trim()
const hasSavedAvatar = normalizedSavedAvatarPath.length > 0
const avatarChanged = useMemo(() => normalizedPendingAvatarPath !== normalizedSavedAvatarPath, [normalizedPendingAvatarPath, normalizedSavedAvatarPath])
const avatarRequiresPayment = avatarChanged && hasSavedAvatar
const avatarStatusTitle = avatarChanged
? normalizedPendingAvatarPath
? "新头像已上传,尚未保存"
: "头像已重置,尚未保存"
: "头像设置已保存"
const avatarStatusDescription = avatarChanged
? normalizedPendingAvatarPath
? "当前页面预览的是新头像。请点击“确认保存头像”完成最后一步,否则离开页面后不会生效。"
: "当前操作会恢复默认头像。请点击右侧“确认保存头像”完成最后一步。"
: ""
const nicknameHint = nicknameChangePointCost > 0
? nicknameChanged
? `本次修改昵称将扣除 ${nicknameChangePointCost} ${pointName}。${nicknameChangePriceDescription ? `${nicknameChangePriceDescription}。` : ""}昵称全站唯一。`
Expand All @@ -231,14 +219,8 @@ export function ProfileEditForm({
: `${introductionChangePriceDescription ? `${introductionChangePriceDescription},` : ""}当前修改介绍免费,支持 Markdown。`
const avatarHint = avatarChangePointCost > 0
? !hasSavedAvatar
? avatarChanged
? `这是你首次设置头像,本次保存免费。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}上传后需手动确认保存。`
: `你还没有上传过头像,首次设置免费。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}上传后需手动确认保存。`
: avatarRequiresPayment
? normalizedPendingAvatarPath
? `本次更换头像将扣除 ${avatarChangePointCost} ${pointName}。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}上传后需手动确认保存。`
: `本次重置头像将扣除 ${avatarChangePointCost} ${pointName}。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}重置后会恢复默认头像。`
: `更换头像或重置头像需消耗 ${avatarChangePointCost} ${pointName}。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}上传后需手动确认保存。`
? `你还没有设置头像,首次设置免费。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}`
: `更换头像或重置头像将消耗 ${avatarChangePointCost} ${pointName}。${avatarChangePriceDescription ? `${avatarChangePriceDescription}。` : ""}`
: `${avatarChangePriceDescription ? `${avatarChangePriceDescription},` : ""}首次设置、更换头像和重置头像当前都免费。`
const avatarRules = [
avatarChangePointCost > 0
Expand All @@ -249,7 +231,13 @@ export function ProfileEditForm({
]

useEffect(() => {
setActiveSection(normalizedSections.includes(initialSection) ? initialSection : normalizedSections[0])
setActiveSection((current) => {
if (normalizedSections.includes(current)) {
return current
}

return normalizedSections.includes(initialSection) ? initialSection : normalizedSections[0]
})
}, [initialSection, normalizedSections])

useEffect(() => {
Expand Down Expand Up @@ -300,12 +288,22 @@ export function ProfileEditForm({
router.refresh()
}

async function uploadAvatarFile(file: File) {
function showAvatarSaveSuccess(result: { data?: { avatarPointCost?: unknown } }, fallbackPointCost: number) {
const responsePointCost = Number(result.data?.avatarPointCost)
const consumedPointCost = Number.isFinite(responsePointCost)
? Math.max(0, responsePointCost)
: Math.max(0, fallbackPointCost)

toast.success(`头像保存成功,消耗 ${consumedPointCost} ${pointName}`, "头像保存成功")
}

async function saveAvatarFile(file: File) {
const fallbackPreviewUrl = previewUrl || pendingAvatarPath || savedAvatarPath || initialAvatarPath || ""
const nextPreviewUrl = URL.createObjectURL(file)

updatePreviewUrl(nextPreviewUrl)
setUploading(true)
setAvatarSaving(true)

const formData = new FormData()
formData.append("file", file)
Expand All @@ -323,16 +321,24 @@ export function ProfileEditForm({
}

const uploadedPath = result.data?.urlPath ?? ""
setPendingAvatarPath(uploadedPath)
updatePreviewUrl(uploadedPath || nextPreviewUrl)
if (!uploadedPath) {
throw new Error("头像上传成功,但未返回文件地址")
}

const profileResult = await updateProfile({ avatarPath: uploadedPath })
const nextAvatarPath = profileResult.data?.avatarPath ?? uploadedPath
setSavedAvatarPath(nextAvatarPath)
setPendingAvatarPath(nextAvatarPath)
updatePreviewUrl(nextAvatarPath)
clearCropSource()
toast.success("新头像已进入预览,请点击“确认保存头像”完成最后一步", "头像待保存")
showAvatarSaveSuccess(profileResult, hasSavedAvatar ? avatarChangePointCost : 0)
} catch (error) {
updatePreviewUrl(fallbackPreviewUrl)
toast.error(error instanceof Error ? error.message : "头像上传失败", "头像上传失败")
toast.error(error instanceof Error ? error.message : "头像保存失败", "头像保存失败")
throw error
} finally {
setUploading(false)
setAvatarSaving(false)
}
}

Expand Down Expand Up @@ -485,27 +491,27 @@ export function ProfileEditForm({
}

async function handleAvatarCropConfirm(croppedFile: File) {
await uploadAvatarFile(croppedFile)
await saveAvatarFile(croppedFile)
}

async function handleAvatarOriginalUpload() {
if (!cropSourceFile) {
return
}

await uploadAvatarFile(cropSourceFile)
await saveAvatarFile(cropSourceFile)
}

async function handleAvatarSave() {
async function handleAvatarReset() {
setAvatarSaving(true)

try {
const result = await updateProfile({ avatarPath: pendingAvatarPath })
const nextAvatarPath = result.data?.avatarPath ?? pendingAvatarPath
const result = await updateProfile({ avatarPath: "" })
const nextAvatarPath = result.data?.avatarPath ?? ""
setSavedAvatarPath(nextAvatarPath)
setPendingAvatarPath(nextAvatarPath)
updatePreviewUrl(nextAvatarPath)
toast.success(result.message ?? "头像已更新", "头像保存成功")
showAvatarSaveSuccess(result, hasSavedAvatar ? avatarChangePointCost : 0)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败", "头像保存失败")
} finally {
Expand Down Expand Up @@ -709,7 +715,7 @@ export function ProfileEditForm({
</div>
<div>
<p className="text-base font-semibold">当前头像</p>
<p className="mt-2 text-sm text-muted-foreground">支持图片上传,先裁剪再上传,并在保存前预览最终效果。</p>
<p className="mt-2 text-sm text-muted-foreground">选择图片后,可直接使用原图或剪裁取景并提交保存。</p>
<p className="mt-1 text-xs text-muted-foreground">{avatarHint}</p>
<p className="mt-1 text-xs text-muted-foreground">建议使用清晰正方形头像,大小控制在 {normalizedAvatarMaxFileSizeMb}MB 以内。</p>
</div>
Expand All @@ -722,26 +728,13 @@ export function ProfileEditForm({
<Button
type="button"
variant="outline"
onClick={() => {
setPendingAvatarPath("")
updatePreviewUrl("")
}}
onClick={handleAvatarReset}
disabled={uploading || avatarSaving || (!normalizedPendingAvatarPath && !normalizedSavedAvatarPath)}
>
重置头像
</Button>
<Button type="button" size="lg" onClick={handleAvatarSave} disabled={uploading || avatarSaving || !avatarChanged} className={avatarChanged ? "shadow-sm" : undefined}>
{avatarSaving ? "保存中..." : avatarChanged ? "确认保存头像" : "头像已保存"}
{avatarSaving ? "保存中..." : "重置头像"}
</Button>
</div>
</div>
<div className={avatarChanged ? "flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-amber-900" : "flex items-start gap-3 rounded-xl border border-border bg-secondary/40 px-4 py-3 text-muted-foreground"}>
{avatarChanged ? <AlertCircle className="mt-0.5 size-4 shrink-0" /> : <CheckCircle2 className="mt-0.5 size-4 shrink-0" />}
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{avatarStatusTitle}</p>
<p className="text-xs leading-6">{avatarStatusDescription}</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<AvatarPreviewCard label="大尺寸" size="lg" avatarPath={previewUrl || pendingAvatarPath || undefined} name={nickname || username} />
<AvatarPreviewCard label="中尺寸" size="md" avatarPath={previewUrl || pendingAvatarPath || undefined} name={nickname || username} />
Expand Down