hololake-system-architecture/product-source/hololake-native-desktop/src/WebNovelWorkspace.tsx

901 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import './web-novel.css'
import { AuthorModuleCenter } from './AuthorModuleCenter'
import { AuthorWritingSidecar } from './AuthorWritingSidecar'
type WorkspaceMode = 'writing' | 'modules' | 'story' | 'review' | 'operations' | 'settings'
interface WorkSummary {
workId: string
title: string
penName: string
genre: string
workKind: 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA'
workflowStatus: string
targetWords: number
totalWords: number
chapterCount: number
publishedChapterCount: number
revision: number
updatedAtUnixMs: number
}
interface Work {
workId: string
title: string
penName: string
genre: string
workKind: 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA'
synopsis: string
contractStatus: string
copyrightStatus: string
workflowStatus: string
targetWords: number
revision: number
createdAtUnixMs: number
updatedAtUnixMs: number
}
interface Volume {
volumeId: string
workId: string
title: string
position: number
revision: number
}
interface ChapterSummary {
chapterId: string
volumeId: string
workId: string
title: string
position: number
synopsis: string
workflowStatus: string
scheduledAtUnixMs?: number
wordCount: number
revision: number
updatedAtUnixMs: number
}
interface Chapter extends ChapterSummary {
content: string
createdAtUnixMs: number
}
interface StoryEntity {
entityId: string
workId: string
entityType: string
name: string
aliases: string[]
description: string
firstChapterId?: string
revision: number
}
interface StoryRelation {
relationId: string
sourceEntityId: string
targetEntityId: string
relationType: string
note: string
}
interface Foreshadow {
foreshadowId: string
title: string
setupChapterId: string
payoffChapterId?: string
status: string
note: string
revision: number
}
interface ReviewNote {
noteId: string
chapterId: string
note: string
status: string
createdAtUnixMs: number
}
interface WorkflowEvent {
eventId: string
chapterId: string
fromStatus: string
toStatus: string
note: string
createdAtUnixMs: number
}
interface Checkpoint {
checkpointId: string
name: string
description: string
chapterCount: number
wordCount: number
createdAtUnixMs: number
}
interface Metric {
metricId: string
metricDate: string
views: number
follows: number
comments: number
paidReaders: number
revenueCents: number
sourceLabel: string
revision: number
}
interface Operations {
totalWords: number
draftChapters: number
editorReviewChapters: number
approvedChapters: number
scheduledChapters: number
publishedChapters: number
openReviewNotes: number
openForeshadows: number
latestViews: number
latestFollows: number
latestComments: number
latestPaidReaders: number
latestRevenueCents: number
}
interface WorkDetail {
work: Work
volumes: Volume[]
chapters: ChapterSummary[]
entities: StoryEntity[]
relations: StoryRelation[]
foreshadows: Foreshadow[]
reviewNotes: ReviewNote[]
workflowEvents: WorkflowEvent[]
checkpoints: Checkpoint[]
metrics: Metric[]
operations: Operations
}
interface WorkspaceSnapshot {
state: string
works: WorkSummary[]
workCount: number
storage: string
authority: string
}
interface ContinuityIssue {
issueId: string
severity: string
code: string
title: string
detail: string
objectId: string
}
interface ContinuityAudit {
state: string
issueCount: number
blockingIssueCount: number
warningIssueCount: number
issues: ContinuityIssue[]
checkedAtUnixMs: number
}
interface ImportSectionPreview {
ordinal: number
title: string
wordCount: number
sceneCount: number
preview: string
}
interface DocumentImportPreview {
importId: string
sourceFilename: string
sourceFormat: string
sourceSha256: string
sourceBytes: number
detectedFamily: 'NOVEL' | 'OUTLINE' | 'SCRIPT'
detectedTitle: string
detectedPenName: string
detectedGenre: string
sectionCount: number
totalWordCount: number
prefaceWordCount: number
sections: ImportSectionPreview[]
}
interface DocumentImportReceipt {
workId: string
chapterCount: number
wordCount: number
firstChapterTitle: string
lastChapterTitle: string
}
const EMPTY_OPERATIONS: Operations = {
totalWords: 0,
draftChapters: 0,
editorReviewChapters: 0,
approvedChapters: 0,
scheduledChapters: 0,
publishedChapters: 0,
openReviewNotes: 0,
openForeshadows: 0,
latestViews: 0,
latestFollows: 0,
latestComments: 0,
latestPaidReaders: 0,
latestRevenueCents: 0,
}
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
SELF_REVIEW: '作者自检',
EDITOR_REVIEW: '编辑审核',
REVISION_REQUIRED: '退修',
APPROVED: '审核通过',
SCHEDULED: '待发布',
PUBLISHED: '已发布',
}
const NEXT_STATUS: Record<string, string[]> = {
DRAFT: ['SELF_REVIEW'],
SELF_REVIEW: ['DRAFT', 'EDITOR_REVIEW'],
EDITOR_REVIEW: ['REVISION_REQUIRED', 'APPROVED'],
REVISION_REQUIRED: ['DRAFT', 'EDITOR_REVIEW'],
APPROVED: ['REVISION_REQUIRED', 'SCHEDULED', 'PUBLISHED'],
SCHEDULED: ['APPROVED', 'PUBLISHED'],
PUBLISHED: ['REVISION_REQUIRED'],
}
function errorMessage(error: unknown) {
const raw = error instanceof Error ? error.message : String(error)
return raw.replace(/^HOLOLAKE_WEBNOVEL_[A-Z_]+:\s*/, '')
}
function formatTime(value: number) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
function statusLabel(value: string) {
return STATUS_LABELS[value] || value
}
function outlineRows(content: string) {
return content.split(/\n+/).map((raw, index) => {
const line = raw.trim()
const field = line.match(/^([^:]{1,18})[:]\s*(.*)$/)
if (field) return { key: index, kind: 'field', label: field[1].trim(), value: field[2].trim() }
if (/^[-—•·*①②③④⑤⑥⑦⑧⑨⑩]/.test(line)) return { key: index, kind: 'beat', label: '情节点', value: line.replace(/^[-—•·*]\s*/, '') }
return { key: index, kind: 'note', label: '叙事说明', value: line }
}).filter((item) => item.value)
}
export function WebNovelWorkspace({ onBack }: { onBack: () => void }) {
const [snapshot, setSnapshot] = useState<WorkspaceSnapshot | null>(null)
const [detail, setDetail] = useState<WorkDetail | null>(null)
const [activeChapter, setActiveChapter] = useState<Chapter | null>(null)
const [mode, setMode] = useState<WorkspaceMode>('writing')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('正在打开当前账号的网文工作空间…')
const [createFeedback, setCreateFeedback] = useState<{ state: 'idle' | 'pending' | 'success' | 'error'; text: string }>({ state: 'idle', text: '' })
const [audit, setAudit] = useState<ContinuityAudit | null>(null)
const [newWork, setNewWork] = useState({ title: '', penName: '', genre: '', workKind: 'LONG_NOVEL' as 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA' })
const [entityDraft, setEntityDraft] = useState({ entityType: 'CHARACTER', name: '', aliases: '', description: '' })
const [foreshadowDraft, setForeshadowDraft] = useState({ title: '', setupChapterId: '', payoffChapterId: '', note: '' })
const [relationDraft, setRelationDraft] = useState({ sourceEntityId: '', targetEntityId: '', relationType: '人物关系', note: '' })
const [reviewDraft, setReviewDraft] = useState('')
const [metricDraft, setMetricDraft] = useState({ metricDate: new Date().toISOString().slice(0, 10), views: '0', follows: '0', comments: '0', paidReaders: '0', revenueYuan: '0', sourceLabel: '人工录入' })
const [importPreview, setImportPreview] = useState<DocumentImportPreview | null>(null)
const [importDraft, setImportDraft] = useState({ targetMode: 'CREATE_NEW', targetWorkId: '', title: '', penName: '', genre: '' })
const chapterRef = useRef<Chapter | null>(null)
const dirtyRef = useRef(false)
const saveTimer = useRef<number | null>(null)
const saveQueue = useRef<Promise<void>>(Promise.resolve())
const flushRef = useRef<() => Promise<void>>(async () => undefined)
const activityRef = useRef({ activityMs: 0, wordsDelta: 0, lastTypedAt: 0, workId: '', chapterId: '' })
const flushWritingActivity = useCallback(async () => {
const pending = activityRef.current
if (!pending.workId || !pending.chapterId || pending.activityMs < 1000) return
activityRef.current = { ...pending, activityMs: 0, wordsDelta: 0 }
const now = new Date()
const localDate = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10)
try {
await invoke('record_web_novel_writing_activity', { input: {
activityId: `WN-ACT-${crypto.randomUUID()}`,
workId: pending.workId,
chapterId: pending.chapterId,
localDate,
activeMs: Math.min(300_000, Math.round(pending.activityMs)),
wordsDelta: Math.max(-100_000, Math.min(100_000, pending.wordsDelta)),
} })
} catch {
activityRef.current.activityMs += pending.activityMs
activityRef.current.wordsDelta += pending.wordsDelta
}
}, [])
const refreshSnapshot = useCallback(async (preferWorkId?: string) => {
const next = await invoke<WorkspaceSnapshot>('get_web_novel_workspace_snapshot')
setSnapshot(next)
const workId = preferWorkId || detail?.work.workId || next.works[0]?.workId
if (workId) {
const nextDetail = await invoke<WorkDetail>('read_web_novel_work', { input: { workId } })
setDetail(nextDetail)
} else {
setDetail(null)
}
return next
}, [detail?.work.workId])
useEffect(() => {
let cancelled = false
invoke<WorkspaceSnapshot>('get_web_novel_workspace_snapshot')
.then(async (next) => {
if (cancelled) return
setSnapshot(next)
if (next.works[0]) {
const nextDetail = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: next.works[0].workId } })
if (!cancelled) setDetail(nextDetail)
}
if (!cancelled) setMessage(next.works.length ? '工作空间已加载' : '当前账号还没有作品,请先创建一本。')
})
.catch((error) => !cancelled && setMessage(`无法打开工作空间:${errorMessage(error)}`))
return () => {
cancelled = true
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current)
void flushWritingActivity()
void flushRef.current()
}
}, [flushWritingActivity])
useEffect(() => {
const timer = window.setInterval(() => void flushWritingActivity(), 30_000)
return () => window.clearInterval(timer)
}, [flushWritingActivity])
useEffect(() => {
chapterRef.current = activeChapter
}, [activeChapter])
const flushChapterSave = useCallback(async () => {
if (saveTimer.current !== null) {
window.clearTimeout(saveTimer.current)
saveTimer.current = null
}
if (!chapterRef.current || !dirtyRef.current) return saveQueue.current
saveQueue.current = saveQueue.current.then(async () => {
const draft = chapterRef.current
if (!draft || !dirtyRef.current) return
dirtyRef.current = false
const payload = {
chapterId: draft.chapterId,
title: draft.title,
synopsis: draft.synopsis,
content: draft.content,
expectedRevision: draft.revision,
saveReason: 'AUTOSAVE',
}
try {
const saved = await invoke<Chapter>('save_web_novel_chapter', { input: payload })
setActiveChapter((current) => {
if (!current || current.chapterId !== saved.chapterId) return current
const unchanged = current.title === payload.title && current.synopsis === payload.synopsis && current.content === payload.content
const next = unchanged ? saved : { ...current, revision: saved.revision, wordCount: saved.wordCount, updatedAtUnixMs: saved.updatedAtUnixMs }
chapterRef.current = next
return next
})
setDetail((current) => current ? {
...current,
chapters: current.chapters.map((chapter) => chapter.chapterId === saved.chapterId ? saved : chapter),
operations: { ...current.operations, totalWords: current.operations.totalWords - (current.chapters.find((chapter) => chapter.chapterId === saved.chapterId)?.wordCount || 0) + saved.wordCount },
} : current)
setMessage(`已保存 · 修订 ${saved.revision}`)
} catch (error) {
dirtyRef.current = true
setMessage(`保存失败:${errorMessage(error)}`)
}
})
return saveQueue.current
}, [])
flushRef.current = flushChapterSave
const updateChapter = (patch: Partial<Chapter>) => {
setActiveChapter((current) => {
if (!current) return current
if (typeof patch.content === 'string' && patch.content !== current.content) {
const now = Date.now()
const pending = activityRef.current
const sameChapter = pending.chapterId === current.chapterId
const elapsed = sameChapter && pending.lastTypedAt && now - pending.lastTypedAt < 5_000 ? now - pending.lastTypedAt : 1_000
const count = (value: string) => Array.from(value).filter((character) => !/\s/.test(character)).length
activityRef.current = {
activityMs: (sameChapter ? pending.activityMs : 0) + elapsed,
wordsDelta: (sameChapter ? pending.wordsDelta : 0) + count(patch.content) - count(current.content),
lastTypedAt: now,
workId: current.workId,
chapterId: current.chapterId,
}
if (activityRef.current.activityMs >= 15_000) void flushWritingActivity()
}
const next = { ...current, ...patch }
chapterRef.current = next
return next
})
dirtyRef.current = true
setMessage('正在保存…')
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current)
saveTimer.current = window.setTimeout(() => void flushChapterSave(), 650)
}
const runAction = async (action: () => Promise<void>) => {
setBusy(true)
try {
await action()
} catch (error) {
setMessage(errorMessage(error))
} finally {
setBusy(false)
}
}
const selectWork = (workId: string) => void runAction(async () => {
await flushChapterSave()
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId } })
setDetail(next)
setActiveChapter(null)
setAudit(null)
setMessage(`已打开《${next.work.title}`)
})
const createWork = async () => {
if (!newWork.title.trim() || !newWork.penName.trim() || !newWork.genre.trim()) {
const text = '请把作品名、笔名和类型填写完整。'
setCreateFeedback({ state: 'error', text })
setMessage(text)
return
}
setBusy(true)
setCreateFeedback({ state: 'pending', text: '正在建立作品与第一卷…' })
try {
const next = await invoke<WorkDetail>('create_web_novel_work', { input: newWork })
setDetail(next)
setNewWork({ title: '', penName: '', genre: '', workKind: 'LONG_NOVEL' })
await refreshSnapshot(next.work.workId)
const text = `${next.work.title}》已创建,第一卷已经可以开始写作。`
setCreateFeedback({ state: 'success', text })
setMessage(text)
} catch (error) {
const text = `作品没有创建:${errorMessage(error)}`
setCreateFeedback({ state: 'error', text })
setMessage(text)
} finally {
setBusy(false)
}
}
const inspectDocument = () => void runAction(async () => {
const preview = await invoke<DocumentImportPreview | null>('inspect_web_novel_document_from_dialog')
if (!preview) {
setMessage('已取消选择文档。')
return
}
setImportPreview(preview)
setImportDraft({
targetMode: 'CREATE_NEW',
targetWorkId: detail?.work.workId || '',
title: preview.detectedTitle,
penName: preview.detectedPenName,
genre: preview.detectedGenre,
})
setMessage(`已真实读取「${preview.sourceFilename}」,等待确认拆分结果。`)
})
const commitDocumentImport = () => void runAction(async () => {
if (!importPreview) return
if (importDraft.targetMode === 'CREATE_NEW' && (!importDraft.title.trim() || !importDraft.penName.trim() || !importDraft.genre.trim())) {
setMessage('请确认作品名、笔名和类型。')
return
}
if (importDraft.targetMode === 'APPEND_EXISTING' && !importDraft.targetWorkId) {
setMessage('请选择要追加到的作品。')
return
}
const receipt = await invoke<DocumentImportReceipt>('commit_web_novel_document_import', { input: {
importId: importPreview.importId,
...importDraft,
targetWorkId: importDraft.targetWorkId || null,
} })
setImportPreview(null)
const next = await refreshSnapshot(receipt.workId)
const importedDetail = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: receipt.workId } })
setDetail(importedDetail)
const firstImported = importedDetail.chapters.find((chapter) => chapter.title === receipt.firstChapterTitle)
if (firstImported) {
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId: firstImported.chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
}
setMode('writing')
setSnapshot(next)
setMessage(`导入完成:${receipt.chapterCount} 个分段、${receipt.wordCount.toLocaleString()} 字,首章已从数据库重新打开。`)
})
const createVolume = () => void runAction(async () => {
if (!detail) return
const title = window.prompt('新卷名称', `${detail.volumes.length + 1}`)
if (!title?.trim()) return
const next = await invoke<WorkDetail>('create_web_novel_volume', { input: { workId: detail.work.workId, title } })
setDetail(next)
setMessage(`已创建「${title}`)
})
const createChapter = (volumeId: string) => void runAction(async () => {
if (!detail) return
await flushWritingActivity()
await flushChapterSave()
const count = detail.chapters.filter((item) => item.volumeId === volumeId).length
const unit = detail.work.workKind === 'SHORT_DRAMA' ? '集' : '章'
const title = window.prompt(`${unit}名称`, `${count + 1}${unit}`)
if (!title?.trim()) return
const chapter = await invoke<Chapter>('create_web_novel_chapter', { input: { workId: detail.work.workId, volumeId, title } })
setActiveChapter(chapter)
chapterRef.current = chapter
dirtyRef.current = false
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: detail.work.workId } })
setDetail(next)
setMode('writing')
setMessage('章节已创建,正文会自动保存并生成版本。')
})
const openChapter = (chapterId: string) => void runAction(async () => {
await flushWritingActivity()
await flushChapterSave()
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
dirtyRef.current = false
setMode('writing')
setMessage(`已打开「${chapter.title}`)
})
const formatActiveChapter = async (preset: string) => {
await flushChapterSave()
const chapter = chapterRef.current
if (!chapter) return
const saved = await invoke<Chapter>('format_web_novel_chapter', { input: { chapterId: chapter.chapterId, expectedRevision: chapter.revision, preset } })
setActiveChapter(saved)
chapterRef.current = saved
setDetail(await invoke<WorkDetail>('read_web_novel_work', { input: { workId: saved.workId } }))
setMessage(`一键排版完成:段落之间已留一行;原稿已保留,当前为修订 ${saved.revision}`)
}
const formatWholeWork = async (preset: string) => {
await flushChapterSave()
if (!detail) return
const receipt = await invoke<{ formattedChapterCount: number }>('format_web_novel_work', { input: { workId: detail.work.workId, preset } })
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: detail.work.workId } })
setDetail(next)
if (chapterRef.current) {
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId: chapterRef.current.chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
}
setMessage(`整部作品排版完成:${receipt.formattedChapterCount} 章已处理,每章原稿均保留在版本链。`)
}
const transitionChapter = (toStatus: string) => void runAction(async () => {
await flushChapterSave()
const chapter = chapterRef.current
if (!chapter) return
const note = window.prompt(`转为“${statusLabel(toStatus)}”的说明`, '') ?? ''
let scheduledAtUnixMs: number | undefined
if (toStatus === 'SCHEDULED') {
const raw = window.prompt('计划发布时间(例如 2026-08-20 20:00', '')
if (!raw) return
scheduledAtUnixMs = new Date(raw.replace(' ', 'T')).getTime()
if (!Number.isFinite(scheduledAtUnixMs)) {
setMessage('发布时间格式无法识别。')
return
}
}
const saved = await invoke<Chapter>('transition_web_novel_chapter', { input: { chapterId: chapter.chapterId, toStatus, scheduledAtUnixMs, note, expectedRevision: chapter.revision } })
setActiveChapter(saved)
chapterRef.current = saved
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: saved.workId } })
setDetail(next)
setMessage(`章节已进入“${statusLabel(saved.workflowStatus)}`)
})
const saveWorkSettings = () => void runAction(async () => {
if (!detail) return
const next = await invoke<WorkDetail>('save_web_novel_work', { input: {
workId: detail.work.workId,
title: detail.work.title,
penName: detail.work.penName,
genre: detail.work.genre,
workKind: detail.work.workKind,
synopsis: detail.work.synopsis,
contractStatus: detail.work.contractStatus,
copyrightStatus: detail.work.copyrightStatus,
targetWords: detail.work.targetWords,
expectedRevision: detail.work.revision,
} })
setDetail(next)
await refreshSnapshot(next.work.workId)
setMessage('作品资料已保存。')
})
const createEntity = () => void runAction(async () => {
if (!detail || !entityDraft.name.trim()) return
await invoke<StoryEntity>('upsert_web_novel_story_entity', { input: {
workId: detail.work.workId,
entityId: null,
entityType: entityDraft.entityType,
name: entityDraft.name,
aliases: entityDraft.aliases.split(/[,]/).map((item) => item.trim()).filter(Boolean),
description: entityDraft.description,
firstChapterId: activeChapter?.chapterId || null,
expectedRevision: null,
} })
setEntityDraft({ entityType: 'CHARACTER', name: '', aliases: '', description: '' })
await refreshSnapshot(detail.work.workId)
setMessage('设定卡已写入作品资料库。')
})
const createRelation = () => void runAction(async () => {
if (!detail || !relationDraft.sourceEntityId || !relationDraft.targetEntityId) return
await invoke<StoryRelation>('create_web_novel_story_relation', { input: { workId: detail.work.workId, ...relationDraft } })
setRelationDraft({ sourceEntityId: '', targetEntityId: '', relationType: '人物关系', note: '' })
await refreshSnapshot(detail.work.workId)
setMessage('对象关系已建立。')
})
const createForeshadow = () => void runAction(async () => {
if (!detail || !foreshadowDraft.title.trim() || !foreshadowDraft.setupChapterId) return
await invoke<Foreshadow>('upsert_web_novel_foreshadow', { input: {
workId: detail.work.workId,
foreshadowId: null,
title: foreshadowDraft.title,
setupChapterId: foreshadowDraft.setupChapterId,
payoffChapterId: foreshadowDraft.payoffChapterId || null,
status: foreshadowDraft.payoffChapterId ? 'PAID_OFF' : 'OPEN',
note: foreshadowDraft.note,
expectedRevision: null,
} })
setForeshadowDraft({ title: '', setupChapterId: '', payoffChapterId: '', note: '' })
await refreshSnapshot(detail.work.workId)
setMessage('伏笔已登记,可进入连续性检查。')
})
const createReviewNote = () => void runAction(async () => {
if (!detail || !activeChapter || !reviewDraft.trim()) return
await invoke<ReviewNote>('create_web_novel_review_note', { input: { workId: detail.work.workId, chapterId: activeChapter.chapterId, note: reviewDraft } })
setReviewDraft('')
await refreshSnapshot(detail.work.workId)
setMessage('编辑意见已记录。')
})
const resolveReviewNote = (noteId: string) => void runAction(async () => {
if (!detail) return
await invoke('resolve_web_novel_review_note', { input: { noteId } })
await refreshSnapshot(detail.work.workId)
setMessage('编辑意见已解决。')
})
const createCheckpoint = () => void runAction(async () => {
if (!detail) return
await flushChapterSave()
const name = window.prompt('检查点名称', `手动检查点 · ${new Date().toLocaleString('zh-CN')}`)
if (!name?.trim()) return
await invoke<Checkpoint>('create_web_novel_checkpoint', { input: { workId: detail.work.workId, name, description: '由工作台手动创建' } })
await refreshSnapshot(detail.work.workId)
setMessage('整部作品检查点已创建。')
})
const restoreCheckpoint = (checkpoint: Checkpoint) => void runAction(async () => {
if (!window.confirm(`确定恢复“${checkpoint.name}”吗?当前章节内容会先保留在版本记录中。`)) return
const next = await invoke<WorkDetail>('restore_web_novel_checkpoint', { input: { checkpointId: checkpoint.checkpointId } })
setDetail(next)
setActiveChapter(null)
setMessage('检查点已恢复,当前编辑器已关闭以避免旧修订覆盖。')
})
const runAudit = () => void runAction(async () => {
if (!detail) return
await flushChapterSave()
const next = await invoke<ContinuityAudit>('run_web_novel_continuity_audit', { input: { workId: detail.work.workId } })
setAudit(next)
setMessage(next.state === 'PASS' ? '连续性检查通过。' : `发现 ${next.issueCount} 个需要处理的问题。`)
})
const saveMetric = () => void runAction(async () => {
if (!detail) return
const integer = (value: string) => Math.max(0, Math.floor(Number(value) || 0))
await invoke<Metric>('save_web_novel_metric', { input: {
workId: detail.work.workId,
metricDate: metricDraft.metricDate,
views: integer(metricDraft.views),
follows: integer(metricDraft.follows),
comments: integer(metricDraft.comments),
paidReaders: integer(metricDraft.paidReaders),
revenueCents: Math.round(Math.max(0, Number(metricDraft.revenueYuan) || 0) * 100),
sourceLabel: metricDraft.sourceLabel,
expectedRevision: null,
} })
await refreshSnapshot(detail.work.workId)
setMessage('运营数据已写入当前账号。')
})
const exportMarkdown = () => void runAction(async () => {
if (!detail) return
await flushChapterSave()
const receipt = await invoke<{ path: string; chapterCount: number; wordCount: number } | null>('export_web_novel_markdown', { input: { workId: detail.work.workId } })
setMessage(receipt ? `已导出 ${receipt.chapterCount} 章、${receipt.wordCount} 字。` : '已取消导出。')
})
const chapterName = useMemo(() => new Map(detail?.chapters.map((chapter) => [chapter.chapterId, chapter.title]) || []), [detail?.chapters])
const entityName = useMemo(() => new Map(detail?.entities.map((entity) => [entity.entityId, entity.name]) || []), [detail?.entities])
const activeVolume = detail?.volumes.find((volume) => volume.volumeId === activeChapter?.volumeId)
const isOutlineChapter = Boolean(activeVolume?.title.includes('细纲') || detail?.work.genre.includes('细纲'))
const activeOutlineRows = useMemo(() => outlineRows(activeChapter?.content || ''), [activeChapter?.content])
if (!snapshot) {
return <section className="wn-workspace"><div className="wn-loading"><i/><h2></h2><p>{message}</p><button type="button" onClick={onBack}></button></div></section>
}
return <section className="wn-workspace" aria-label="网文行业工作空间">
<header className="wn-topbar">
<button type="button" onClick={() => void runAction(async () => { await flushChapterSave(); onBack() })}> </button>
<div><span>WEB NOVEL / LOCAL ENGINE</span><b>{detail ? `${detail.work.title}` : '轻量作者工作台 · 模块拆分验收副本'}</b></div>
<small>{busy ? '引擎正在处理…' : message}</small>
</header>
<div className="wn-shell">
<aside className="wn-library">
<header><div><span></span><h2></h2></div><strong>{snapshot.workCount}</strong></header>
<div className="wn-work-list">
{snapshot.works.map((work) => <button className={detail?.work.workId === work.workId ? 'active' : ''} type="button" key={work.workId} onClick={() => selectWork(work.workId)}><b>{work.title}</b><span>{work.penName} · {work.genre}</span><small>{work.chapterCount} · {work.totalWords.toLocaleString()} </small></button>)}
</div>
<form className="wn-create-work" aria-busy={createFeedback.state === 'pending'} onSubmit={(event) => { event.preventDefault(); void createWork() }}>
<button className="wn-import-button" type="button" disabled={busy} onClick={inspectDocument}> / / </button>
<h3></h3>
<input aria-label="作品名" placeholder="作品名" value={newWork.title} onChange={(event) => setNewWork({ ...newWork, title: event.target.value })}/>
<input aria-label="笔名" placeholder="笔名" value={newWork.penName} onChange={(event) => setNewWork({ ...newWork, penName: event.target.value })}/>
<input aria-label="作品类型" placeholder="类型,如玄幻、悬疑" value={newWork.genre} onChange={(event) => setNewWork({ ...newWork, genre: event.target.value })}/>
<select aria-label="写作形态" value={newWork.workKind} onChange={(event) => setNewWork({ ...newWork, workKind: event.target.value as typeof newWork.workKind })}><option value="LONG_NOVEL"></option><option value="SHORT_NOVEL"></option><option value="SHORT_DRAMA"></option></select>
<button type="submit" disabled={busy}>{createFeedback.state === 'pending' ? '正在建立作品…' : ' 建立作品'}</button>
{createFeedback.text && <p className={`wn-create-feedback ${createFeedback.state}`} role={createFeedback.state === 'error' ? 'alert' : 'status'}>{createFeedback.text}</p>}
</form>
</aside>
{detail ? <>
<nav className="wn-nav" aria-label="网文工作台功能">
{([['writing', '码字'], ['modules', '作者模块'], ['story', '设定'], ['review', '编辑'], ['operations', '运营'], ['settings', '作品']] as [WorkspaceMode, string][]).map(([key, label]) => <button type="button" className={mode === key ? 'active' : ''} key={key} onClick={() => setMode(key)}>{label}</button>)}
</nav>
<main className="wn-main">
{mode === 'writing' && <div className="wn-writing-layout">
<aside className="wn-outline">
<header className="wn-outline-actions"><div><h3>{detail.work.workKind === 'SHORT_DRAMA' ? '分集目录' : '章节目录'}</h3><button className="wn-outline-secondary" type="button" onClick={createVolume}> </button></div><button className="wn-new-chapter" type="button" disabled={!detail.volumes.length} onClick={() => createChapter(activeChapter?.volumeId || detail.volumes[0].volumeId)}> {detail.work.workKind === 'SHORT_DRAMA' ? '新建一集' : '新建章节'}</button></header>
{detail.volumes.map((volume) => <section key={volume.volumeId}>
<div><b>{volume.title}</b><button type="button" onClick={() => createChapter(volume.volumeId)}> {detail.work.workKind === 'SHORT_DRAMA' ? '集' : '章'}</button></div>
{detail.chapters.filter((chapter) => chapter.volumeId === volume.volumeId).map((chapter) => <button className={activeChapter?.chapterId === chapter.chapterId ? 'active' : ''} type="button" key={chapter.chapterId} onClick={() => openChapter(chapter.chapterId)}><span>{chapter.title}</span><small>{statusLabel(chapter.workflowStatus)} · {chapter.wordCount} </small></button>)}
</section>)}
</aside>
{activeChapter ? <article className="wn-editor">
<header>
<input aria-label="章节标题" value={activeChapter.title} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ title: event.target.value })}/>
<div><span className={`wn-status status-${activeChapter.workflowStatus.toLowerCase()}`}>{statusLabel(activeChapter.workflowStatus)}</span><small>{Array.from(activeChapter.content).filter((character) => !/\s/.test(character)).length.toLocaleString()} · {activeChapter.revision}</small></div>
</header>
<input className="wn-chapter-synopsis" aria-label="章节梗概" placeholder="本章梗概与场景目标" value={activeChapter.synopsis} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ synopsis: event.target.value })}/>
{isOutlineChapter ? <div className="wn-outline-editor-body"><textarea aria-label="细纲原文编辑" placeholder="按字段或情节点继续补写;右侧结构视图会同步更新。" value={activeChapter.content} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ content: event.target.value })}/><section className="wn-outline-render" aria-label="细纲结构视图"><header><span>STRUCTURED OUTLINE</span><b></b><small></small></header>{activeOutlineRows.map((row) => <article className={`kind-${row.kind}`} key={row.key}><span>{row.label}</span><p>{row.value}</p></article>)}</section></div> : <textarea aria-label="章节正文" placeholder="从这里开始写。停笔 650 毫秒后会保存,并生成可追溯版本。" value={activeChapter.content} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ content: event.target.value })}/>}
<footer>
<button type="button" disabled={!dirtyRef.current || activeChapter.workflowStatus === 'PUBLISHED'} onClick={() => void flushChapterSave()}></button>
<div>{(NEXT_STATUS[activeChapter.workflowStatus] || []).map((status) => <button className="primary" type="button" key={status} onClick={() => transitionChapter(status)}>{statusLabel(status)}</button>)}</div>
</footer>
</article> : <div className="wn-empty"><i/><h2></h2><p></p></div>}
<AuthorWritingSidecar
workId={detail.work.workId}
workKind={detail.work.workKind}
activeChapter={activeChapter}
chapters={detail.chapters}
entities={detail.entities}
onOpenChapter={openChapter}
onFormat={formatActiveChapter}
onFormatWork={formatWholeWork}
onMessage={setMessage}
/>
</div>}
{mode === 'modules' && <AuthorModuleCenter
workId={detail.work.workId}
chapters={detail.chapters.map((chapter) => ({ chapterId: chapter.chapterId, title: chapter.title }))}
entities={detail.entities.map((entity) => ({ entityId: entity.entityId, name: entity.name, entityType: entity.entityType }))}
onMessage={setMessage}
onWorkRefresh={async () => {
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: detail.work.workId } })
setDetail(next)
if (activeChapter) {
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId: activeChapter.chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
}
}}
/>}
{mode === 'story' && <div className="wn-board">
<header><div><span>STORY BIBLE</span><h2></h2></div><button type="button" onClick={runAudit}></button></header>
{audit && <section className={`wn-audit audit-${audit.state.toLowerCase()}`}><b>{audit.state === 'PASS' ? '连续性检查通过' : `${audit.issueCount} 个检查结果`}</b><span>{audit.blockingIssueCount} · {audit.warningIssueCount} </span>{audit.issues.map((issue) => <article key={issue.issueId}><strong>{issue.title}</strong><p>{issue.detail}</p></article>)}</section>}
<div className="wn-board-grid">
<section><h3></h3><form onSubmit={(event) => { event.preventDefault(); createEntity() }}><select value={entityDraft.entityType} onChange={(event) => setEntityDraft({ ...entityDraft, entityType: event.target.value })}><option value="CHARACTER"></option><option value="LOCATION"></option><option value="WORLD_RULE"></option><option value="ORGANIZATION"></option><option value="ITEM"></option></select><input placeholder="名称" value={entityDraft.name} onChange={(event) => setEntityDraft({ ...entityDraft, name: event.target.value })}/><input placeholder="别名,用逗号分隔" value={entityDraft.aliases} onChange={(event) => setEntityDraft({ ...entityDraft, aliases: event.target.value })}/><textarea placeholder="设定描述" value={entityDraft.description} onChange={(event) => setEntityDraft({ ...entityDraft, description: event.target.value })}/><button type="submit"></button></form>{detail.entities.map((entity) => <article key={entity.entityId}><b>{entity.name}</b><small>{entity.entityType} · {entity.aliases.join('、') || '无别名'}</small><p>{entity.description || '尚无描述'}</p></article>)}</section>
<section><h3></h3><form onSubmit={(event) => { event.preventDefault(); createRelation() }}><select value={relationDraft.sourceEntityId} onChange={(event) => setRelationDraft({ ...relationDraft, sourceEntityId: event.target.value })}><option value=""></option>{detail.entities.map((entity) => <option key={entity.entityId} value={entity.entityId}>{entity.name}</option>)}</select><select value={relationDraft.targetEntityId} onChange={(event) => setRelationDraft({ ...relationDraft, targetEntityId: event.target.value })}><option value=""></option>{detail.entities.map((entity) => <option key={entity.entityId} value={entity.entityId}>{entity.name}</option>)}</select><input placeholder="关系类型" value={relationDraft.relationType} onChange={(event) => setRelationDraft({ ...relationDraft, relationType: event.target.value })}/><input placeholder="关系说明" value={relationDraft.note} onChange={(event) => setRelationDraft({ ...relationDraft, note: event.target.value })}/><button type="submit"></button></form>{detail.relations.map((relation) => <article key={relation.relationId}><b>{entityName.get(relation.sourceEntityId)} {entityName.get(relation.targetEntityId)}</b><small>{relation.relationType}</small><p>{relation.note}</p></article>)}</section>
<section><h3></h3><form onSubmit={(event) => { event.preventDefault(); createForeshadow() }}><input placeholder="伏笔名称" value={foreshadowDraft.title} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, title: event.target.value })}/><select value={foreshadowDraft.setupChapterId} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, setupChapterId: event.target.value })}><option value=""></option>{detail.chapters.map((chapter) => <option key={chapter.chapterId} value={chapter.chapterId}>{chapter.title}</option>)}</select><select value={foreshadowDraft.payoffChapterId} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, payoffChapterId: event.target.value })}><option value=""></option>{detail.chapters.map((chapter) => <option key={chapter.chapterId} value={chapter.chapterId}>{chapter.title}</option>)}</select><input placeholder="备注" value={foreshadowDraft.note} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, note: event.target.value })}/><button type="submit"></button></form>{detail.foreshadows.map((item) => <article key={item.foreshadowId}><b>{item.title}</b><small>{chapterName.get(item.setupChapterId)} {item.payoffChapterId ? chapterName.get(item.payoffChapterId) : '待回收'}</small><p>{item.note}</p></article>)}</section>
</div>
</div>}
{mode === 'review' && <div className="wn-board">
<header><div><span>EDITORIAL WORKFLOW</span><h2></h2></div></header>
<div className="wn-review-grid">
<section><h3></h3>{detail.chapters.map((chapter) => <button className={activeChapter?.chapterId === chapter.chapterId ? 'active' : ''} type="button" key={chapter.chapterId} onClick={() => openChapter(chapter.chapterId)}><b>{chapter.title}</b><span>{statusLabel(chapter.workflowStatus)}</span><small>{chapter.wordCount} · {chapter.revision}</small></button>)}</section>
<section><h3></h3>{activeChapter ? <form onSubmit={(event) => { event.preventDefault(); createReviewNote() }}><p>{activeChapter.title}</p><textarea placeholder="写下具体、可执行的修改意见" value={reviewDraft} onChange={(event) => setReviewDraft(event.target.value)}/><button type="submit"></button></form> : <p></p>}{detail.reviewNotes.map((note) => <article key={note.noteId}><b>{chapterName.get(note.chapterId) || '章节'}</b><small>{note.status === 'OPEN' ? '待处理' : '已解决'} · {formatTime(note.createdAtUnixMs)}</small><p>{note.note}</p>{note.status === 'OPEN' && <button type="button" onClick={() => resolveReviewNote(note.noteId)}></button>}</article>)}</section>
<section><h3></h3>{detail.workflowEvents.map((event) => <article key={event.eventId}><b>{chapterName.get(event.chapterId) || '章节'}</b><small>{statusLabel(event.fromStatus)} {statusLabel(event.toStatus)}</small><p>{event.note || '无附加说明'} · {formatTime(event.createdAtUnixMs)}</p></article>)}</section>
</div>
</div>}
{mode === 'operations' && <div className="wn-board">
<header><div><span>OPERATIONS</span><h2></h2></div><div><button type="button" onClick={createCheckpoint}></button><button type="button" onClick={exportMarkdown}> Markdown</button></div></header>
<div className="wn-metrics">{[['总字数', detail.operations.totalWords], ['草稿章', detail.operations.draftChapters], ['编辑审核', detail.operations.editorReviewChapters], ['已通过', detail.operations.approvedChapters], ['待发布', detail.operations.scheduledChapters], ['已发布', detail.operations.publishedChapters], ['未回收伏笔', detail.operations.openForeshadows], ['未解决意见', detail.operations.openReviewNotes]].map(([label, value]) => <article key={label}><span>{label}</span><b>{Number(value).toLocaleString()}</b></article>)}</div>
<div className="wn-operations-grid">
<section><h3></h3><form className="wn-metric-form" onSubmit={(event) => { event.preventDefault(); saveMetric() }}><input type="date" value={metricDraft.metricDate} onChange={(event) => setMetricDraft({ ...metricDraft, metricDate: event.target.value })}/>{(['views', 'follows', 'comments', 'paidReaders'] as const).map((field) => <label key={field}><span>{{ views: '阅读', follows: '追读', comments: '评论', paidReaders: '付费读者' }[field]}</span><input inputMode="numeric" value={metricDraft[field]} onChange={(event) => setMetricDraft({ ...metricDraft, [field]: event.target.value })}/></label>)}<label><span></span><input inputMode="decimal" value={metricDraft.revenueYuan} onChange={(event) => setMetricDraft({ ...metricDraft, revenueYuan: event.target.value })}/></label><input placeholder="数据来源" value={metricDraft.sourceLabel} onChange={(event) => setMetricDraft({ ...metricDraft, sourceLabel: event.target.value })}/><button type="submit"></button></form>{detail.metrics.map((metric) => <article key={metric.metricId}><b>{metric.metricDate} · {metric.sourceLabel}</b><small>{metric.views.toLocaleString()} · {metric.follows.toLocaleString()} · ¥{(metric.revenueCents / 100).toFixed(2)}</small></article>)}</section>
<section><h3></h3>{detail.checkpoints.map((checkpoint) => <article key={checkpoint.checkpointId}><b>{checkpoint.name}</b><small>{checkpoint.chapterCount} · {checkpoint.wordCount} · {formatTime(checkpoint.createdAtUnixMs)}</small><p>{checkpoint.description}</p><button type="button" onClick={() => restoreCheckpoint(checkpoint)}></button></article>)}</section>
</div>
</div>}
{mode === 'settings' && <div className="wn-board wn-settings">
<header><div><span>WORK PROFILE</span><h2></h2></div><button type="button" onClick={saveWorkSettings}></button></header>
<div className="wn-settings-form"><label><span></span><input value={detail.work.title} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, title: event.target.value } })}/></label><label><span></span><input value={detail.work.penName} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, penName: event.target.value } })}/></label><label><span></span><input value={detail.work.genre} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, genre: event.target.value } })}/></label><label><span></span><input inputMode="numeric" value={detail.work.targetWords} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, targetWords: Math.max(0, Number(event.target.value) || 0) } })}/></label><label><span></span><select value={detail.work.contractStatus} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, contractStatus: event.target.value } })}><option value="UNSIGNED"></option><option value="REVIEWING"></option><option value="SIGNED"></option><option value="ENDED"></option></select></label><label><span></span><select value={detail.work.copyrightStatus} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, copyrightStatus: event.target.value } })}><option value="AUTHOR_OWNED"></option><option value="LICENSED"></option><option value="SHARED"></option><option value="UNKNOWN"></option></select></label><label className="wide"><span></span><textarea value={detail.work.synopsis} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, synopsis: event.target.value } })}/></label></div>
<p className="wn-boundary">线</p>
</div>}
</main>
</> : <div className="wn-welcome"><span>REAL ENGINE / EMPTY ACCOUNT</span><h1></h1><p> SQLite </p></div>}
</div>
{importPreview && <div className="wn-import-layer" role="dialog" aria-modal="true" aria-label="真实文档导入确认">
<section className="wn-import-dialog">
<header>
<div><span>DOCUMENT IMPORT / REAL PARSER</span><h2></h2></div>
<button type="button" onClick={() => setImportPreview(null)}></button>
</header>
<div className="wn-import-receipt">
<article><span></span><b>{{ NOVEL: '小说正文', OUTLINE: '拆分细纲', SCRIPT: '分集剧本' }[importPreview.detectedFamily]}</b></article>
<article><span></span><b>{importPreview.sectionCount.toLocaleString()} {importPreview.detectedFamily === 'SCRIPT' ? '集' : '章'}</b></article>
<article><span></span><b>{importPreview.totalWordCount.toLocaleString()}</b></article>
<article><span></span><b>{importPreview.sourceSha256.slice(0, 12)}</b></article>
</div>
<p className="wn-import-source"><b>{importPreview.sourceFilename}</b><span>{importPreview.sourceFormat} · {(importPreview.sourceBytes / 1024).toFixed(1)} KB · {importPreview.prefaceWordCount.toLocaleString()} </span></p>
<div className="wn-import-body">
<section className="wn-import-sections"><h3></h3>{importPreview.sections.map((section) => <article key={`${section.ordinal}-${section.title}`}><b>{section.ordinal}. {section.title}</b><small>{section.wordCount.toLocaleString()} {section.sceneCount ? ` · ${section.sceneCount}` : ''}</small><p>{section.preview || '(空分段)'}</p></article>)}</section>
<form onSubmit={(event) => { event.preventDefault(); commitDocumentImport() }}>
<h3></h3>
<label><span></span><select value={importDraft.targetMode} onChange={(event) => setImportDraft({ ...importDraft, targetMode: event.target.value })}><option value="CREATE_NEW"></option>{snapshot.works.length > 0 && <option value="APPEND_EXISTING"></option>}</select></label>
{importDraft.targetMode === 'APPEND_EXISTING' ? <label><span></span><select value={importDraft.targetWorkId} onChange={(event) => setImportDraft({ ...importDraft, targetWorkId: event.target.value })}>{snapshot.works.map((work) => <option key={work.workId} value={work.workId}>{work.title}</option>)}</select></label> : <>
<label><span></span><input value={importDraft.title} onChange={(event) => setImportDraft({ ...importDraft, title: event.target.value })}/></label>
<label><span></span><input value={importDraft.penName} onChange={(event) => setImportDraft({ ...importDraft, penName: event.target.value })}/></label>
<label><span></span><input value={importDraft.genre} onChange={(event) => setImportDraft({ ...importDraft, genre: event.target.value })}/></label>
</>}
<p></p>
<button className="primary" type="submit" disabled={busy}>{busy ? '正在写入数据库…' : `确认导入 ${importPreview.sectionCount} ${importPreview.detectedFamily === 'SCRIPT' ? '集' : '章'}`}</button>
</form>
</div>
</section>
</div>}
</section>
}