# Sidebar.tsx · 功能08版本 · 2026-04-26

妈妈把这里的代码完整复制，粘贴到 VS Code 里的 Sidebar.tsx 文件，覆盖全部内容保存即可。

```tsx
import { useState, useEffect } from 'react'
import { useWritingStore } from '../stores/writingStore'

const TODAY = new Date().toISOString().slice(0, 10)

function getDailyGoalData() {
  try {
    const raw = localStorage.getItem('dailyGoal')
    if (!raw) return { goal: 0, baseCount: 0, date: TODAY }
    return JSON.parse(raw)
  } catch { return { goal: 0, baseCount: 0, date: TODAY } }
}

function saveDailyGoalData(data: { goal: number; baseCount: number; date: string }) {
  localStorage.setItem('dailyGoal', JSON.stringify(data))
}

export function Sidebar() {
  const {
    novels, currentNovelId, currentChapterId,
    createNovel, createChapter, deleteNovel, deleteChapter,
    setCurrentNovel, setCurrentChapter, reorderChapters, updateNovelInfo
  } = useWritingStore()

  const [isCreatingNovel, setIsCreatingNovel] = useState(false)
  const [newNovelTitle, setNewNovelTitle] = useState('')
  const [isCreatingChapter, setIsCreatingChapter] = useState(false)
  const [newChapterTitle, setNewChapterTitle] = useState('')
  const [expandedNovels, setExpandedNovels] = useState<Set<string>>(new Set())
  const [editingInfoId, setEditingInfoId] = useState<string | null>(null)
  const [editCover, setEditCover] = useState('')
  const [editDesc, setEditDesc] = useState('')
  const [goalData, setGoalData] = useState(getDailyGoalData)
  const [isEditingGoal, setIsEditingGoal] = useState(false)
  const [goalInput, setGoalInput] = useState('')

  const currentNovel = novels.find(n => n.id === currentNovelId)
  const totalWords = currentNovel
    ? currentNovel.chapters.reduce((sum, c) => sum + c.wordCount, 0)
    : novels.reduce((sum, n) => sum + n.chapters.reduce((s, c) => s + c.wordCount, 0), 0)

  useEffect(() => {
    const data = getDailyGoalData()
    if (data.date !== TODAY) {
      const newData = { goal: data.goal, baseCount: totalWords, date: TODAY }
      saveDailyGoalData(newData)
      setGoalData(newData)
    }
  }, [])

  const todayWords = Math.max(0, totalWords - goalData.baseCount)
  const goalProgress = goalData.goal > 0 ? Math.min(100, Math.round((todayWords / goalData.goal) * 100)) : 0
  const goalDone = goalData.goal > 0 && todayWords >= goalData.goal

  const handleSetGoal = () => {
    const num = parseInt(goalInput)
    if (!isNaN(num) && num > 0) {
      const newData = { goal: num, baseCount: totalWords, date: TODAY }
      saveDailyGoalData(newData)
      setGoalData(newData)
    } else if (goalInput === '0' || goalInput === '') {
      const newData = { goal: 0, baseCount: totalWords, date: TODAY }
      saveDailyGoalData(newData)
      setGoalData(newData)
    }
    setIsEditingGoal(false)
    setGoalInput('')
  }

  const formatTime = (iso: string) => {
    const d = new Date(iso)
    const now = new Date()
    const diffMin = Math.floor((now.getTime() - d.getTime()) / 60000)
    if (diffMin < 1) return '刚刚'
    if (diffMin < 60) return diffMin + '分钟前'
    const diffH = Math.floor(diffMin / 60)
    if (diffH < 24) return diffH + '小时前'
    return (d.getMonth() + 1) + '/' + d.getDate()
  }

  const handleCreateNovel = () => {
    if (newNovelTitle.trim()) {
      createNovel(newNovelTitle.trim())
      setNewNovelTitle('')
      setIsCreatingNovel(false)
    }
  }

  const handleCreateChapter = () => {
    if (newChapterTitle.trim() && currentNovelId) {
      createChapter(newChapterTitle.trim())
      setNewChapterTitle('')
      setIsCreatingChapter(false)
    }
  }

  const toggleExpand = (id: string) => {
    const newExpanded = new Set(expandedNovels)
    if (newExpanded.has(id)) newExpanded.delete(id)
    else newExpanded.add(id)
    setExpandedNovels(newExpanded)
  }

  const openEditInfo = (novel: typeof novels[0]) => {
    setEditingInfoId(novel.id)
    setEditCover(novel.cover || '')
    setEditDesc(novel.description || '')
  }

  const saveEditInfo = (id: string) => {
    updateNovelInfo(id, editCover, editDesc)
    setEditingInfoId(null)
  }

  return (
    <div className="w-64 h-full bg-gray-50 border-r border-gray-200 flex flex-col">
      <div className="p-4 border-b border-gray-200">
        <h1 className="text-lg font-bold text-gray-800">光湖码字工作台</h1>
      </div>

      <div className="flex-1 overflow-auto p-2">
        {isCreatingNovel ? (
          <div className="mb-2 p-2 bg-white rounded border">
            <input
              type="text" value={newNovelTitle}
              onChange={(e) => setNewNovelTitle(e.target.value)}
              placeholder="作品名称"
              className="w-full px-2 py-1 text-sm border rounded mb-2"
              autoFocus
              onKeyDown={(e) => e.key === 'Enter' && handleCreateNovel()}
            />
            <div className="flex gap-2">
              <button onClick={handleCreateNovel} className="flex-1 px-2 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600">创建</button>
              <button onClick={() => setIsCreatingNovel(false)} className="flex-1 px-2 py-1 text-sm border rounded hover:bg-gray-50">取消</button>
            </div>
          </div>
        ) : (
          <button onClick={() => setIsCreatingNovel(true)} className="w-full mb-2 p-2 text-sm text-blue-600 hover:bg-blue-50 rounded border border-dashed border-blue-300">
            + 新建作品
          </button>
        )}

        {novels.map((novel) => (
          <div key={novel.id} className="mb-1">
            <div
              className={`flex items-center p-2 rounded cursor-pointer group ${currentNovelId === novel.id ? 'bg-blue-100' : 'hover:bg-gray-100'}`}
              onClick={() => { setCurrentNovel(novel.id); toggleExpand(novel.id) }}
            >
              <span className="mr-1 text-xs">{expandedNovels.has(novel.id) ? '▼' : '▶'}</span>
              <span className="flex-1 truncate text-sm">{novel.title}</span>
              <span className="text-xs text-gray-400 mr-1">{novel.chapters.length}章</span>
              <button
                onClick={(e) => { e.stopPropagation(); openEditInfo(novel) }}
                className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-500 text-xs px-1"
                title="编辑封面/简介"
              >编</button>
              <button
                onClick={(e) => { e.stopPropagation(); if (confirm('确定删除《' + novel.title + '》？')) deleteNovel(novel.id) }}
                className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 text-xs px-1"
              >✕</button>
            </div>

            {editingInfoId === novel.id && (
              <div className="mx-2 mb-2 p-2 bg-white rounded border border-blue-200 shadow-sm">
                <div className="text-xs text-gray-500 mb-1 font-medium">作品封面 / 简介</div>
                {editCover && (
                  <img src={editCover} alt="封面预览" className="w-full h-24 object-cover rounded mb-2" onError={(e) => (e.currentTarget.style.display = 'none')} />
                )}
                <input
                  type="text" value={editCover}
                  onChange={(e) => setEditCover(e.target.value)}
                  placeholder="封面图片链接（可选）"
                  className="w-full px-2 py-1 text-xs border rounded mb-1"
                />
                <textarea
                  value={editDesc}
                  onChange={(e) => setEditDesc(e.target.value)}
                  placeholder="作品简介（可选）"
                  className="w-full px-2 py-1 text-xs border rounded mb-2 resize-none"
                  rows={3}
                />
                <div className="flex gap-2">
                  <button onClick={() => saveEditInfo(novel.id)} className="flex-1 px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">保存</button>
                  <button onClick={() => setEditingInfoId(null)} className="flex-1 px-2 py-1 text-xs border rounded hover:bg-gray-50">取消</button>
                </div>
              </div>
            )}

            {expandedNovels.has(novel.id) && (
              <div className="ml-4 border-l border-gray-200">
                {novel.cover && (
                  <div className="mx-2 mb-1">
                    <img src={novel.cover} alt="封面" className="w-full h-16 object-cover rounded" onError={(e) => (e.currentTarget.style.display = 'none')} />
                  </div>
                )}
                {novel.description && (
                  <div className="mx-2 mb-1 px-2 py-1 bg-gray-100 rounded text-xs text-gray-500">{novel.description}</div>
                )}
                {novel.chapters.map((chapter, index) => (
                  <div
                    key={chapter.id}
                    className={`flex flex-col p-2 pl-2 rounded cursor-pointer group ${
                      currentChapterId === chapter.id ? 'bg-blue-50 border-l-2 border-blue-500' : 'hover:bg-gray-50'
                    }`}
                    onClick={() => setCurrentChapter(chapter.id)}
                  >
                    <div className="flex items-center">
                      <div className="flex flex-col mr-1 opacity-0 group-hover:opacity-100" onClick={(e) => e.stopPropagation()}>
                        <button
                          onClick={() => index > 0 && reorderChapters(novel.id, index, index - 1)}
                          disabled={index === 0}
                          className="text-gray-300 hover:text-gray-500 disabled:opacity-20 text-xs leading-none"
                        >▲</button>
                        <button
                          onClick={() => index < novel.chapters.length - 1 && reorderChapters(novel.id, index, index + 1)}
                          disabled={index === novel.chapters.length - 1}
                          className="text-gray-300 hover:text-gray-500 disabled:opacity-20 text-xs leading-none"
                        >▼</button>
                      </div>
                      <span className="flex-1 truncate text-xs font-medium">{chapter.title}</span>
                      <button
                        onClick={(e) => { e.stopPropagation(); if (confirm('确定删除「' + chapter.title + '」？')) deleteChapter(chapter.id) }}
                        className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 text-xs px-1"
                      >✕</button>
                    </div>
                    <div className="flex items-center gap-2 mt-0.5 pl-5">
                      <span className="text-xs text-gray-400">{chapter.wordCount} 字</span>
                      <span className="text-xs text-gray-300">·</span>
                      <span className="text-xs text-gray-400">{formatTime(chapter.updatedAt)}</span>
                    </div>
                  </div>
                ))}

                {isCreatingChapter && currentNovelId === novel.id ? (
                  <div className="p-2 ml-3 bg-white rounded border">
                    <input
                      type="text" value={newChapterTitle}
                      onChange={(e) => setNewChapterTitle(e.target.value)}
                      placeholder="章节名称"
                      className="w-full px-2 py-1 text-sm border rounded mb-2"
                      autoFocus
                      onKeyDown={(e) => e.key === 'Enter' && handleCreateChapter()}
                    />
                    <div className="flex gap-2">
                      <button onClick={handleCreateChapter} className="flex-1 px-2 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600">创建</button>
                      <button onClick={() => setIsCreatingChapter(false)} className="flex-1 px-2 py-1 text-sm border rounded hover:bg-gray-50">取消</button>
                    </div>
                  </div>
                ) : (
                  currentNovelId === novel.id && (
                    <button onClick={() => setIsCreatingChapter(true)} className="w-full ml-3 mt-1 p-2 text-xs text-blue-600 hover:bg-blue-50 rounded border border-dashed border-blue-300">
                      + 新建章节
                    </button>
                  )
                )}
              </div>
            )}
          </div>
        ))}
      </div>

      <div className="p-3 border-t border-gray-200 space-y-2">
        <div className="flex justify-between text-sm text-gray-600">
          <span>总字数：</span>
          <span className="font-medium">{totalWords.toLocaleString()}</span>
        </div>
        {isEditingGoal ? (
          <div className="flex gap-1 items-center">
            <input type="number" value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="今日目标字数" className="flex-1 px-2 py-1 text-xs border rounded" autoFocus onKeyDown={(e) => e.key === 'Enter' && handleSetGoal()} />
            <button onClick={handleSetGoal} className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">✓</button>
            <button onClick={() => { setIsEditingGoal(false); setGoalInput('') }} className="px-2 py-1 text-xs border rounded hover:bg-gray-50">✕</button>
          </div>
        ) : goalData.goal > 0 ? (
          <div className="space-y-1">
            <div className="flex justify-between items-center">
              <span className="text-xs text-gray-500">今日 {todayWords.toLocaleString()} / {goalData.goal.toLocaleString()} 字{goalDone ? ' ✓ 完成' : ''}</span>
              <button onClick={() => { setGoalInput(String(goalData.goal)); setIsEditingGoal(true) }} className="text-xs text-gray-400 hover:text-blue-500">改</button>
            </div>
            <div className="w-full bg-gray-200 rounded-full h-1.5">
              <div className={`h-1.5 rounded-full transition-all ${goalDone ? 'bg-green-500' : 'bg-blue-400'}`} style={{ width: goalProgress + '%' }} />
            </div>
            <div className="text-right text-xs text-gray-400">{goalProgress}%</div>
          </div>
        ) : (
          <button onClick={() => setIsEditingGoal(true)} className="w-full text-xs text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded py-1 border border-dashed border-gray-200 hover:border-blue-300">+ 设置今日目标</button>
        )}
      </div>
    </div>
  )
}
```