polish(frontend): 侧栏与徽章可读性,智能悬停提示
降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -280,7 +280,7 @@ export default function CommentSection({
|
||||
<button
|
||||
onClick={() => openReply(c)}
|
||||
className={`cmt-reply-btn transition-colors ${
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
|
||||
}`}
|
||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||
data-tip="回复"
|
||||
@@ -294,7 +294,7 @@ export default function CommentSection({
|
||||
{canAccept(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--accent)] font-semibold"
|
||||
className="cmt-reply-btn !text-[var(--accent)]"
|
||||
disabled={acceptingId === c.id}
|
||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||
@@ -308,7 +308,7 @@ export default function CommentSection({
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
@@ -350,7 +350,7 @@ export default function CommentSection({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
<Link href={`/u/${c.user.id}`} className="group">
|
||||
<span className="font-semibold truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
|
||||
<span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
|
||||
{c.user.nickname || c.user.username}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -372,7 +372,7 @@ export default function CommentSection({
|
||||
<button
|
||||
onClick={() => openReply(c)}
|
||||
className={`cmt-reply-btn transition-colors ${
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
|
||||
}`}
|
||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||
data-tip="回复"
|
||||
@@ -386,7 +386,7 @@ export default function CommentSection({
|
||||
{canAccept(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--accent)] font-semibold"
|
||||
className="cmt-reply-btn !text-[var(--accent)]"
|
||||
disabled={acceptingId === c.id}
|
||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||
@@ -400,7 +400,7 @@ export default function CommentSection({
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
@@ -447,11 +447,11 @@ export default function CommentSection({
|
||||
return (
|
||||
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
|
||||
<div className="flex items-center gap-2.5 mb-6">
|
||||
<h2 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
|
||||
评论
|
||||
</h2>
|
||||
<span
|
||||
className="text-[12px] font-bold px-2 py-0.5 rounded-full tabular-nums"
|
||||
className="text-[12px] font-medium px-2 py-0.5 rounded-full tabular-nums"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
{totalComments}
|
||||
@@ -503,7 +503,7 @@ export default function CommentSection({
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center py-14">
|
||||
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>
|
||||
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||
暂无评论
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function Header({
|
||||
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
姜十三
|
||||
</span>
|
||||
<span className="block text-[10.5px] font-semibold mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
J13 社区
|
||||
</span>
|
||||
</span>
|
||||
@@ -307,7 +307,7 @@ export default function Header({
|
||||
<MessageSquare size={17} />
|
||||
{chatUnread > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold flex items-center justify-center rounded-full"
|
||||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--clay)",
|
||||
color: "#fff",
|
||||
@@ -377,7 +377,7 @@ export default function Header({
|
||||
<MessageSquare size={15} /> 消息
|
||||
{chatUnread > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full"
|
||||
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full"
|
||||
style={{ background: "var(--clay)", color: "#fff" }}
|
||||
>
|
||||
{chatUnread > 99 ? "99+" : chatUnread}
|
||||
@@ -431,7 +431,7 @@ function NavPill({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13.5px] font-semibold transition-colors"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13.5px] transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
@@ -456,7 +456,7 @@ function MobileLink({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="px-3 py-3 rounded-2xl text-[15px] font-semibold transition-colors"
|
||||
className="px-3 py-3 rounded-2xl text-[15px] transition-colors"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
background: active ? "var(--accent-soft)" : "transparent",
|
||||
@@ -492,7 +492,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
}, [open]);
|
||||
|
||||
const itemCls =
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] font-semibold transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
|
||||
|
||||
return (
|
||||
<div className="relative hidden lg:block" ref={menuRef}>
|
||||
@@ -508,7 +508,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
|
||||
<span
|
||||
className="text-[13px] font-semibold max-w-[90px] truncate"
|
||||
className="text-[13px] max-w-[90px] truncate"
|
||||
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
{user.nickname}
|
||||
@@ -540,7 +540,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{user.nickname}
|
||||
</p>
|
||||
<p className="meta text-xs truncate">@ {user.username}</p>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function LikeButton({
|
||||
data-tip={liked ? "取消点赞" : "点赞"}
|
||||
aria-label={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
className={`j13-like inline-flex items-center justify-center font-semibold tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
className={`j13-like inline-flex items-center justify-center tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
>
|
||||
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
|
||||
<IconHeart size={iconSize} filled={liked} />
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
<Bell size={17} />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold flex items-center justify-center rounded-full"
|
||||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--clay)",
|
||||
color: "#fff",
|
||||
@@ -194,7 +194,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
className="flex items-center justify-between px-5 py-3.5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
|
||||
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
@@ -238,7 +238,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
|
||||
{approval ? (
|
||||
<span
|
||||
className="font-bold"
|
||||
className="font-medium"
|
||||
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
|
||||
>
|
||||
{actionText(n)}
|
||||
|
||||
@@ -665,7 +665,7 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
{/* 类型专属面板 */}
|
||||
{postType === "poll" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
投票选项
|
||||
</p>
|
||||
{!canEditPoll && (
|
||||
@@ -732,7 +732,7 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
)}
|
||||
{postType === "bounty" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏积分
|
||||
</p>
|
||||
<p className="meta text-[12.5px]">
|
||||
@@ -773,7 +773,7 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
)}
|
||||
{postType === "lottery" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖设置
|
||||
</p>
|
||||
<p className="meta text-[12.5px]">
|
||||
@@ -887,7 +887,7 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
<li key={a.id} className="j13-compose-attach-row">
|
||||
<Download size={15} className="shrink-0 opacity-50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[13.5px]">{a.name}</div>
|
||||
<div className="truncate text-[13.5px]">{a.name}</div>
|
||||
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
||||
</div>
|
||||
<label className="meta text-[12px] flex items-center gap-1.5 shrink-0">
|
||||
|
||||
@@ -75,7 +75,7 @@ function QuestionPanel({ postId, state }: { postId: number; state: QuestionState
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
问答 · {state.solved ? "已解决" : "未解决"}
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
@@ -173,7 +173,7 @@ function PollPanel({
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
投票{state.multi ? "(多选)" : "(单选)"}
|
||||
{state.anonymous ? " · 匿名" : " · 实名"}
|
||||
</h2>
|
||||
@@ -209,7 +209,7 @@ function PollPanel({
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
||||
{o.text}
|
||||
</span>
|
||||
@@ -226,7 +226,7 @@ function PollPanel({
|
||||
<li key={v.id}>
|
||||
<Link
|
||||
href={`/u/${v.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px] font-semibold"
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px]"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||
@@ -304,7 +304,7 @@ function BountyPanel({ postId, state }: { postId: number; state: BountyState })
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏 {state.points} 积分
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
@@ -357,7 +357,7 @@ function LotteryPanel({
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖 · {state.slots} 个名额
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
@@ -395,7 +395,7 @@ function LotteryPanel({
|
||||
</div>
|
||||
{state.drawn && (
|
||||
<div>
|
||||
<p className="font-bold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
<p className="font-semibold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
|
||||
</p>
|
||||
{state.winners.length > 0 && (
|
||||
@@ -408,7 +408,7 @@ function LotteryPanel({
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
|
||||
<span className="text-[13px] font-bold">{w.nickname || w.username}</span>
|
||||
<span className="text-[13px]">{w.nickname || w.username}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -2,14 +2,14 @@ import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { PostListItem } from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } from "@/lib/postMeta";
|
||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||
import LikeButton from "./LikeButton";
|
||||
import Avatar from "./Avatar";
|
||||
import { IconComment, IconEye } from "./Icons";
|
||||
|
||||
/**
|
||||
* 帖子列表行(参考社区信息流样式):
|
||||
* 大头像 + 加粗标题(徽章内联)+ 第二行「板块 · 作者 · 发布时间 ← 最后回复」+
|
||||
* 大头像 + 标题(徽章内联)+ 第二行「板块 · 作者 · 发布时间 ← 最后回复」+
|
||||
* 右侧评论数等统计垂直居中。用于首页/板块页/搜索结果。
|
||||
*/
|
||||
export default function PostRow({
|
||||
@@ -31,6 +31,7 @@ export default function PostRow({
|
||||
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||
const pt = normalizePostType(post.post_type);
|
||||
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
|
||||
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -65,9 +66,7 @@ export default function PostRow({
|
||||
aria-label={post.title}
|
||||
>
|
||||
<h3
|
||||
className={`font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)] ${
|
||||
compact ? "text-[15px]" : "text-[16px] sm:text-[17px]"
|
||||
}`}
|
||||
className="text-[15px] font-normal leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
@@ -78,8 +77,8 @@ export default function PostRow({
|
||||
{pt !== "discussion" && pt !== "question" && (
|
||||
<span className="badge-type shrink-0">{postTypeLabel(post.post_type)}</span>
|
||||
)}
|
||||
{statusBadge && (
|
||||
<span className="badge-access shrink-0">{statusBadge}</span>
|
||||
{statusBadge && statusClass && (
|
||||
<span className={`${statusClass} shrink-0`}>{statusBadge}</span>
|
||||
)}
|
||||
{post.content_access && post.content_access !== "public" && (
|
||||
<span className="badge-access shrink-0">{contentAccessLabel(post.content_access)}</span>
|
||||
@@ -98,7 +97,7 @@ export default function PostRow({
|
||||
)}
|
||||
<Link
|
||||
href={`/u/${post.user.id}`}
|
||||
className="font-semibold hover:text-[var(--accent)] transition-colors"
|
||||
className="hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{post.user.nickname}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function RoleBadge({
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold leading-[1.6] ${
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] ${
|
||||
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
|
||||
} ${className}`}
|
||||
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}
|
||||
|
||||
@@ -10,6 +10,67 @@ type TipState = {
|
||||
place: "top" | "bottom";
|
||||
} | null;
|
||||
|
||||
function norm(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** 可见文字:跳过 aria-hidden / svg / img,避免把图标标题算进来 */
|
||||
function getVisibleText(root: Element): string {
|
||||
const parts: string[] = [];
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const t = node.textContent?.replace(/\s+/g, " ").trim();
|
||||
if (t) parts.push(t);
|
||||
return;
|
||||
}
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node.getAttribute("aria-hidden") === "true") return;
|
||||
const tag = node.tagName.toUpperCase();
|
||||
if (tag === "SVG" || tag === "IMG" || tag === "CANVAS" || tag === "PATH") return;
|
||||
for (const child of node.childNodes) walk(child);
|
||||
};
|
||||
walk(root);
|
||||
return norm(parts.join(" "));
|
||||
}
|
||||
|
||||
/** 是否存在省略截断(含 line-clamp) */
|
||||
function isVisuallyTruncated(root: Element): boolean {
|
||||
const nodes: Element[] = [root, ...Array.from(root.querySelectorAll("*"))];
|
||||
for (const el of nodes) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
if (el.clientWidth <= 0 && el.clientHeight <= 0) continue;
|
||||
const st = getComputedStyle(el);
|
||||
const ellipsis = st.textOverflow === "ellipsis" || el.classList.contains("truncate");
|
||||
const nowrapHidden =
|
||||
(st.overflowX === "hidden" || st.overflow === "hidden") &&
|
||||
(st.whiteSpace === "nowrap" || st.whiteSpace === "pre");
|
||||
const clampRaw = st.getPropertyValue("-webkit-line-clamp").trim();
|
||||
const clamped = Boolean(clampRaw) && clampRaw !== "none";
|
||||
if (ellipsis || nowrapHidden) {
|
||||
if (el.scrollWidth > el.clientWidth + 1) return true;
|
||||
}
|
||||
if (clamped && el.scrollHeight > el.clientHeight + 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在以下场景显示气泡:
|
||||
* 1. 提示文案与可见内容不一致(且提示未完整出现在可见文字中)
|
||||
* 2. 停留目标无可见文字(图标等)
|
||||
* 3. 可见内容被省略截断,需要补全
|
||||
*/
|
||||
function shouldShowTip(host: Element, tip: string): boolean {
|
||||
if (isVisuallyTruncated(host)) return true;
|
||||
const visible = getVisibleText(host);
|
||||
if (!visible) return true;
|
||||
const nTip = norm(tip);
|
||||
const nVis = visible;
|
||||
if (nTip === nVis) return false;
|
||||
if (nVis.includes(nTip)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
|
||||
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
|
||||
@@ -38,6 +99,16 @@ export default function TipHost() {
|
||||
});
|
||||
};
|
||||
|
||||
const tryShow = (host: Element) => {
|
||||
const text = host.getAttribute("data-tip")?.trim();
|
||||
if (!text || !shouldShowTip(host, text)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
activeEl.current = host;
|
||||
placeTip(host, text);
|
||||
};
|
||||
|
||||
const onOver = (e: Event) => {
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
@@ -46,18 +117,12 @@ export default function TipHost() {
|
||||
if (activeEl.current) hide();
|
||||
return;
|
||||
}
|
||||
const text = host.getAttribute("data-tip")?.trim();
|
||||
if (!text) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
activeEl.current = host;
|
||||
placeTip(host, text);
|
||||
tryShow(host);
|
||||
};
|
||||
|
||||
const onOut = (e: Event) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
const related = e.relatedTarget;
|
||||
if (!(e instanceof MouseEvent) && !(e instanceof FocusEvent)) return;
|
||||
const related = "relatedTarget" in e ? e.relatedTarget : null;
|
||||
if (
|
||||
related instanceof Node &&
|
||||
activeEl.current &&
|
||||
@@ -70,12 +135,7 @@ export default function TipHost() {
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
if (!activeEl.current) return;
|
||||
const text = activeEl.current.getAttribute("data-tip")?.trim();
|
||||
if (!text) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
placeTip(activeEl.current, text);
|
||||
tryShow(activeEl.current);
|
||||
};
|
||||
|
||||
// mouseover 冒泡,便于 closest;立即显示,无延时
|
||||
|
||||
@@ -43,7 +43,7 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
|
||||
{checked ? <CalendarCheck size={16} /> : <CalendarDays size={16} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12.5px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[12.5px] leading-tight" style={{ color: "var(--ink)" }}>
|
||||
每日签到
|
||||
</p>
|
||||
<p className="meta mt-0.5 truncate text-[11px]">
|
||||
|
||||
@@ -73,10 +73,10 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
||||
实时
|
||||
</span>
|
||||
</div>
|
||||
<div className="j13-pulse mt-3">
|
||||
<div className="j13-pulse">
|
||||
{cells.map((c) => (
|
||||
<div key={c.label} className="j13-pulse-cell">
|
||||
<p className="j13-pulse-lab" style={{ color: "var(--ink-3)" }}>
|
||||
<p className="j13-pulse-lab">
|
||||
<span style={{ color: "var(--accent)" }} aria-hidden="true">
|
||||
{c.icon}
|
||||
</span>
|
||||
@@ -102,32 +102,31 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
||||
function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="站点公告">
|
||||
<p className="j13-module-title mb-2">
|
||||
<p className="j13-module-title">
|
||||
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
|
||||
</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="meta px-1 py-1.5">暂无公告</p>
|
||||
<p className="meta mt-2 px-0.5">暂无公告</p>
|
||||
) : (
|
||||
<ul>
|
||||
<ul className="j13-anno-list">
|
||||
{items.map((a) => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
className="j13-anno-item"
|
||||
data-tip={a.title}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">
|
||||
{formatShortDate(a.created_at)}
|
||||
<span className="j13-anno-item-body">
|
||||
<span className="j13-anno-item-title truncate">{a.title}</span>
|
||||
<span className="j13-anno-item-meta">
|
||||
{a.tag ? (
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
) : null}
|
||||
<span className="meta text-[11px] tabular-nums">
|
||||
{formatShortDate(a.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="mt-1.5 block truncate text-[13px] leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -236,7 +235,7 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
<Avatar name={name} src={u.avatar} size={30} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span
|
||||
className="block truncate text-[13px] font-semibold leading-tight"
|
||||
className="block truncate text-[13px] leading-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{name}
|
||||
@@ -251,7 +250,7 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[13px] font-extrabold tabular-nums"
|
||||
className="shrink-0 text-[13px] tabular-nums"
|
||||
style={{ color: "var(--accent)" }}
|
||||
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
||||
>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function FeedNewTip({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="j13-feed-new-tip w-full inline-flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors"
|
||||
className="j13-feed-new-tip w-full inline-flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] rounded-[var(--r-sm)] transition-colors"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background: "var(--accent-soft)",
|
||||
|
||||
@@ -10,23 +10,14 @@ function boardHref(id: number) {
|
||||
return `/?board=${id}`;
|
||||
}
|
||||
|
||||
// 桌面左栏:发帖 CTA + 板块导航 + 登录态快捷入口
|
||||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user: User | null }) {
|
||||
// 桌面左栏:板块导航
|
||||
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const composePath =
|
||||
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
|
||||
const composeHref = user
|
||||
? composePath
|
||||
: `/login?redirect=${encodeURIComponent(composePath)}`;
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:block w-56 xl:w-60 shrink-0" aria-label="工作台导航">
|
||||
<div className="sticky top-24 space-y-5">
|
||||
<Link href={composeHref} className="btn btn-primary w-full justify-center">
|
||||
<PenSquare size={16} /> 发起讨论
|
||||
</Link>
|
||||
|
||||
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
|
||||
<div className="space-y-0.5">
|
||||
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
|
||||
@@ -37,7 +28,7 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
||||
>
|
||||
<Home size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate text-[13px] font-bold">
|
||||
<span className="j13-bcard-name flex-1 truncate text-[14px]">
|
||||
全部讨论
|
||||
</span>
|
||||
</Link>
|
||||
@@ -48,7 +39,7 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
||||
<div>
|
||||
<Link
|
||||
href="/boards"
|
||||
className="group px-1 mb-2 flex items-center justify-between text-[11.5px] font-bold tracking-[0.08em] uppercase text-[var(--ink-3)] hover:text-[var(--accent)] transition-colors"
|
||||
className="group px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)] hover:text-[var(--accent)] transition-colors"
|
||||
aria-label="进入板块目录列表页"
|
||||
>
|
||||
<span className="flex items-center">
|
||||
@@ -60,7 +51,7 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
||||
/>
|
||||
</span>
|
||||
{boards.length > 0 && (
|
||||
<span className="tracking-normal font-semibold tabular-nums">{boards.length} 个</span>
|
||||
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
|
||||
)}
|
||||
</Link>
|
||||
<div className="space-y-2">
|
||||
@@ -76,7 +67,7 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate text-[13px] font-bold">
|
||||
<span className="j13-bcard-name truncate text-[14px]">
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
|
||||
Reference in New Issue
Block a user