Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
14 KiB
14 KiB
Sidebar.tsx · 功能08版本 · 2026-04-26
妈妈把这里的代码完整复制,粘贴到 VS Code 里的 Sidebar.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>
)
}