feat: admit signed web novel module family

This commit is contained in:
冰朔 2026-08-19 03:51:18 +08:00
commit eb7223ea4c
41 changed files with 10452 additions and 21 deletions

View file

@ -816,6 +816,310 @@ const ROUTES = {
"moduleNumber": "HLP-NIPC-MOD-0024",
"operationNumber": "HLP-NIPC-OP-0102",
"targetNumber": "HLP-NIPC-TGT-0024"
},
"get_web_novel_workspace_snapshot": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0103",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_work": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0104",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"read_web_novel_work": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0105",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"save_web_novel_work": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0106",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_volume": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0107",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_chapter": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0108",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"read_web_novel_chapter": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0109",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"save_web_novel_chapter": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0110",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"transition_web_novel_chapter": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0111",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_checkpoint": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0112",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"restore_web_novel_checkpoint": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0113",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"upsert_web_novel_story_entity": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0114",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_story_relation": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0115",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"upsert_web_novel_foreshadow": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0116",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_review_note": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0117",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"resolve_web_novel_review_note": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0118",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"save_web_novel_metric": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0119",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"run_web_novel_continuity_audit": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0120",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"export_web_novel_markdown": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0121",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"inspect_web_novel_document_from_dialog": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0122",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"commit_web_novel_document_import": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0123",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"get_web_novel_author_snapshot": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0124",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"record_web_novel_writing_activity": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0125",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"create_web_novel_inspiration": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0126",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"set_web_novel_inspiration_status": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0127",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"search_web_novel_full_text": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0128",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"format_web_novel_chapter": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0129",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"format_web_novel_work": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0130",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"upsert_web_novel_shot": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0131",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"get_web_novel_author_module_data": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0025",
"operationNumber": "HLP-NIPC-OP-0132",
"targetNumber": "HLP-NIPC-TGT-0025"
},
"upsert_web_novel_author_scene": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0026",
"operationNumber": "HLP-NIPC-OP-0133",
"targetNumber": "HLP-NIPC-TGT-0026"
},
"upsert_web_novel_author_beat": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0026",
"operationNumber": "HLP-NIPC-OP-0134",
"targetNumber": "HLP-NIPC-TGT-0026"
},
"upsert_web_novel_story_field_definition": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0027",
"operationNumber": "HLP-NIPC-OP-0135",
"targetNumber": "HLP-NIPC-TGT-0027"
},
"upsert_web_novel_story_field_value": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0027",
"operationNumber": "HLP-NIPC-OP-0136",
"targetNumber": "HLP-NIPC-TGT-0027"
},
"upsert_web_novel_timeline_event": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0028",
"operationNumber": "HLP-NIPC-OP-0137",
"targetNumber": "HLP-NIPC-TGT-0028"
},
"link_web_novel_scene_entity": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0028",
"operationNumber": "HLP-NIPC-OP-0138",
"targetNumber": "HLP-NIPC-TGT-0028"
},
"restore_web_novel_chapter_version": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0029",
"operationNumber": "HLP-NIPC-OP-0139",
"targetNumber": "HLP-NIPC-TGT-0029"
},
"export_web_novel_author_delivery": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0029",
"operationNumber": "HLP-NIPC-OP-0140",
"targetNumber": "HLP-NIPC-TGT-0029"
}
} as const

View file

