diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx index 9e27ca5..ed16941 100644 --- a/apps/web/app/loading.tsx +++ b/apps/web/app/loading.tsx @@ -1,9 +1,79 @@ -import { ThinkingIndicator } from "@/components/ThinkingIndicator"; +import { Card } from "@/components/ui/Card"; +import { PageContainer } from "@/components/ui/PageContainer"; +import { Skeleton } from "@/components/ui/Skeleton"; +// 全站加载骨架:与作品库结构近似(标题条 + 工具条 + 卡片网格), +// motion-safe 脉冲,尊重 prefers-reduced-motion。 export default function Loading() { return ( -
- +
+ +
+ 加载中… + + + +
+
); } + +// 标题区:大标题条 + 说明条 + 右侧两枚按钮位。 +function HeaderSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ ); +} + +// 工具条:搜索框 + 三枚控件位。 +function ToolbarSkeleton() { + return ( + + + + + + + ); +} + +// 卡片网格:6 张与作品卡等高的占位卡。 +function GridSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); +} + +// 单张作品卡骨架:标题 + 徽章 + 三行简介 + 底部时间。 +function CardSkeleton() { + return ( + +
+
+ + +
+ +
+
+ + +
+ +
+ ); +} diff --git a/apps/web/components/ProjectWizard.tsx b/apps/web/components/ProjectWizard.tsx index d7eb17c..6499765 100644 --- a/apps/web/components/ProjectWizard.tsx +++ b/apps/web/components/ProjectWizard.tsx @@ -107,9 +107,9 @@ export function ProjectWizard() { const isLast = step === WIZARD_STEPS; return ( -
+
-

新建作品

+

新建作品

@@ -175,7 +175,7 @@ function StepDots({ current }: { current: number }) { {Array.from({ length: WIZARD_STEPS }, (_, i) => ( @@ -235,7 +235,7 @@ function PlanAssistant({ const generating = status === "generating"; return ( -
+

@@ -449,7 +449,7 @@ function StepConfirm({ form }: { form: WizardForm }) {

复核以下信息,确认无误后即可完成立项。其余设定可进工作台后在「设定库」继续完善。

-
+
{rows.map((row) => (
{row.label}
diff --git a/apps/web/components/codex/CodexPage.tsx b/apps/web/components/codex/CodexPage.tsx index 9012925..6b244f5 100644 --- a/apps/web/components/codex/CodexPage.tsx +++ b/apps/web/components/codex/CodexPage.tsx @@ -50,7 +50,7 @@ const RelationshipGraph = dynamic( { ssr: false, loading: () => ( -
+
关系图谱加载中…
), @@ -101,7 +101,7 @@ export function CodexPage({ {tab === "characters" ? (
-

+

已入库人物({characters.length})

{characters.length > 0 ? ( @@ -109,10 +109,10 @@ export function CodexPage({ {characters.map((c, i) => (
  • - +
    @@ -127,7 +127,7 @@ export function CodexPage({ {c.relations.map((r, j) => (
  • {r.kind} · {r.name} @@ -163,7 +163,7 @@ export function CodexPage({ {characters.length > 0 ? ( -

    +

    @@ -184,7 +184,7 @@ export function CodexPage({ {tab === "world" ? (
    -

    +

    已入库世界观({initialWorldEntities.length})

    {initialWorldEntities.length > 0 ? ( @@ -192,7 +192,7 @@ export function CodexPage({ {initialWorldEntities.map((entity, i) => (
    (
    -

    +

    卷 {group.volume}

      diff --git a/apps/web/components/projects/ProjectCard.tsx b/apps/web/components/projects/ProjectCard.tsx new file mode 100644 index 0000000..ba1df3d --- /dev/null +++ b/apps/web/components/projects/ProjectCard.tsx @@ -0,0 +1,164 @@ +import Link from "next/link"; +import { ChevronRight, Clock3 } from "lucide-react"; + +import { Badge } from "@/components/ui/Badge"; +import { StatusDot } from "@/components/ui/StatusDot"; +import type { ProjectResponse } from "@/lib/api/types"; +import { + formatProjectUpdatedAt, + pendingReviewCount, +} from "@/lib/projects/projects"; +import { cardClass, cn, focusRing } from "@/lib/ui/variants"; + +interface ProjectCardProps { + project: ProjectResponse; + compact?: boolean; +} + +// 缺一句话故事时的中性占位——避免卡片正文塌陷成空白(P3-2)。 +const LOGLINE_PLACEHOLDER = "还没有一句话故事,进入写作台补上一句梗概。"; + +// 书脊/封面暖色板:全部取自现有语义 token(朱砂/琥珀),仅调透明度,无硬编码 hex。 +const COVER_TINTS = [ + "bg-[var(--color-cinnabar-wash)] text-cinnabar", + "bg-cinnabar/10 text-cinnabar", + "bg-cinnabar/[0.16] text-cinnabar", + "bg-overdue/10 text-overdue", + "bg-overdue/[0.18] text-overdue", + "bg-surface-strong text-body-strong", +] as const; + +// 由 id/title 确定性取暖色底色块(djb2 变体哈希,同一本书永远同一色)。 +function coverTint(seed: string): string { + let hash = 0; + for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0; + return COVER_TINTS[hash % COVER_TINTS.length] ?? COVER_TINTS[0]; +} + +// 取书名首个字符作封面字(Array.from 保证 CJK/emoji 不被截断)。 +function coverGlyph(title: string): string { + return Array.from(title.trim())[0] ?? "书"; +} + +interface CoverProps { + project: ProjectResponse; + size: "sm" | "md"; +} + +// 书脊色块:填补此前的空白,给每本书一个稳定的视觉锚点。 +function Cover({ project, size }: CoverProps) { + const tint = coverTint(project.id || project.title || ""); + const box = size === "md" ? "h-12 w-12 text-title-lg" : "h-10 w-10 text-title-md"; + return ( + + ); +} + +// 作品卡(UX §6.1):书脊色块 + 书名衬线 + 一句话故事 + 元信息行。 +// M1 无字数/章数端点 → 不展示该统计(避免编造 API)。 +export function ProjectCard({ project, compact = false }: ProjectCardProps) { + const pendingCount = pendingReviewCount(project); + const updatedLabel = formatProjectUpdatedAt(project.updated_at); + const title = project.title || "未命名作品"; + const logline = project.logline?.trim(); + + if (compact) { + return ( + + + + + 〈{title}〉 + + + + + + {logline ?? LOGLINE_PLACEHOLDER} + + + + + +