901 lines
51 KiB
TypeScript
901 lines
51 KiB
TypeScript
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>
|
||
}
|