@ -0,0 +1,228 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { numberedInvoke as invoke } from '../numbered-ipc'
interface ChapterRef { chapterId: string; title: string }
interface EntityRef { entityId: string; name: string; entityType: string }
interface BundledModuleDescriptor { moduleNumber: string; displayName: string; version: string; adapter: string; permissions: string[]; packageSha256: string; installedState: string; signatureVerified: boolean }
interface ModuleDescriptor { moduleId: string; name: string; description: string; version: string; packageSha256: string; installState: 'AVAILABLE' | 'INSTALLED' | 'MOUNTED'; selfTestState: string }
interface ModuleReceipt { receiptId: string; moduleId: string; action: string; state: string; detail: string; receiptHash: string; createdAtUnixMs: number }
interface Marketplace { state: string; modules: ModuleDescriptor[]; recentReceipts: ModuleReceipt[] }
interface Scene { sceneId: string; workId: string; chapterId: string; title: string; position: number; synopsis: string; status: string; goal: string; conflict: string; outcome: string; hook: string; emotionalPoint: string; targetWords: number; revision: number }
interface Beat { beatId: string; sceneId: string; position: number; title: string; note: string; status: string; revision: number }
interface FieldDefinition { fieldId: string; label: string; fieldType: string; options: string[]; position: number; revision: number }
interface FieldValue { fieldId: string; sceneId: string; value: string; revision: number }
interface TimelineEvent { eventId: string; sceneId?: string; entityId?: string; title: string; calendarKind: string; storyDay?: number; dateText: string; timeText: string; durationMinutes: number; description: string; revision: number }
interface ChapterVersion { versionId: string; chapterId: string; chapterTitle: string; revision: number; wordCount: number; saveReason: string; createdAtUnixMs: number }
interface ModuleData { scenes: Scene[]; beats: Beat[]; fieldDefinitions: FieldDefinition[]; fieldValues: FieldValue[]; timelineEvents: TimelineEvent[]; chapterVersions: ChapterVersion[] }
interface DeliveryReceipt { format: string; path: string; bytes: number; chapterCount: number; packageSha256: string }
const MODULES = {
outline: 'HLP-MOD-OFFICIAL-WEB-NOVEL-OUTLINE-0001',
grid: 'HLP-MOD-OFFICIAL-WEB-NOVEL-GRID-0001',
storyworld: 'HLP-MOD-OFFICIAL-WEB-NOVEL-STORYWORLD-0001',
delivery: 'HLP-MOD-OFFICIAL-WEB-NOVEL-DELIVERY-0001',
} as const
const MODULE_DESCRIPTIONS: Record<string, string> = {
[MODULES.outline]: '场景、情节拍、目标冲突结果、钩子与伏笔追踪。',
[MODULES.grid]: '由同一场景对象生成可编辑表格、自定义字段与分组情节板。',
[MODULES.storyworld]: '故事时间、场景与人物地点物品的真实关系投影。',
[MODULES.delivery]: '章节版本读回与恢复,以及 TXT、DOCX、EPUB、JSON 真实导出。',
}
function readableError(error: unknown) {
return String(error instanceof Error ? error.message : error).replace(/^HOLOLAKE_WEBNOVEL_[A-Z_]+:?\s*/, '')
}
export function AuthorModuleCenter({ workId, chapters, entities, onMessage, onWorkRefresh }: {
workId: string
chapters: ChapterRef[]
entities: EntityRef[]
onMessage: (message: string) => void
onWorkRefresh: () => Promise<void>
}) {
const [marketplace, setMarketplace] = useState<Marketplace | null>(null)
const [data, setData] = useState<ModuleData | null>(null)
const [activeModule, setActiveModule] = useState<string>(MODULES.outline)
const [busy, setBusy] = useState(false)
const [sceneDraft, setSceneDraft] = useState({ chapterId: chapters[0]?.chapterId || '', title: '', synopsis: '', status: 'PLANNED', goal: '', conflict: '', outcome: '', hook: '', emotionalPoint: '', targetWords: '1200' })
const [fieldDraft, setFieldDraft] = useState({ label: '', fieldType: 'TEXT', options: '' })
const [timelineDraft, setTimelineDraft] = useState({ title: '', calendarKind: 'STORY_DAY', storyDay: '1', dateText: '', timeText: '', durationMinutes: '0', description: '', sceneId: '', entityId: '' })
const refresh = useCallback(async () => {
const catalog = await invoke<BundledModuleDescriptor[]>('get_bundled_module_catalog')
const nextMarketplace: Marketplace = {
state: 'READY',
modules: catalog.filter((item) => Object.values(MODULES).includes(item.moduleNumber as typeof MODULES[keyof typeof MODULES])).map((item) => ({
moduleId: item.moduleNumber,
name: item.displayName,
description: MODULE_DESCRIPTIONS[item.moduleNumber] || '光湖官方编号模块。',
version: item.version,
packageSha256: item.packageSha256,
installState: item.installedState === 'ACTIVE' ? 'MOUNTED' : item.installedState === 'NOT_INSTALLED' ? 'AVAILABLE' : 'INSTALLED',
selfTestState: item.installedState === 'ACTIVE' ? 'PASS' : item.signatureVerified ? 'SIGNED' : 'UNVERIFIED',
})),
recentReceipts: [],
}
const nextData = await invoke<ModuleData>('get_web_novel_author_module_data', { input: { workId } })
setMarketplace(nextMarketplace)
setData(nextData)
}, [workId])
useEffect(() => {
let cancelled = false
refresh().catch((error) => !cancelled && onMessage(`模块运行时读取失败:${readableError(error)}`))
return () => { cancelled = true }
}, [refresh, onMessage])
useEffect(() => {
if (!sceneDraft.chapterId && chapters[0]) setSceneDraft((current) => ({ ...current, chapterId: chapters[0].chapterId }))
}, [chapters, sceneDraft.chapterId])
const run = async (action: () => Promise<void>) => {
setBusy(true)
try { await action() } catch (error) { onMessage(readableError(error)) } finally { setBusy(false) }
}
const moduleAction = (action: 'activate' | 'unmount', moduleId: string, success: string) => void run(async () => {
if (action === 'activate') await invoke('activate_bundled_module', { input: { moduleNumber: moduleId, humanConfirmedPermissionExpansion: true } })
else await invoke('unmount_module', { input: { moduleNumber: moduleId } })
await refresh()
onMessage(success)
})
const createScene = () => void run(async () => {
if (!sceneDraft.chapterId || !sceneDraft.title.trim()) { onMessage('请选择章节并填写场景名称。'); return }
await invoke('upsert_web_novel_author_scene', { input: {
workId, sceneId: null, chapterId: sceneDraft.chapterId, title: sceneDraft.title,
position: null, synopsis: sceneDraft.synopsis, status: sceneDraft.status,
goal: sceneDraft.goal, conflict: sceneDraft.conflict, outcome: sceneDraft.outcome,
hook: sceneDraft.hook, emotionalPoint: sceneDraft.emotionalPoint,
targetWords: Math.max(0, Number(sceneDraft.targetWords) || 0), expectedRevision: null,
} })
setSceneDraft((current) => ({ ...current, title: '', synopsis: '', goal: '', conflict: '', outcome: '', hook: '', emotionalPoint: '' }))
await refresh()
onMessage('场景已写入作品结构库。')
})
const editScene = (scene: Scene, patch: Partial<Scene>) => void run(async () => {
await invoke('upsert_web_novel_author_scene', { input: {
workId, sceneId: scene.sceneId, chapterId: patch.chapterId ?? scene.chapterId,
title: patch.title ?? scene.title, position: patch.position ?? scene.position,
synopsis: patch.synopsis ?? scene.synopsis, status: patch.status ?? scene.status,
goal: patch.goal ?? scene.goal, conflict: patch.conflict ?? scene.conflict,
outcome: patch.outcome ?? scene.outcome, hook: patch.hook ?? scene.hook,
emotionalPoint: patch.emotionalPoint ?? scene.emotionalPoint,
targetWords: patch.targetWords ?? scene.targetWords, expectedRevision: scene.revision,
} })
await refresh()
onMessage('情节表修订已落库。')
})
const addBeat = (scene: Scene) => {
const title = window.prompt('情节拍名称', '')
if (!title?.trim()) return
const note = window.prompt('这一拍发生什么?', '') || ''
void run(async () => {
await invoke('upsert_web_novel_author_beat', { input: { sceneId: scene.sceneId, beatId: null, title, note, status: 'PLANNED', position: null, expectedRevision: null } })
await refresh(); onMessage('情节拍已追加。')
})
}
const addField = () => void run(async () => {
if (!fieldDraft.label.trim()) return
await invoke('upsert_web_novel_story_field_definition', { input: { workId, fieldId: null, label: fieldDraft.label, fieldType: fieldDraft.fieldType, options: fieldDraft.options.split(/[,]/).map((value) => value.trim()).filter(Boolean), expectedRevision: null } })
setFieldDraft({ label: '', fieldType: 'TEXT', options: '' }); await refresh(); onMessage('自定义情节维度已添加。')
})
const editFieldValue = (field: FieldDefinition, scene: Scene) => {
const current = data?.fieldValues.find((value) => value.fieldId === field.fieldId && value.sceneId === scene.sceneId)
const value = window.prompt(`${scene.title} · ${field.label}`, current?.value || '')
if (value === null) return
void run(async () => {
await invoke('upsert_web_novel_story_field_value', { input: { fieldId: field.fieldId, sceneId: scene.sceneId, value, expectedRevision: current?.revision ?? null } })
await refresh(); onMessage(`已保存“${field.label}”。`)
})
}
const createTimeline = () => void run(async () => {
if (!timelineDraft.title.trim()) return
await invoke('upsert_web_novel_timeline_event', { input: {
workId, eventId: null, sceneId: timelineDraft.sceneId || null, entityId: timelineDraft.entityId || null,
title: timelineDraft.title, calendarKind: timelineDraft.calendarKind,
storyDay: timelineDraft.storyDay ? Number(timelineDraft.storyDay) : null,
dateText: timelineDraft.dateText, timeText: timelineDraft.timeText,
durationMinutes: Math.max(0, Number(timelineDraft.durationMinutes) || 0),
description: timelineDraft.description, expectedRevision: null,
} })
setTimelineDraft((current) => ({ ...current, title: '', description: '' })); await refresh(); onMessage('时间线事件已写入故事世界。')
})
const linkEntity = (scene: Scene) => {
if (!entities.length) { onMessage('请先在“设定”里建立人物、地点或物品。'); return }
const value = window.prompt(`输入对象序号:\n${entities.map((entity, index) => `${index + 1}. ${entity.name}`).join('\n')}`, '1')
const entity = entities[(Number(value) || 0) - 1]
if (!entity) return
const role = window.prompt('该对象在场景中的作用', '出场') || '出场'
void run(async () => {
await invoke('link_web_novel_scene_entity', { input: { workId, sceneId: scene.sceneId, entityId: entity.entityId, role, note: '' } })
await refresh(); onMessage(`已把${entity.name}关联到${scene.title}`)
})
}
const restoreVersion = (version: ChapterVersion) => {
if (!window.confirm(`把“${version.chapterTitle}”恢复到修订 ${version.revision}?当前内容会先留在版本链中。`)) return
void run(async () => {
await invoke('restore_web_novel_chapter_version', { input: { versionId: version.versionId } })
await onWorkRefresh(); await refresh(); onMessage('历史版本已恢复,并生成了新的恢复版本。')
})
}
const exportDelivery = (format: string) => void run(async () => {
const receipt = await invoke<DeliveryReceipt | null>('export_web_novel_author_delivery', { input: { workId, format } })
if (receipt) onMessage(`${format} 已真实导出 ${receipt.chapterCount} 章、${receipt.bytes.toLocaleString()} 字节;校验 ${receipt.packageSha256.slice(0, 12)}`)
})
const chapterName = useMemo(() => new Map(chapters.map((chapter) => [chapter.chapterId, chapter.title])), [chapters])
const mounted = (moduleId: string) => marketplace?.modules.find((module) => module.moduleId === moduleId)?.installState === 'MOUNTED'
return <div className="wn-module-center">
<header className="wn-module-header"><div><span>OFFICIAL HOT-PLUG MODULES</span><h2></h2><p></p></div><button type="button" disabled={busy} onClick={() => void refresh()}></button></header>
<section className="wn-marketplace-cards">
{marketplace?.modules.map((module) => <article className={activeModule === module.moduleId ? 'active' : ''} key={module.moduleId} onClick={() => setActiveModule(module.moduleId)}>
<div><small>{module.moduleId}</small><b>{module.name}</b><p>{module.description}</p><span>{module.installState === 'MOUNTED' ? '已挂载' : module.installState === 'INSTALLED' ? '已安装' : '可安装'} · {module.selfTestState}</span></div>
<footer>
{module.installState === 'AVAILABLE' && <button type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('activate', module.moduleId, '官方签名、编号、权限与自检均通过,模块已激活。') }}></button>}
{module.installState === 'INSTALLED' && <button className="primary" type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('activate', module.moduleId, '模块已由统一运行时重新激活。') }}></button>}
{module.installState === 'MOUNTED' && <button type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('unmount', module.moduleId, '模块已停止投影,作品和模块数据完整保留。') }}></button>}
</footer>
</article>)}
</section>
{activeModule === MODULES.outline && <section className="wn-module-workbench">
<header><div><small>{MODULES.outline}</small><h3></h3></div><span>{mounted(MODULES.outline) ? '引擎已挂载' : '先安装并挂载'}</span></header>
<form className="wn-module-form" onSubmit={(event) => { event.preventDefault(); createScene() }}><select value={sceneDraft.chapterId} onChange={(event) => setSceneDraft({ ...sceneDraft, chapterId: event.target.value })}><option value=""></option>{chapters.map((chapter) => <option key={chapter.chapterId} value={chapter.chapterId}>{chapter.title}</option>)}</select><input placeholder="场景名称" value={sceneDraft.title} onChange={(event) => setSceneDraft({ ...sceneDraft, title: event.target.value })}/><input placeholder="场景概要" value={sceneDraft.synopsis} onChange={(event) => setSceneDraft({ ...sceneDraft, synopsis: event.target.value })}/><input placeholder="目标" value={sceneDraft.goal} onChange={(event) => setSceneDraft({ ...sceneDraft, goal: event.target.value })}/><input placeholder="冲突" value={sceneDraft.conflict} onChange={(event) => setSceneDraft({ ...sceneDraft, conflict: event.target.value })}/><input placeholder="结果" value={sceneDraft.outcome} onChange={(event) => setSceneDraft({ ...sceneDraft, outcome: event.target.value })}/><input placeholder="章末钩子" value={sceneDraft.hook} onChange={(event) => setSceneDraft({ ...sceneDraft, hook: event.target.value })}/><input placeholder="情绪点" value={sceneDraft.emotionalPoint} onChange={(event) => setSceneDraft({ ...sceneDraft, emotionalPoint: event.target.value })}/><button className="primary" disabled={!mounted(MODULES.outline) || busy} type="submit"></button></form>
<div className="wn-scene-list">{data?.scenes.map((scene) => <article key={scene.sceneId}><div><small>{chapterName.get(scene.chapterId)} · {scene.status}</small><b>{scene.title}</b><p>{scene.goal || '待填目标'} {scene.conflict || '待填冲突'} {scene.outcome || '待填结果'}</p><span>{scene.hook || '未设置'} · {scene.revision}</span></div><footer><button type="button" onClick={() => addBeat(scene)}>+</button><button type="button" onClick={() => linkEntity(scene)}></button></footer><ol>{data.beats.filter((beat) => beat.sceneId === scene.sceneId).map((beat) => <li key={beat.beatId}><b>{beat.title}</b><span>{beat.note}</span></li>)}</ol></article>)}</div>
</section>}
{activeModule === MODULES.grid && <section className="wn-module-workbench">
<header><div><small>{MODULES.grid}</small><h3></h3></div><span>{mounted(MODULES.grid) ? '可编辑' : '先安装并挂载'}</span></header>
<form className="wn-field-form" onSubmit={(event) => { event.preventDefault(); addField() }}><input placeholder="新维度,如视角人物" value={fieldDraft.label} onChange={(event) => setFieldDraft({ ...fieldDraft, label: event.target.value })}/><select value={fieldDraft.fieldType} onChange={(event) => setFieldDraft({ ...fieldDraft, fieldType: event.target.value })}><option value="TEXT"></option><option value="NUMBER"></option><option value="SELECT"></option><option value="BOOLEAN">/</option></select><input placeholder="选项,逗号分隔" value={fieldDraft.options} onChange={(event) => setFieldDraft({ ...fieldDraft, options: event.target.value })}/><button disabled={!mounted(MODULES.grid)} type="submit"></button></form>
<div className="wn-story-grid"><table><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th>{data?.fieldDefinitions.map((field) => <th key={field.fieldId}>{field.label}</th>)}</tr></thead><tbody>{data?.scenes.map((scene) => <tr key={scene.sceneId}><td>{chapterName.get(scene.chapterId)}</td><td>{scene.title}</td><td><select value={scene.status} disabled={!mounted(MODULES.outline)} onChange={(event) => editScene(scene, { status: event.target.value })}><option value="PLANNED"></option><option value="DRAFTING"></option><option value="DONE"></option><option value="REVISE"></option></select></td><td>{scene.goal}</td><td>{scene.conflict}</td><td>{scene.outcome}</td><td>{scene.hook}</td>{data.fieldDefinitions.map((field) => { const value = data.fieldValues.find((item) => item.fieldId === field.fieldId && item.sceneId === scene.sceneId)?.value; return <td key={field.fieldId}><button type="button" disabled={!mounted(MODULES.grid)} onClick={() => editFieldValue(field, scene)}>{value || '点击填写'}</button></td> })}</tr>)}</tbody></table></div>
</section>}
{activeModule === MODULES.storyworld && <section className="wn-module-workbench">
<header><div><small>{MODULES.storyworld}</small><h3>线</h3></div><span>{mounted(MODULES.storyworld) ? '引擎已挂载' : '先安装并挂载'}</span></header>
<form className="wn-module-form" onSubmit={(event) => { event.preventDefault(); createTimeline() }}><input placeholder="事件名称" value={timelineDraft.title} onChange={(event) => setTimelineDraft({ ...timelineDraft, title: event.target.value })}/><select value={timelineDraft.calendarKind} onChange={(event) => setTimelineDraft({ ...timelineDraft, calendarKind: event.target.value })}><option value="STORY_DAY"></option><option value="DATE"></option><option value="CUSTOM"></option></select><input inputMode="numeric" placeholder="故事天" value={timelineDraft.storyDay} onChange={(event) => setTimelineDraft({ ...timelineDraft, storyDay: event.target.value })}/><input placeholder="日期文本" value={timelineDraft.dateText} onChange={(event) => setTimelineDraft({ ...timelineDraft, dateText: event.target.value })}/><input placeholder="时间" value={timelineDraft.timeText} onChange={(event) => setTimelineDraft({ ...timelineDraft, timeText: event.target.value })}/><select value={timelineDraft.sceneId} onChange={(event) => setTimelineDraft({ ...timelineDraft, sceneId: event.target.value })}><option value=""></option>{data?.scenes.map((scene) => <option key={scene.sceneId} value={scene.sceneId}>{scene.title}</option>)}</select><select value={timelineDraft.entityId} onChange={(event) => setTimelineDraft({ ...timelineDraft, entityId: event.target.value })}><option value=""></option>{entities.map((entity) => <option key={entity.entityId} value={entity.entityId}>{entity.name}</option>)}</select><textarea placeholder="事件描述" value={timelineDraft.description} onChange={(event) => setTimelineDraft({ ...timelineDraft, description: event.target.value })}/><button className="primary" disabled={!mounted(MODULES.storyworld)} type="submit">线</button></form>
<div className="wn-timeline">{data?.timelineEvents.map((event) => <article key={event.eventId}><time>{event.storyDay != null ? `${event.storyDay}` : event.dateText || '未定日期'} {event.timeText}</time><div><b>{event.title}</b><p>{event.description}</p><small>{event.sceneId ? data.scenes.find((scene) => scene.sceneId === event.sceneId)?.title : '全局事件'}{event.entityId ? ` · ${entities.find((entity) => entity.entityId === event.entityId)?.name || '对象'}` : ''}</small></div></article>)}</div>
</section>}
{activeModule === MODULES.delivery && <section className="wn-module-workbench">
<header><div><small>{MODULES.delivery}</small><h3></h3></div><span>{mounted(MODULES.delivery) ? '引擎已挂载' : '先安装并挂载'}</span></header>
<div className="wn-delivery-actions">{['TXT', 'DOCX', 'EPUB', 'JSON'].map((format) => <button className={format === 'DOCX' ? 'primary' : ''} type="button" key={format} disabled={!mounted(MODULES.delivery) || busy} onClick={() => exportDelivery(format)}> {format}</button>)}</div>
<div className="wn-version-list">{data?.chapterVersions.map((version) => <article key={version.versionId}><div><b>{version.chapterTitle}</b><small> {version.revision} · {version.wordCount.toLocaleString()} · {version.saveReason}</small></div><button type="button" disabled={!mounted(MODULES.delivery)} onClick={() => restoreVersion(version)}></button></article>)}</div>
</section>}
<details className="wn-module-receipts"><summary></summary>{marketplace?.recentReceipts.map((receipt) => <article key={receipt.receiptId}><b>{receipt.action} · {receipt.state}</b><span>{receipt.moduleId}</span><p>{receipt.detail}</p><small>{receipt.receiptHash.slice(0, 16)} · {new Date(receipt.createdAtUnixMs).toLocaleString('zh-CN', { hour12: false })}</small></article>)}</details>
</div>
}

