feat: improve app ui and project metadata

This commit is contained in:
Yaojia Wang
2026-06-28 07:31:20 +02:00
parent 3bd464d400
commit 90a66437d7
86 changed files with 4892 additions and 1108 deletions

View File

@@ -1,10 +1,24 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookOpen,
ClipboardCheck,
FileText,
Library,
PanelLeft,
PanelRight,
PenLine,
Square,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave";
@@ -14,6 +28,7 @@ import {
type ChapterEntry,
} from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor";
@@ -50,6 +65,8 @@ export function Workbench({
const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream();
const lastStreamText = useRef("");
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => {
@@ -95,7 +112,7 @@ export function Workbench({
projectId={project.id}
activeNav="write"
>
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:h-[calc(100vh-var(--chrome,4rem))] lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList
projectId={project.id}
chapters={chapters}
@@ -103,6 +120,13 @@ export function Workbench({
/>
<section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar
chapterNo={chapterNo}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
@@ -126,8 +150,6 @@ export function Workbench({
streamError={stream.state.error}
onWrite={onWrite}
onStop={stream.stop}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
/>
</section>
@@ -140,6 +162,7 @@ export function Workbench({
onClose={closePanel}
side="left"
label="目录"
triggerRef={chapterTriggerRef}
>
<ChapterListContent
projectId={project.id}
@@ -152,6 +175,7 @@ export function Workbench({
onClose={closePanel}
side="right"
label="本章助手"
triggerRef={assistantTriggerRef}
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer>
@@ -159,6 +183,48 @@ export function Workbench({
);
}
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
function MobileContextBar({
chapterNo,
onOpenChapters,
onOpenAssistant,
chapterTriggerRef,
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 lg:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
variant="secondary"
size="sm"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
{chapterNo}
</span>
<Button
ref={assistantTriggerRef}
onClick={onOpenAssistant}
variant="secondary"
size="sm"
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
}
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
@@ -174,42 +240,55 @@ function DirectivePanel({
presetIds,
onTogglePreset,
}: DirectivePanelProps) {
const panelId = useId();
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
return (
<details className="border-b border-line bg-panel px-6 py-2">
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
<details className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
aria-controls={panelId}
>
<span className="font-serif text-base text-ink"></span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
</summary>
<div className="mt-2 space-y-2">
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="写作指令"
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
/>
<label className="block">
<span className="sr-only"></span>
<textarea
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
/>
</label>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<button
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
className={
active
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</button>
</Button>
);
})}
</div>
{activeCount > 0 ? (
<StatusNote variant="info">
</StatusNote>
) : null}
</div>
</details>
);
@@ -250,8 +329,6 @@ interface ToolbarProps {
streamError: { code: string; message: string } | null;
onWrite: () => void;
onStop: () => void;
onOpenChapters: () => void;
onOpenAssistant: () => void;
}
function Toolbar({
@@ -264,36 +341,22 @@ function Toolbar({
streamError,
onWrite,
onStop,
onOpenChapters,
onOpenAssistant,
}: ToolbarProps) {
return (
<div className="border-t border-line bg-panel px-4 py-3 sm:px-6">
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<button
type="button"
onClick={onOpenChapters}
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
>
</button>
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{streaming ? (
<button
type="button"
onClick={onStop}
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
>
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</button>
</Button>
) : (
<button
type="button"
onClick={onWrite}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
>
</button>
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
</Button>
)}
{streaming ? (
<span className="font-mono text-xs text-cinnabar motion-safe:animate-pulse">
@@ -306,30 +369,32 @@ function Toolbar({
)}
<Link
href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<BookOpen className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
className={buttonClass({
variant: "secondary",
size: "sm",
className: "hidden sm:inline-flex",
})}
>
<Library className="h-4 w-4" aria-hidden="true" />
</Link>
<button
type="button"
onClick={onOpenAssistant}
className="rounded border border-line px-3 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar lg:hidden"
>
</button>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
className={buttonClass({ variant: "outline", size: "sm" })}
>
稿
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
<span className="ml-auto font-mono text-xs text-ink-soft">
</div>
<span className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto">
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{saveStatus === "saving"
? "保存中…"
: saveStatus === "error"