新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
246 lines
8.1 KiB
TypeScript
246 lines
8.1 KiB
TypeScript
"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";
|
||
|
||
/** 计算禁言剩余时间,返回「x天x小时x分钟」格式 */
|
||
function formatMuteRemain(untilIso: string): string {
|
||
const until = new Date(untilIso).getTime();
|
||
const diff = until - Date.now();
|
||
if (diff <= 0) return "即将";
|
||
const days = Math.floor(diff / 86_400_000);
|
||
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||
if (days > 0) return `${days} 天`;
|
||
if (hours > 0) return `${hours} 小时`;
|
||
return `${minutes} 分钟`;
|
||
}
|
||
|
||
export type ChatUserCardTarget = {
|
||
user: User;
|
||
/** 群内身份(可从成员表查到时携带) */
|
||
roomRole?: "owner" | "admin" | "member";
|
||
muted?: boolean;
|
||
/** 禁言截止时间(ISO 字符串);null/缺省表示永久 */
|
||
mutedUntil?: string | null;
|
||
};
|
||
|
||
/**
|
||
* 聊天用户资料卡:头像(可进图箱)+ 基本信息 + 主页/私聊入口。
|
||
* 公开资料拉取失败时降级为消息内已有的基础字段,不阻塞弹层。
|
||
*/
|
||
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} /> 禁言中
|
||
{target.mutedUntil && (
|
||
<span>· {formatMuteRemain(target.mutedUntil)}后解除</span>
|
||
)}
|
||
</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={() => {}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|