View file

@ -0,0 +1,80 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { numberedInvoke as invoke } from '../numbered-ipc'
type Kind = 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA'
interface ChapterRef { chapterId: string; title: string; wordCount: number; content: string; revision: number }
interface EntityRef { entityId: string; entityType: string; name: string; description: string }
interface Inspiration { inspirationId: string; content: string; tags: string[]; status: 'INBOX' | 'USED' | 'ARCHIVED' }
interface Shot { shotId: string; chapterId: string; position: number; shotNumber: string; shotSize: string; cameraMovement: string; location: string; timeOfDay: string; action: string; dialogue: string; durationSeconds: number; visualPrompt: string; imagePrompt: string; videoPrompt: string }
interface AuthorSnapshot { totalWords: number; todayWords: number; todayActiveMs: number; inspirations: Inspiration[]; shots: Shot[] }
interface SearchHit { chapterId: string; chapterTitle: string; field: string; snippet: string; occurrenceCount: number }
interface SearchResult { chapterCount: number; occurrenceCount: number; hits: SearchHit[] }
const labels: Record<Kind, string> = { LONG_NOVEL: '长篇小说', SHORT_NOVEL: '短篇小说', SHORT_DRAMA: '短剧剧本' }
const emptyShot = { shotNumber: '', shotSize: '中景', cameraMovement: '固定', location: '', timeOfDay: '日', action: '', dialogue: '', durationSeconds: '3', visualPrompt: '', imagePrompt: '', videoPrompt: '' }
function localDate() { const now = new Date(); return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10) }
export function AuthorWritingSidecar({ workId, workKind, activeChapter, chapters, entities, onOpenChapter, onFormat, onFormatWork, onMessage }: {
workId: string; workKind: Kind; activeChapter: ChapterRef | null
chapters: Array<Pick<ChapterRef, 'chapterId' | 'title' | 'wordCount'>>; entities: EntityRef[]
onOpenChapter: (chapterId: string) => void; onFormat: (preset: string) => Promise<void>; onFormatWork: (preset: string) => Promise<void>; onMessage: (message: string) => void
}) {
const [tab, setTab] = useState<'overview' | 'characters' | 'inspiration' | 'search' | 'shots'>('overview')
const [snapshot, setSnapshot] = useState<AuthorSnapshot | null>(null)
const [inspiration, setInspiration] = useState('')
const [query, setQuery] = useState('')
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
const [shot, setShot] = useState(emptyShot)
const [busy, setBusy] = useState(false)
const [formatPreset, setFormatPreset] = useState('AUTO')
const refresh = useCallback(async () => setSnapshot(await invoke<AuthorSnapshot>('get_web_novel_author_snapshot', { input: { workId, localDate: localDate() } })), [workId])
useEffect(() => { void refresh() }, [refresh])
const activeShots = useMemo(() => (snapshot?.shots || []).filter((item) => item.chapterId === activeChapter?.chapterId), [snapshot?.shots, activeChapter?.chapterId])
const captureInspiration = async () => {
if (!inspiration.trim()) return
setBusy(true)
try {
await invoke('create_web_novel_inspiration', { input: { workId, chapterId: activeChapter?.chapterId || null, content: inspiration, tags: activeChapter ? ['随写', activeChapter.title] : ['随写'] } })
setInspiration(''); await refresh(); onMessage('灵感已写入当前作品。')
} finally { setBusy(false) }
}
const search = async () => {
if (!query.trim()) return
setBusy(true)
try { const result = await invoke<SearchResult>('search_web_novel_full_text', { input: { workId, query } }); setSearchResult(result); onMessage(`全文追踪:${result.chapterCount} 章、${result.occurrenceCount} 处命中。`) } finally { setBusy(false) }
}
const saveShot = async () => {
if (!activeChapter) return
setBusy(true)
try {
await invoke('upsert_web_novel_shot', { input: { workId, chapterId: activeChapter.chapterId, shotId: null, ...shot, durationSeconds: Number(shot.durationSeconds) || 0, expectedRevision: null } })
setShot(emptyShot); await refresh(); onMessage('分镜和提示词已写入当前分集。')
} finally { setBusy(false) }
}
const tabs: [typeof tab, string][] = [['overview', '进度'], ['characters', '人物'], ['inspiration', '灵感'], ['search', '全文']]
if (workKind === 'SHORT_DRAMA') tabs.push(['shots', '分镜'])
return <aside className="wn-writing-sidecar" aria-label="作者随写工具">
<header><span>{labels[workKind]}</span><b></b></header>
<nav>{tabs.map(([key, label]) => <button type="button" className={tab === key ? 'active' : ''} key={key} onClick={() => setTab(key)}>{label}</button>)}</nav>
<div className="wn-sidecar-body">
{tab === 'overview' && <><div className="wn-live-stats">
<article><span></span><b>{(snapshot?.totalWords || 0).toLocaleString()}</b><small></small></article>
<article><span></span><b>{(snapshot?.todayWords || 0).toLocaleString()}</b><small></small></article>
<article><span></span><b>{Math.floor((snapshot?.todayActiveMs || 0) / 60_000)}</b><small></small></article>
<article><span></span><b>{activeChapter?.wordCount.toLocaleString() || '0'}</b><small></small></article>
</div><div className="wn-format-control"><label><span></span><select value={formatPreset} onChange={(event) => setFormatPreset(event.target.value)}><option value="AUTO"></option><option value="NOVEL"></option><option value="OUTLINE"></option><option value="SCREENPLAY"></option><option value="CLEAN"></option></select></label><button className="wn-sidecar-primary" type="button" disabled={!activeChapter || busy} onClick={() => void onFormat(formatPreset)}> · </button><button type="button" disabled={!chapters.length || busy} onClick={() => void onFormatWork(formatPreset)}></button><small></small></div>
<section className="wn-tracking-list"><h4></h4>{chapters.slice(-12).map((chapter) => <button type="button" key={chapter.chapterId} onClick={() => onOpenChapter(chapter.chapterId)}><span>{chapter.title}</span><small>{chapter.wordCount.toLocaleString()} </small></button>)}</section></>}
{tab === 'characters' && <section className="wn-reference-list"><h4></h4>{entities.filter((item) => item.entityType === 'CHARACTER').map((item) => <article key={item.entityId}><b>{item.name}</b><p>{item.description || '尚未补写人物说明'}</p></article>)}{!entities.some((item) => item.entityType === 'CHARACTER') && <p></p>}</section>}
{tab === 'inspiration' && <><form className="wn-quick-form" onSubmit={(event) => { event.preventDefault(); void captureInspiration() }}><textarea placeholder="记下一闪而过的情节、对白或设定…" value={inspiration} onChange={(event) => setInspiration(event.target.value)}/><button type="submit" disabled={busy}></button></form><section className="wn-reference-list">{(snapshot?.inspirations || []).map((item) => <article key={item.inspirationId}><p>{item.content}</p><small>{item.tags.join(' · ') || '未标记'}</small>{item.status === 'INBOX' && <button type="button" onClick={() => void invoke('set_web_novel_inspiration_status', { input: { inspirationId: item.inspirationId, status: 'USED' } }).then(refresh)}></button>}</article>)}</section></>}
{tab === 'search' && <><form className="wn-inline-search" onSubmit={(event) => { event.preventDefault(); void search() }}><input placeholder="追踪名字、伏笔、地点或一句话" value={query} onChange={(event) => setQuery(event.target.value)}/><button type="submit" disabled={busy}></button></form>{searchResult && <section className="wn-reference-list"><h4>{searchResult.chapterCount} · {searchResult.occurrenceCount} </h4>{searchResult.hits.map((hit) => <button className="wn-search-hit" type="button" key={hit.chapterId} onClick={() => onOpenChapter(hit.chapterId)}><b>{hit.chapterTitle}</b><small>{hit.field} · {hit.occurrenceCount} </small><p>{hit.snippet}</p></button>)}</section>}</>}
{tab === 'shots' && <><section className="wn-script-format"><b></b><code>1-1 / / <br/><br/> <br/><br/>/</code></section>{activeChapter ? <form className="wn-shot-form" onSubmit={(event) => { event.preventDefault(); void saveShot() }}>
<div><input placeholder="镜号" value={shot.shotNumber} onChange={(event) => setShot({ ...shot, shotNumber: event.target.value })}/><select value={shot.shotSize} onChange={(event) => setShot({ ...shot, shotSize: event.target.value })}><option></option><option></option><option></option><option></option><option></option></select></div>
<div><input placeholder="运镜" value={shot.cameraMovement} onChange={(event) => setShot({ ...shot, cameraMovement: event.target.value })}/><input placeholder="时长/秒" value={shot.durationSeconds} onChange={(event) => setShot({ ...shot, durationSeconds: event.target.value })}/></div>
<div><input placeholder="地点" value={shot.location} onChange={(event) => setShot({ ...shot, location: event.target.value })}/><input placeholder="日/夜" value={shot.timeOfDay} onChange={(event) => setShot({ ...shot, timeOfDay: event.target.value })}/></div>
{(['action', 'dialogue', 'visualPrompt', 'imagePrompt', 'videoPrompt'] as const).map((field) => <textarea key={field} placeholder={{ action: '画面动作', dialogue: '对白/声音', visualPrompt: '视觉提示词', imagePrompt: '图片提示词', videoPrompt: '视频提示词' }[field]} value={shot[field]} onChange={(event) => setShot({ ...shot, [field]: event.target.value })}/>)}<button type="submit" disabled={busy}></button>
</form> : <p></p>}<section className="wn-reference-list">{activeShots.map((item) => <article key={item.shotId}><b>{item.shotNumber || `镜头 ${item.position + 1}`} · {item.shotSize} · {item.cameraMovement}</b><small>{item.location} {item.timeOfDay} · {item.durationSeconds} </small><p>{item.action || item.dialogue}</p><p className="wn-prompt-preview">{item.videoPrompt || item.imagePrompt || item.visualPrompt}</p></article>)}</section></>}
</div>
</aside>
}

