Files
jiang13-bbs/frontend/components/ChatUserCard.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

228 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Calendar, Crown, MessageSquare, VolumeX } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
export type ChatUserCardTarget = {
user: User;
/** 群内身份(可从成员表查到时携带) */
roomRole?: "owner" | "admin" | "member";
muted?: boolean;
};
/**
* 聊天用户资料卡:头像(可进图箱)+ 基本信息 + 主页/私聊入口。
* 公开资料拉取失败时降级为消息内已有的基础字段,不阻塞弹层。
*/
export default function ChatUserCard({
target,
meId,
onClose,
}: {
target: ChatUserCardTarget;
meId: number;
onClose: () => void;
}) {
const router = useRouter();
const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [dmBusy, setDmBusy] = useState(false);
const [avatarOpen, setAvatarOpen] = useState(false);
const { user } = target;
const isSelf = user.id === meId;
const name = user.nickname || user.username;
useEffect(() => {
let alive = true;
setProfile(null);
apiGetUserProfile(user.id)
.then((p) => {
if (alive) setProfile(p);
})
.catch(() => {
// 静默:资料卡仍展示消息携带的基础字段
});
return () => {
alive = false;
};
}, [user.id]);
const startDM = async () => {
setDmBusy(true);
try {
const { room } = await apiOpenDirectChat(user.id);
onClose();
router.push(`/chat/${room.id}`);
} catch (e) {
toast(e instanceof Error ? e.message : "无法发起私聊");
} finally {
setDmBusy(false);
}
};
const sig = (profile?.user.signature || "").trim();
const canPreviewAvatar = !!user.avatar;
const stats = profile?.stats;
return (
<>
<Modal
open
title="用户资料"
ariaLabel={`${name} 的资料卡`}
onClose={onClose}
maxWidthClass="max-w-sm"
>
{/* 头部:头像 + 昵称/角色 */}
<div className="flex items-center gap-3.5">
{canPreviewAvatar ? (
<button
type="button"
className="cursor-zoom-in rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
aria-label="查看头像"
onClick={() => setAvatarOpen(true)}
>
<Avatar name={name} src={user.avatar || undefined} size={64} />
</button>
) : (
<Avatar name={name} src={user.avatar || undefined} size={64} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="text-[16px] font-extrabold tracking-tight truncate"
style={{ color: "var(--ink)" }}
>
{name}
</p>
<LevelBadge level={user.level} />
{(user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{user.role && user.role !== "user" && (
<RoleBadge role={user.role} boardIds={user.board_ids} />
)}
{profile?.user.banned === true && (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
已封禁
</span>
)}
</div>
<p className="meta text-[12.5px] mt-0.5 truncate">@{user.username}</p>
{target.roomRole === "owner" && (
<p
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium"
style={{ color: "var(--gold)" }}
>
<Crown size={12} /> 群主
</p>
)}
{target.roomRole === "admin" && (
<p
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium"
style={{ color: "var(--accent)" }}
>
<Crown size={12} /> 群管理员
</p>
)}
{target.muted && (
<p
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<VolumeX size={12} /> 禁言中
</p>
)}
</div>
</div>
{/* 签名 */}
{sig && (
<p
className="mt-3 text-[13px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
{sig}
</p>
)}
{/* 基本信息行 */}
<div
className="mt-3.5 pt-3 flex items-center gap-x-3 gap-y-1.5 flex-wrap text-[12.5px]"
style={{ borderTop: "1px solid var(--line)", color: "var(--ink-2)" }}
>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{stats ? stats.post_count : "–"}
</span>{" "}
帖子
</span>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{stats ? stats.comment_count : "–"}
</span>{" "}
评论
</span>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{stats ? stats.points : "–"}
</span>{" "}
积分
</span>
{profile?.user.created_at && (
<span className="inline-flex items-center gap-1 meta">
<Calendar size={12} />
{new Date(profile.user.created_at).toLocaleDateString("zh-CN")} 加入
</span>
)}
</div>
{/* 操作区 */}
<div className="mt-4 flex items-center gap-2">
<Link href={userPath(urlStyle, user.id)} className="btn btn-line btn-sm flex-1 justify-center">
用户主页
</Link>
{allowMessages && !isSelf && (
<button
type="button"
onClick={startDM}
disabled={dmBusy}
className="btn btn-accent btn-sm flex-1 justify-center gap-1.5"
>
<MessageSquare size={14} />
{dmBusy ? "打开中…" : "发私信"}
</button>
)}
</div>
</Modal>
{avatarOpen && canPreviewAvatar && (
<ImageLightbox
items={[{ src: user.avatar, alt: `${name} 的头像` }]}
index={0}
onClose={() => setAvatarOpen(false)}
onIndexChange={() => {}}
/>
)}
</>
);
}