Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
296 lines
14 KiB
Markdown
296 lines
14 KiB
Markdown
# 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>
|
|
)
|
|
}
|
|
``` |