View file

@ -0,0 +1,929 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { numberedInvoke as invoke } from '../numbered-ipc'
import './styles.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 [createOpen, setCreateOpen] = useState(false)
const [chapterCreate, setChapterCreate] = useState<{ volumeId: string; title: string } | null>(null)
const [volumeCreate, setVolumeCreate] = useState<string | null>(null)
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 ? '工作空间已加载' : '当前账号还没有作品,请先创建一本。')
if (!next.works.length) setCreateOpen(true)
}
})
.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)
setCreateOpen(false)
} 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 = volumeCreate?.trim()
if (!title) {
setMessage('请填写分卷名称。')
return
}
const next = await invoke<WorkDetail>('create_web_novel_volume', { input: { workId: detail.work.workId, title } })
setDetail(next)
setVolumeCreate(null)
setMessage(`已创建「${title}`)
})
const openChapterCreate = (volumeId: string) => {
if (!detail) return
const count = detail.chapters.filter((item) => item.volumeId === volumeId).length
const unit = detail.work.workKind === 'SHORT_DRAMA' ? '集' : '章'
setChapterCreate({ volumeId, title: `${count + 1}${unit}` })
}
const createChapter = () => void runAction(async () => {
if (!detail) return
const draft = chapterCreate
if (!draft?.title.trim()) {
setMessage('请填写章节名称。')
return
}
await flushWritingActivity()
await flushChapterSave()
const chapter = await invoke<Chapter>('create_web_novel_chapter', { input: { workId: detail.work.workId, volumeId: draft.volumeId, title: draft.title.trim() } })
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')
setChapterCreate(null)
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>
<div className="wn-topbar-actions"><small>{busy ? '引擎正在处理…' : message}</small><button type="button" disabled={busy} onClick={() => { setCreateFeedback({ state: 'idle', text: '' }); setCreateOpen(true) }}> </button></div>
</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>
<div className="wn-create-work">
<button className="wn-import-button" type="button" disabled={busy} onClick={inspectDocument}> / / </button>
<button type="button" disabled={busy} onClick={() => { setCreateFeedback({ state: 'idle', text: '' }); setCreateOpen(true) }}> </button>
</div>
</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={() => setVolumeCreate(`${detail.volumes.length + 1}`)}> </button></div><button className="wn-new-chapter" type="button" disabled={!detail.volumes.length} onClick={() => openChapterCreate(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={() => openChapterCreate(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><button type="button" onClick={() => setCreateOpen(true)}> </button></div>}
</div>
{createOpen && <div className="wn-create-layer" role="dialog" aria-modal="true" aria-label="新建作品">
<button className="wn-create-scrim" type="button" aria-label="关闭新建作品" onClick={() => !busy && setCreateOpen(false)}/>
<form className="wn-create-card" aria-busy={createFeedback.state === 'pending'} onSubmit={(event) => { event.preventDefault(); void createWork() }}>
<header><div><span>CREATE A REAL WORK</span><h2></h2><p></p></div><button type="button" aria-label="关闭" disabled={busy} onClick={() => setCreateOpen(false)}>×</button></header>
<label><span></span><input autoFocus aria-label="作品名" placeholder="例如:长安落雪" value={newWork.title} onChange={(event) => setNewWork({ ...newWork, title: event.target.value })}/></label>
<label><span></span><input aria-label="笔名" placeholder="作者署名" value={newWork.penName} onChange={(event) => setNewWork({ ...newWork, penName: event.target.value })}/></label>
<label><span></span><input aria-label="作品类型" placeholder="例如:玄幻、悬疑、现实" value={newWork.genre} onChange={(event) => setNewWork({ ...newWork, genre: event.target.value })}/></label>
<label><span></span><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></label>
{createFeedback.text && <p className={`wn-create-feedback ${createFeedback.state}`} role={createFeedback.state === 'error' ? 'alert' : 'status'}>{createFeedback.text}</p>}
<footer><button type="button" disabled={busy} onClick={() => setCreateOpen(false)}></button><button className="primary" type="submit" disabled={busy}>{createFeedback.state === 'pending' ? '正在建立作品…' : '建立作品并开始码字'}</button></footer>
</form>
</div>}
{chapterCreate && <div className="wn-create-layer" role="dialog" aria-modal="true" aria-label="新建章节"><button className="wn-create-scrim" type="button" aria-label="关闭新建章节" onClick={() => !busy && setChapterCreate(null)}/><form className="wn-create-card wn-compact-create" onSubmit={(event) => { event.preventDefault(); createChapter() }}><header><div><span>NEW CHAPTER</span><h2>{detail?.work.workKind === 'SHORT_DRAMA' ? '新建一集' : '新建章节'}</h2><p></p></div><button type="button" aria-label="关闭" disabled={busy} onClick={() => setChapterCreate(null)}>×</button></header><label className="wide"><span></span><input autoFocus aria-label="章节名称" value={chapterCreate.title} onChange={(event) => setChapterCreate({ ...chapterCreate, title: event.target.value })}/></label><footer><button type="button" disabled={busy} onClick={() => setChapterCreate(null)}></button><button className="primary" type="submit" disabled={busy || !chapterCreate.title.trim()}>{busy ? '正在创建…' : '创建并开始码字'}</button></footer></form></div>}
{volumeCreate !== null && <div className="wn-create-layer" role="dialog" aria-modal="true" aria-label="新建分卷"><button className="wn-create-scrim" type="button" aria-label="关闭新建分卷" onClick={() => !busy && setVolumeCreate(null)}/><form className="wn-create-card wn-compact-create" onSubmit={(event) => { event.preventDefault(); createVolume() }}><header><div><span>NEW VOLUME</span><h2></h2><p></p></div><button type="button" aria-label="关闭" disabled={busy} onClick={() => setVolumeCreate(null)}>×</button></header><label className="wide"><span></span><input autoFocus aria-label="分卷名称" value={volumeCreate} onChange={(event) => setVolumeCreate(event.target.value)}/></label><footer><button type="button" disabled={busy} onClick={() => setVolumeCreate(null)}></button><button className="primary" type="submit" disabled={busy || !volumeCreate.trim()}>{busy ? '正在创建…' : '建立分卷'}</button></footer></form></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>
}

View file

@ -0,0 +1,286 @@
.wn-workspace { position: absolute; z-index: 60; inset: 0; overflow: hidden; color: var(--content-primary); background: radial-gradient(circle at 76% 0, color-mix(in srgb, var(--primitive-warm-glow) 9%, transparent), transparent 30%), radial-gradient(circle at 18% 100%, color-mix(in srgb, var(--primitive-cool-glow) 12%, transparent), transparent 34%), color-mix(in srgb, var(--surface-depth) 98%, transparent); backdrop-filter: blur(24px); }
.wn-workspace button, .wn-workspace input, .wn-workspace textarea, .wn-workspace select { font: inherit; }
.wn-workspace button { cursor: pointer; }
.wn-workspace button:disabled { cursor: default; opacity: .48; }
.wn-topbar { height: 62px; display: grid; grid-template-columns: minmax(180px, 1fr) auto minmax(240px, 1fr); align-items: center; gap: 20px; padding: 0 22px; border-bottom: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 88%, transparent); }
.wn-topbar > button { justify-self: start; border: 0; color: var(--content-muted); background: transparent; font-size: 12px; font-weight: 680; }
.wn-topbar > div { display: grid; justify-items: center; gap: 2px; }
.wn-topbar span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .2em; }
.wn-topbar b { max-width: 440px; overflow: hidden; color: var(--content-primary); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.wn-topbar-actions { justify-self:end; display:flex; align-items:center; gap:10px; min-width:0; }
.wn-topbar-actions small { max-width:330px; overflow:hidden; color:var(--content-muted); font-size:10.5px; text-overflow:ellipsis; white-space:nowrap; }
.wn-topbar-actions button { min-height:34px; padding:7px 12px; border:1px solid color-mix(in srgb, var(--accent-light) 42%, var(--panel-edge)); border-radius:999px; color:var(--content-primary); background:color-mix(in srgb, var(--primitive-cool-glow) 12%, var(--primitive-glass)); font-size:11px; font-weight:720; }
.wn-shell { height: calc(100% - 62px); display: grid; grid-template-columns: 220px 58px minmax(0, 1fr); }
.wn-library { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; padding: 18px 13px 14px; border-right: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 54%, transparent); }
.wn-library > header { display: flex; align-items: end; justify-content: space-between; padding: 0 5px 14px; }
.wn-library > header span { color: var(--content-faint); font-size: 9px; font-weight: 750; letter-spacing: .14em; }
.wn-library h2 { margin: 3px 0 0; font-size: 17px; }
.wn-library > header strong { color: var(--accent-light); font-size: 24px; }
.wn-work-list { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 6px; }
.wn-work-list button { display: grid; gap: 4px; padding: 11px; border: 1px solid transparent; border-radius: 11px; color: var(--content-secondary); background: transparent; text-align: left; }
.wn-work-list button:hover, .wn-work-list button.active { border-color: var(--panel-edge); background: color-mix(in srgb, var(--primitive-glass) 78%, transparent); }
.wn-work-list b { overflow: hidden; color: var(--content-primary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.wn-work-list span, .wn-work-list small { color: var(--content-muted); font-size: 10px; }
.wn-create-work { display: grid; gap: 7px; padding: 13px 4px 0; border-top: 1px solid var(--panel-edge); }
.wn-create-work h3 { margin: 0 0 2px; color: var(--content-secondary); font-size: 11px; }
.wn-workspace input, .wn-workspace textarea, .wn-workspace select { min-width: 0; box-sizing: border-box; border: 1px solid var(--panel-edge); border-radius: 9px; outline: 0; color: var(--content-primary); background: color-mix(in srgb, var(--primitive-glass) 76%, transparent); }
.wn-workspace input:focus, .wn-workspace textarea:focus, .wn-workspace select:focus { border-color: color-mix(in srgb, var(--accent-light) 58%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-light) 8%, transparent); }
.wn-create-work input { padding: 8px 9px; font-size: 11px; }
.wn-create-work button, .wn-board button, .wn-editor footer button { min-height: 34px; padding: 7px 11px; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-secondary); background: color-mix(in srgb, var(--primitive-glass) 76%, transparent); font-size: 11px; font-weight: 680; }
.wn-create-work button { color: var(--button-primary-text); background: var(--button-primary-bg); }
.wn-create-work .wn-import-button { margin-bottom: 5px; border-color: color-mix(in srgb, var(--accent-light) 40%, var(--panel-edge)); color: var(--content-primary); background: color-mix(in srgb, var(--primitive-cool-glow) 12%, var(--primitive-glass)); }
.wn-create-feedback { margin: 2px 0 0; padding: 8px 9px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: color-mix(in srgb, var(--primitive-glass) 72%, transparent); font-size: 10.5px; font-weight: 650; line-height: 1.45; }
.wn-create-feedback.success { border-color: color-mix(in srgb, #61c991 44%, var(--panel-edge)); color: color-mix(in srgb, #8fe7b3 78%, var(--content-primary)); }
.wn-create-feedback.error { border-color: color-mix(in srgb, #ef7890 48%, var(--panel-edge)); color: color-mix(in srgb, #ff9aac 82%, var(--content-primary)); }
.wn-nav { display: grid; align-content: start; gap: 5px; padding: 14px 7px; border-right: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--surface-depth) 58%, transparent); }
.wn-nav button { min-height: 50px; padding: 0; border: 1px solid transparent; border-radius: 11px; color: var(--content-muted); background: transparent; font-size: 11px; font-weight: 720; letter-spacing: .08em; }
.wn-nav button.active { border-color: color-mix(in srgb, var(--accent-light) 24%, var(--panel-edge)); color: var(--content-primary); background: color-mix(in srgb, var(--primitive-cool-glow) 9%, var(--primitive-glass)); box-shadow: inset 3px 0 var(--accent-light); }
.wn-main { min-width: 0; min-height: 0; overflow: hidden; }
.wn-writing-layout { height: 100%; display: grid; grid-template-columns: 220px minmax(460px, 1fr) 300px; }
.wn-outline { min-height: 0; overflow: auto; padding: 16px 10px; border-right: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 32%, transparent); }
.wn-outline > header, .wn-outline section > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.wn-outline > header { padding: 0 6px 14px; }
.wn-outline > header.wn-outline-actions { display: grid; align-items: stretch; gap: 8px; }
.wn-outline-actions > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.wn-outline-actions .wn-outline-secondary { padding: 4px 0; color: var(--content-muted); font-size: 9px; }
.wn-outline-actions .wn-new-chapter { min-height: 36px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent-light) 40%, var(--panel-edge)); border-radius: 9px; color: var(--button-primary-text); background: var(--button-primary-bg); font-size: 11px; font-weight: 760; text-align: center; }
.wn-outline h3 { margin: 0; font-size: 14px; }
.wn-outline button { border: 0; color: var(--content-muted); background: transparent; }
.wn-outline section { margin-bottom: 13px; }
.wn-outline section > div { padding: 7px 6px; color: var(--content-secondary); font-size: 11px; }
.wn-outline section > button { width: 100%; display: grid; gap: 3px; padding: 8px 9px 8px 14px; border-radius: 8px; text-align: left; }
.wn-outline section > button:hover, .wn-outline section > button.active { color: var(--content-primary); background: color-mix(in srgb, var(--primitive-glass) 80%, transparent); }
.wn-outline section > button small { color: var(--content-faint); font-size: 9px; }
.wn-editor { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; padding: 22px clamp(20px, 4vw, 72px) 18px; }
.wn-editor > header { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding-bottom: 12px; }
.wn-editor > header input { flex: 1; padding: 7px 0; border: 0; border-radius: 0; background: transparent; font-size: clamp(22px, 3vw, 32px); font-weight: 740; }
.wn-editor > header input:focus { box-shadow: none; }
.wn-editor > header > div { display: grid; justify-items: end; gap: 5px; }
.wn-editor > header small { color: var(--content-faint); font-size: 9px; }
.wn-status { padding: 4px 8px; border: 1px solid var(--panel-edge); border-radius: 999px; color: var(--content-secondary); font-size: 9px; font-weight: 750; letter-spacing: .05em; }
.wn-status.status-published, .wn-status.status-approved { color: var(--accent-light); }
.wn-chapter-synopsis { width: 100%; padding: 9px 11px; font-size: 11px; }
.wn-editor > textarea, .wn-outline-editor-body > textarea { width: 100%; height: 100%; margin-top: 12px; padding: 24px clamp(18px, 3vw, 42px); resize: none; border-radius: 14px; color: var(--content-primary); background: color-mix(in srgb, var(--panel-bg) 54%, transparent); font-family: ui-serif, Georgia, 'Songti SC', serif; font-size: 16px; line-height: 2; letter-spacing: .04em; }
.wn-outline-editor-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(260px, .95fr); gap: 12px; }
.wn-outline-editor-body > textarea { min-height: 0; }
.wn-outline-render { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 8px; margin-top: 12px; padding: 16px; border: 1px solid var(--panel-edge); border-radius: 14px; background: color-mix(in srgb, var(--panel-bg) 68%, transparent); }
.wn-outline-render > header { display: grid; gap: 3px; padding: 2px 2px 8px; border-bottom: 1px solid var(--panel-edge); }
.wn-outline-render > header span { color: var(--accent-light); font-size: 8px; font-weight: 760; letter-spacing: .12em; }
.wn-outline-render > header b { font-size: 14px; }
.wn-outline-render > header small { color: var(--content-faint); font-size: 8px; }
.wn-outline-render article { display: grid; grid-template-columns: 66px minmax(0, 1fr); gap: 9px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 78%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--primitive-glass) 46%, transparent); }
.wn-outline-render article span { color: var(--accent-light); font-size: 8.5px; font-weight: 720; }
.wn-outline-render article p { margin: 0; color: var(--content-secondary); font-size: 10px; line-height: 1.7; white-space: pre-wrap; }
.wn-outline-render article.kind-beat { border-left: 2px solid var(--accent-light); }
.wn-editor footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-top: 12px; }
.wn-editor footer > div { display: flex; gap: 8px; }
.wn-editor footer button.primary, .wn-board > header button:last-child, .wn-board form button { border-color: color-mix(in srgb, var(--accent-light) 36%, transparent); color: var(--button-primary-text); background: var(--button-primary-bg); }
.wn-writing-sidecar { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); border-left: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 36%, transparent); }
.wn-writing-sidecar > header { display: grid; gap: 3px; padding: 16px 14px 11px; }
.wn-writing-sidecar > header span { color: var(--accent-light); font-size: 9px; font-weight: 760; letter-spacing: .12em; }
.wn-writing-sidecar > header b { font-size: 15px; }
.wn-writing-sidecar > nav { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 0 10px 10px; }
.wn-writing-sidecar > nav button { min-width: 0; padding: 7px 3px; border: 0; border-radius: 7px; color: var(--content-muted); background: transparent; font-size: 9px; font-weight: 680; }
.wn-writing-sidecar > nav button.active { color: var(--content-primary); background: color-mix(in srgb, var(--primitive-glass) 82%, transparent); }
.wn-sidecar-body { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 10px; padding: 2px 11px 16px; }
.wn-live-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.wn-live-stats article { display: grid; grid-template-columns: 1fr auto; gap: 3px 5px; padding: 10px; border: 1px solid var(--panel-edge); border-radius: 10px; background: color-mix(in srgb, var(--primitive-glass) 50%, transparent); }
.wn-live-stats span { grid-column: 1 / 3; color: var(--content-muted); font-size: 8.5px; }
.wn-live-stats b { font-size: 17px; }
.wn-live-stats small { align-self: end; padding-bottom: 2px; color: var(--content-faint); font-size: 8px; }
.wn-sidecar-primary, .wn-quick-form button, .wn-inline-search button, .wn-shot-form button { min-height: 34px; border: 1px solid color-mix(in srgb, var(--accent-light) 34%, var(--panel-edge)); border-radius: 8px; color: var(--button-primary-text); background: var(--button-primary-bg); font-size: 9.5px; font-weight: 720; }
.wn-format-control { display: grid; gap: 6px; padding: 9px; border: 1px solid var(--panel-edge); border-radius: 10px; background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-format-control label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; }
.wn-format-control label span { color: var(--content-muted); font-size: 8.5px; }
.wn-format-control select { width: 100%; min-width: 0; padding: 6px 7px; font-size: 9px; }
.wn-format-control > button:not(.wn-sidecar-primary) { min-height: 31px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: transparent; font-size: 9px; font-weight: 680; }
.wn-format-control > small { color: var(--content-faint); font-size: 8px; line-height: 1.55; }
.wn-tracking-list, .wn-reference-list { display: grid; gap: 5px; }
.wn-tracking-list h4, .wn-reference-list h4 { margin: 5px 2px 2px; font-size: 10px; }
.wn-tracking-list > button, .wn-search-hit { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 8px; border: 1px solid transparent; border-radius: 8px; color: var(--content-secondary); background: transparent; text-align: left; }
.wn-tracking-list > button:hover, .wn-search-hit:hover { border-color: var(--panel-edge); background: var(--primitive-glass); }
.wn-tracking-list small, .wn-reference-list small { color: var(--content-muted); font-size: 8px; }
.wn-reference-list article, .wn-reference-list > p { display: grid; gap: 5px; margin: 0; padding: 9px; border: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); border-radius: 9px; color: var(--content-muted); background: color-mix(in srgb, var(--primitive-glass) 44%, transparent); font-size: 9px; line-height: 1.6; }
.wn-reference-list article p, .wn-search-hit p { grid-column: 1 / 3; margin: 0; color: var(--content-muted); font-size: 9px; line-height: 1.6; }
.wn-reference-list article button { justify-self: start; padding: 4px 7px; border: 1px solid var(--panel-edge); border-radius: 6px; color: var(--content-secondary); background: transparent; font-size: 8px; }
.wn-quick-form, .wn-inline-search, .wn-shot-form { display: grid; gap: 6px; }
.wn-quick-form textarea, .wn-shot-form textarea { width: 100%; min-height: 64px; padding: 8px; resize: vertical; font-size: 9.5px; }
.wn-inline-search { grid-template-columns: 1fr auto; }
.wn-inline-search input, .wn-shot-form input, .wn-shot-form select { width: 100%; padding: 8px; font-size: 9px; }
.wn-inline-search button { padding: 0 10px; }
.wn-search-hit b { font-size: 10px; }
.wn-script-format { display: grid; gap: 6px; padding: 10px; border-left: 2px solid var(--accent-light); background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-script-format b { font-size: 10px; }
.wn-script-format code { color: var(--content-muted); font-family: ui-monospace, monospace; font-size: 8.5px; line-height: 1.7; }
.wn-shot-form > div { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.wn-prompt-preview { color: var(--accent-light) !important; }
.wn-empty, .wn-welcome, .wn-loading { height: 100%; display: grid; place-content: center; justify-items: center; padding: 40px; text-align: center; }
.wn-empty i, .wn-loading i { width: 32px; height: 32px; border: 1px solid var(--accent-light); border-radius: 50%; box-shadow: 0 0 28px color-mix(in srgb, var(--accent-light) 42%, transparent); }
.wn-empty h2, .wn-welcome h1, .wn-loading h2 { margin: 20px 0 8px; font-size: 26px; }
.wn-empty p, .wn-welcome p, .wn-loading p { max-width: 560px; color: var(--content-muted); font-size: 12px; line-height: 1.8; }
.wn-welcome { grid-column: 2 / 4; }
.wn-welcome span { color: var(--accent-light); font-size: 10px; font-weight: 800; letter-spacing: .18em; }
.wn-welcome > button { margin-top:18px; min-height:42px; padding:9px 18px; border:1px solid color-mix(in srgb, var(--accent-light) 48%, var(--panel-edge)); border-radius:999px; color:var(--button-primary-text); background:var(--button-primary-bg); font-weight:760; }
.wn-create-layer { position:absolute; z-index:180; inset:0; display:grid; place-items:center; padding:22px; }
.wn-create-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(2,5,15,.66); backdrop-filter:blur(12px); cursor:default !important; }
.wn-create-card { position:relative; z-index:1; width:min(520px, calc(100vw - 36px)); display:grid; grid-template-columns:1fr 1fr; gap:14px; box-sizing:border-box; padding:26px; border:1px solid color-mix(in srgb, var(--accent-light) 22%, var(--panel-edge)); border-radius:22px; background:color-mix(in srgb, var(--panel-bg) 94%, transparent); box-shadow:0 34px 100px rgba(0,0,0,.56); }
.wn-create-card > header { grid-column:1 / -1; display:flex; align-items:start; justify-content:space-between; gap:20px; margin-bottom:2px; }
.wn-create-card > header span { color:var(--accent-light); font-size:9px; font-weight:800; letter-spacing:.18em; }
.wn-create-card > header h2 { margin:5px 0 4px; color:var(--content-primary); font-size:25px; }
.wn-create-card > header p { margin:0; color:var(--content-muted); font-size:11px; }
.wn-create-card > header button { width:34px; height:34px; padding:0; border:1px solid var(--panel-edge); border-radius:50%; color:var(--content-secondary); background:var(--primitive-glass); font-size:21px; }
.wn-create-card label { display:grid; gap:7px; }
.wn-create-card label.wide { grid-column:1 / -1; }
.wn-create-card label > span { color:var(--content-muted); font-size:10px; font-weight:700; letter-spacing:.06em; }
.wn-create-card input, .wn-create-card select { width:100%; height:43px; padding:9px 11px; font-size:12px; }
.wn-create-card .wn-create-feedback { grid-column:1 / -1; }
.wn-create-card footer { grid-column:1 / -1; display:flex; justify-content:flex-end; gap:9px; margin-top:4px; }
.wn-create-card footer button { min-height:39px; padding:8px 15px; border:1px solid var(--panel-edge); border-radius:10px; color:var(--content-secondary); background:var(--primitive-glass); font-size:11px; font-weight:720; }
.wn-create-card footer button.primary { border-color:transparent; color:var(--button-primary-text); background:var(--button-primary-bg); }
.wn-create-card.wn-compact-create { width:min(470px, calc(100vw - 36px)); }
.wn-loading button { margin-top: 16px; padding: 9px 13px; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-secondary); background: var(--primitive-glass); }
.wn-board { height: 100%; box-sizing: border-box; overflow: auto; padding: 22px clamp(18px, 3vw, 42px) 38px; }
.wn-board > header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
.wn-board > header span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .2em; }
.wn-board > header h2 { margin: 5px 0 0; font-size: 25px; }
.wn-board > header > div:last-child { display: flex; gap: 8px; }
.wn-board-grid, .wn-review-grid, .wn-operations-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: start; }
.wn-review-grid { grid-template-columns: .85fr 1.15fr 1fr; }
.wn-operations-grid { grid-template-columns: 1.15fr .85fr; margin-top: 14px; }
.wn-board-grid > section, .wn-review-grid > section, .wn-operations-grid > section, .wn-audit { padding: 15px; border: 1px solid var(--panel-edge); border-radius: 14px; background: color-mix(in srgb, var(--panel-bg) 58%, transparent); }
.wn-board h3 { margin: 0 0 13px; font-size: 14px; }
.wn-board form { display: grid; gap: 8px; margin-bottom: 15px; }
.wn-board form input, .wn-board form select, .wn-board form textarea { width: 100%; padding: 9px 10px; font-size: 11px; }
.wn-board form textarea { min-height: 82px; resize: vertical; }
.wn-board section > article { display: grid; gap: 5px; margin-top: 8px; padding: 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 68%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-board article b, .wn-board article strong { color: var(--content-primary); font-size: 12px; }
.wn-board article small { color: var(--content-muted); font-size: 9.5px; line-height: 1.55; }
.wn-board article p { margin: 0; color: var(--content-muted); font-size: 10.5px; line-height: 1.65; }
.wn-review-grid > section:first-child > button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: 5px; margin-bottom: 6px; padding: 10px; border: 1px solid transparent; border-radius: 9px; color: var(--content-secondary); background: transparent; text-align: left; }
.wn-review-grid > section:first-child > button.active { border-color: var(--panel-edge); background: var(--primitive-glass); }
.wn-review-grid > section:first-child > button small { grid-column: 1 / 3; color: var(--content-faint); }
.wn-audit { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 16px; margin-bottom: 14px; }
.wn-audit > span { color: var(--content-muted); font-size: 10px; }
.wn-audit article { width: 100%; }
.wn-audit-pass { border-color: color-mix(in srgb, var(--accent-light) 38%, var(--panel-edge)); }
.wn-metrics { display: grid; grid-template-columns: repeat(8, minmax(92px, 1fr)); gap: 8px; }
.wn-metrics article { display: grid; gap: 5px; padding: 12px; border: 1px solid var(--panel-edge); border-radius: 11px; background: color-mix(in srgb, var(--panel-bg) 58%, transparent); }
.wn-metrics span { color: var(--content-muted); font-size: 9px; }
.wn-metrics b { font-size: 19px; }
.wn-metric-form { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.wn-metric-form label { display: grid; gap: 4px; }
.wn-metric-form label span { color: var(--content-muted); font-size: 9px; }
.wn-settings-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; max-width: 900px; }
.wn-settings-form label { display: grid; gap: 7px; color: var(--content-muted); font-size: 10px; }
.wn-settings-form input, .wn-settings-form select, .wn-settings-form textarea { padding: 11px 12px; }
.wn-settings-form label.wide { grid-column: 1 / 3; }
.wn-settings-form textarea { min-height: 150px; resize: vertical; }
.wn-boundary { max-width: 900px; margin: 20px 0 0; padding: 13px 15px; border-left: 2px solid var(--accent-light); color: var(--content-muted); background: color-mix(in srgb, var(--primitive-glass) 52%, transparent); font-size: 11px; line-height: 1.8; }
.wn-import-layer { position: absolute; z-index: 90; inset: 0; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--surface-depth) 78%, transparent); backdrop-filter: blur(18px); }
.wn-import-dialog { width: min(1040px, 94vw); max-height: min(820px, 92vh); overflow: hidden; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); padding: 22px; border: 1px solid color-mix(in srgb, var(--accent-light) 24%, var(--panel-edge)); border-radius: 20px; background: color-mix(in srgb, var(--panel-bg) 94%, var(--surface-depth)); box-shadow: 0 24px 90px rgba(0,0,0,.48); }
.wn-import-dialog > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; }
.wn-import-dialog > header span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .18em; }
.wn-import-dialog > header h2 { margin: 5px 0 0; font-size: 23px; }
.wn-import-dialog > header button { border: 0; color: var(--content-muted); background: transparent; }
.wn-import-receipt { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 17px; }
.wn-import-receipt article { display: grid; gap: 5px; padding: 11px 13px; border: 1px solid var(--panel-edge); border-radius: 11px; background: color-mix(in srgb, var(--primitive-glass) 58%, transparent); }
.wn-import-receipt span, .wn-import-source span { color: var(--content-muted); font-size: 9px; }
.wn-import-receipt b { font-size: 15px; }
.wn-import-source { display: flex; justify-content: space-between; gap: 20px; margin: 10px 0 14px; padding: 0 3px; }
.wn-import-source b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.wn-import-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .55fr); gap: 13px; }
.wn-import-body > section, .wn-import-body > form { min-height: 0; overflow: auto; padding: 14px; border: 1px solid var(--panel-edge); border-radius: 13px; background: color-mix(in srgb, var(--primitive-glass) 40%, transparent); }
.wn-import-body h3 { margin: 0 0 12px; font-size: 13px; }
.wn-import-sections article { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 9px 4px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 60%, transparent); }
.wn-import-sections article b { font-size: 11px; }
.wn-import-sections article small { color: var(--content-muted); font-size: 9px; }
.wn-import-sections article p { grid-column: 1 / 3; overflow: hidden; margin: 0; color: var(--content-faint); font-size: 9.5px; line-height: 1.55; text-overflow: ellipsis; white-space: nowrap; }
.wn-import-body form { display: grid; align-content: start; gap: 9px; }
.wn-import-body form label { display: grid; gap: 5px; color: var(--content-muted); font-size: 9px; }
.wn-import-body form input, .wn-import-body form select { width: 100%; padding: 9px 10px; font-size: 11px; }
.wn-import-body form p { margin: 4px 0; color: var(--content-muted); font-size: 9.5px; line-height: 1.7; }
.wn-import-body form button { min-height: 38px; border: 1px solid color-mix(in srgb, var(--accent-light) 36%, transparent); border-radius: 9px; color: var(--button-primary-text); background: var(--button-primary-bg); font-weight: 700; }
.wn-module-center { display: grid; gap: 14px; padding: 22px; }
.wn-module-header, .wn-module-workbench > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; }
.wn-module-header span, .wn-module-workbench > header small { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .18em; }
.wn-module-header h2 { margin: 5px 0; font-size: 25px; }
.wn-module-header p { max-width: 720px; margin: 0; color: var(--content-muted); font-size: 10.5px; line-height: 1.7; }
.wn-marketplace-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; }
.wn-marketplace-cards > article { min-width: 0; display: grid; align-content: space-between; gap: 12px; padding: 13px; border: 1px solid var(--panel-edge); border-radius: 13px; background: color-mix(in srgb, var(--panel-bg) 62%, transparent); cursor: pointer; }
.wn-marketplace-cards > article.active { border-color: color-mix(in srgb, var(--accent-light) 48%, var(--panel-edge)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-light) 14%, transparent); }
.wn-marketplace-cards > article small { overflow: hidden; display: block; color: var(--content-faint); font-size: 7.5px; text-overflow: ellipsis; white-space: nowrap; }
.wn-marketplace-cards > article b { display: block; margin-top: 7px; font-size: 12px; }
.wn-marketplace-cards > article p { min-height: 46px; margin: 6px 0; color: var(--content-muted); font-size: 9.5px; line-height: 1.6; }
.wn-marketplace-cards > article span { color: var(--accent-light); font-size: 8px; }
.wn-marketplace-cards footer, .wn-scene-list footer, .wn-delivery-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.wn-marketplace-cards button, .wn-module-workbench button, .wn-module-header button { min-height: 30px; padding: 6px 10px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: var(--primitive-glass); font-size: 9px; }
.wn-marketplace-cards button.primary, .wn-module-workbench button.primary { color: var(--button-primary-text); background: var(--button-primary-bg); }
.wn-module-workbench { min-width: 0; display: grid; gap: 13px; padding: 17px; border: 1px solid var(--panel-edge); border-radius: 15px; background: color-mix(in srgb, var(--panel-bg) 56%, transparent); }
.wn-module-workbench > header { align-items: end; padding-bottom: 11px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 60%, transparent); }
.wn-module-workbench > header h3 { margin: 4px 0 0; font-size: 17px; }
.wn-module-workbench > header > span { color: var(--content-muted); font-size: 9px; }
.wn-module-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; }
.wn-module-form input, .wn-module-form select, .wn-module-form textarea, .wn-field-form input, .wn-field-form select { min-width: 0; padding: 8px 9px; font-size: 10px; }
.wn-module-form textarea { grid-column: span 2; min-height: 56px; resize: vertical; }
.wn-field-form { display: grid; grid-template-columns: 1.2fr .55fr 1.2fr auto; gap: 7px; }
.wn-scene-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.wn-scene-list > article { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 11px; border: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); border-radius: 10px; background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-scene-list small, .wn-scene-list span { color: var(--content-muted); font-size: 8.5px; }
.wn-scene-list b { display: block; margin: 4px 0; font-size: 11px; }
.wn-scene-list p { margin: 0 0 5px; color: var(--content-secondary); font-size: 9.5px; line-height: 1.6; }
.wn-scene-list ol { grid-column: 1 / 3; margin: 0; padding: 7px 0 0 22px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 50%, transparent); }
.wn-scene-list li { margin: 3px 0; font-size: 9px; }
.wn-scene-list li span { margin-left: 8px; }
.wn-story-grid { overflow: auto; border: 1px solid color-mix(in srgb, var(--panel-edge) 75%, transparent); border-radius: 10px; }
.wn-story-grid table { min-width: 980px; width: 100%; border-collapse: collapse; font-size: 9px; }
.wn-story-grid th { position: sticky; top: 0; padding: 9px; color: var(--content-muted); background: var(--panel-bg); text-align: left; }
.wn-story-grid td { max-width: 180px; padding: 7px 9px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 55%, transparent); color: var(--content-secondary); }
.wn-story-grid td button { width: 100%; overflow: hidden; border: 0; background: transparent; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.wn-story-grid select { max-width: 95px; padding: 4px; font-size: 8.5px; }
.wn-timeline { position: relative; display: grid; gap: 8px; padding-left: 15px; }
.wn-timeline::before { position: absolute; inset: 0 auto 0 4px; width: 1px; background: color-mix(in srgb, var(--accent-light) 30%, var(--panel-edge)); content: ''; }
.wn-timeline article { position: relative; display: grid; grid-template-columns: 110px 1fr; gap: 12px; padding: 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 65%, transparent); border-radius: 9px; }
.wn-timeline article::before { position: absolute; left: -15px; top: 16px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-light); content: ''; }
.wn-timeline time, .wn-timeline small { color: var(--content-muted); font-size: 8.5px; }
.wn-timeline b { font-size: 11px; }
.wn-timeline p { margin: 4px 0; color: var(--content-secondary); font-size: 9.5px; }
.wn-version-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.wn-version-list article { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 65%, transparent); border-radius: 9px; }
.wn-version-list b, .wn-version-list small { display: block; }
.wn-version-list b { font-size: 10.5px; }
.wn-version-list small { margin-top: 3px; color: var(--content-muted); font-size: 8px; }
.wn-module-receipts { padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--panel-edge) 65%, transparent); border-radius: 11px; color: var(--content-muted); font-size: 9px; }
.wn-module-receipts article { display: grid; grid-template-columns: auto 1fr auto; gap: 5px 12px; padding: 9px 2px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 50%, transparent); }
.wn-module-receipts article p { grid-column: 1 / 3; margin: 0; }
@media (max-width: 1050px) {
.wn-shell { grid-template-columns: 190px 52px minmax(0, 1fr); }
.wn-writing-layout { grid-template-columns: 180px minmax(420px, 1fr) 260px; }
.wn-board-grid, .wn-review-grid { grid-template-columns: 1fr 1fr; }
.wn-metrics { grid-template-columns: repeat(4, minmax(92px, 1fr)); }
.wn-marketplace-cards { grid-template-columns: 1fr 1fr; }
.wn-module-form { grid-template-columns: 1fr 1fr; }
.wn-outline-editor-body { grid-template-columns: 1fr; overflow: auto; }
.wn-outline-editor-body > textarea { min-height: 420px; }
.wn-outline-render { min-height: 320px; }
}
@media (max-width: 760px) {
.wn-topbar { grid-template-columns: auto 1fr; }
.wn-topbar-actions small { display:none; }
.wn-topbar-actions button { padding-inline:10px; }
.wn-shell { grid-template-columns: 52px minmax(0, 1fr); }
.wn-library { display: none; }
.wn-writing-layout { grid-template-columns: 150px minmax(420px, 1fr); overflow: auto; }
.wn-writing-sidecar { display: none; }
.wn-board-grid, .wn-review-grid, .wn-operations-grid { grid-template-columns: 1fr; }
.wn-metric-form, .wn-settings-form { grid-template-columns: 1fr; }
.wn-settings-form label.wide { grid-column: 1; }
.wn-create-card { grid-template-columns:1fr; max-height:calc(100vh - 32px); overflow:auto; }
.wn-create-card > header, .wn-create-card .wn-create-feedback, .wn-create-card footer { grid-column:1; }
.wn-import-receipt { grid-template-columns: 1fr 1fr; }
.wn-import-body { grid-template-columns: 1fr; overflow: auto; }
.wn-marketplace-cards, .wn-scene-list, .wn-version-list { grid-template-columns: 1fr; }
.wn-field-form, .wn-module-form { grid-template-columns: 1fr; }
}