@waterwx/dsh-novel-forge 1.7.3 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +3219 -662
- package/lib/client.js.map +1 -1
- package/lib/index.js +629 -41
- package/lib/index.js.map +1 -1
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/client/api.d.ts +18 -0
- package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +20 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/SettingsView.d.ts +14 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/engine.d.ts +24 -1
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +192 -0
- package/package.json +1 -1
- package/src/author-assets.ts +166 -0
- package/src/client/api.ts +41 -0
- package/src/client/mount.tsx._new +97 -0
- package/src/client/mount.tsx._nf +97 -0
- package/src/client/mount.tsx._tmp +97 -0
- package/src/client/panel/AdaptModeView.tsx +204 -0
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +93 -0
- package/src/client/panel/ImportModal.tsx +2 -1
- package/src/client/panel/NovelPanel.tsx +47 -16
- package/src/client/panel/ProgressHomeView.tsx +87 -0
- package/src/client/panel/SettingsView.tsx +285 -0
- package/src/client/panel/panel.module.css +325 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/engine.ts +221 -0
- package/src/index.ts +20 -0
- package/src/protocol.ts +196 -0
- package/src/routes.ts +191 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting — mirrors the family plugins: a container appended
|
|
3
|
+
* inside the conversation grid item, hidden while inactive; toggling is a
|
|
4
|
+
* data attribute on <html>, with cross-plugin activation events.
|
|
5
|
+
*/
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import type { NovelApi } from './api.ts'
|
|
8
|
+
import type { PanelController } from './panel/controller.ts'
|
|
9
|
+
import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
10
|
+
import css from './panel/panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** The injected panel container. */
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
17
|
+
/** Sibling panels' activation attributes (evicted when this panel opens). */
|
|
18
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
19
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
20
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
21
|
+
const PANEL_NAME = 'novelforge'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the panel React tree into the center column and bind visibility to
|
|
30
|
+
* the controller.
|
|
31
|
+
*/
|
|
32
|
+
export function mountPanel(controller: PanelController, api: NovelApi): () => void {
|
|
33
|
+
let root: Root | undefined
|
|
34
|
+
let container: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
const ensure = (): void => {
|
|
37
|
+
if (container !== undefined) {
|
|
38
|
+
if (container.isConnected) return
|
|
39
|
+
root?.unmount()
|
|
40
|
+
root = undefined
|
|
41
|
+
container.remove()
|
|
42
|
+
container = undefined
|
|
43
|
+
}
|
|
44
|
+
const column = conversationColumn()
|
|
45
|
+
if (column === undefined) return
|
|
46
|
+
container = document.createElement('div')
|
|
47
|
+
container.dataset.dshNovelforgeView = 'true'
|
|
48
|
+
container.className = css.view
|
|
49
|
+
column.appendChild(container)
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(<NovelPanel controller={controller} api={api} />)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
55
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Jump out on sidebar context clicks.
|
|
73
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
74
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
75
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
76
|
+
const target = event.target as HTMLElement | null
|
|
77
|
+
if (target === null) return
|
|
78
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
81
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
82
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
83
|
+
applyActive()
|
|
84
|
+
ensure()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting — mirrors the family plugins: a container appended
|
|
3
|
+
* inside the conversation grid item, hidden while inactive; toggling is a
|
|
4
|
+
* data attribute on <html>, with cross-plugin activation events.
|
|
5
|
+
*/
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import type { NovelApi } from './api.ts'
|
|
8
|
+
import type { PanelController } from './panel/controller.ts'
|
|
9
|
+
import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
10
|
+
import css from './panel/panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** The injected panel container. */
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
17
|
+
/** Sibling panels' activation attributes (evicted when this panel opens). */
|
|
18
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
19
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
20
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
21
|
+
const PANEL_NAME = 'novelforge'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the panel React tree into the center column and bind visibility to
|
|
30
|
+
* the controller.
|
|
31
|
+
*/
|
|
32
|
+
export function mountPanel(controller: PanelController, api: NovelApi): () => void {
|
|
33
|
+
let root: Root | undefined
|
|
34
|
+
let container: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
const ensure = (): void => {
|
|
37
|
+
if (container !== undefined) {
|
|
38
|
+
if (container.isConnected) return
|
|
39
|
+
root?.unmount()
|
|
40
|
+
root = undefined
|
|
41
|
+
container.remove()
|
|
42
|
+
container = undefined
|
|
43
|
+
}
|
|
44
|
+
const column = conversationColumn()
|
|
45
|
+
if (column === undefined) return
|
|
46
|
+
container = document.createElement('div')
|
|
47
|
+
container.dataset.dshNovelforgeView = 'true'
|
|
48
|
+
container.className = css.view
|
|
49
|
+
column.appendChild(container)
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(<NovelPanel controller={controller} api={api} />)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
55
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Jump out on sidebar context clicks.
|
|
73
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
74
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
75
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
76
|
+
const target = event.target as HTMLElement | null
|
|
77
|
+
if (target === null) return
|
|
78
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
81
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
82
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
83
|
+
applyActive()
|
|
84
|
+
ensure()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting — mirrors the family plugins: a container appended
|
|
3
|
+
* inside the conversation grid item, hidden while inactive; toggling is a
|
|
4
|
+
* data attribute on <html>, with cross-plugin activation events.
|
|
5
|
+
*/
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import type { NovelApi } from './api.ts'
|
|
8
|
+
import type { PanelController } from './panel/controller.ts'
|
|
9
|
+
import { NovelPanel } from './panel/NovelPanel.tsx'
|
|
10
|
+
import css from './panel/panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** The injected panel container. */
|
|
13
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
|
|
14
|
+
|
|
15
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
16
|
+
const ACTIVE_ATTR = 'data-dsh-novelforge-active'
|
|
17
|
+
/** Sibling panels' activation attributes (evicted when this panel opens). */
|
|
18
|
+
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
19
|
+
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
20
|
+
const ACTIVATE_EVENT = 'dsh-panel-activate'
|
|
21
|
+
const PANEL_NAME = 'novelforge'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the panel React tree into the center column and bind visibility to
|
|
30
|
+
* the controller.
|
|
31
|
+
*/
|
|
32
|
+
export function mountPanel(controller: PanelController, api: NovelApi): () => void {
|
|
33
|
+
let root: Root | undefined
|
|
34
|
+
let container: HTMLDivElement | undefined
|
|
35
|
+
|
|
36
|
+
const ensure = (): void => {
|
|
37
|
+
if (container !== undefined) {
|
|
38
|
+
if (container.isConnected) return
|
|
39
|
+
root?.unmount()
|
|
40
|
+
root = undefined
|
|
41
|
+
container.remove()
|
|
42
|
+
container = undefined
|
|
43
|
+
}
|
|
44
|
+
const column = conversationColumn()
|
|
45
|
+
if (column === undefined) return
|
|
46
|
+
container = document.createElement('div')
|
|
47
|
+
container.dataset.dshNovelforgeView = 'true'
|
|
48
|
+
container.className = css.view
|
|
49
|
+
column.appendChild(container)
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(<NovelPanel controller={controller} api={api} />)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
55
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
56
|
+
|
|
57
|
+
const applyActive = (): void => {
|
|
58
|
+
if (controller.getSnapshot().panelOpen) {
|
|
59
|
+
for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
|
|
60
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
61
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
|
|
62
|
+
} else {
|
|
63
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const onOtherActivate = (event: Event): void => {
|
|
67
|
+
const detail = (event as CustomEvent).detail
|
|
68
|
+
if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
|
|
69
|
+
controller.close()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Jump out on sidebar context clicks.
|
|
73
|
+
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
74
|
+
const onClickSidebarRow = (event: MouseEvent): void => {
|
|
75
|
+
if (!controller.getSnapshot().panelOpen) return
|
|
76
|
+
const target = event.target as HTMLElement | null
|
|
77
|
+
if (target === null) return
|
|
78
|
+
if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('click', onClickSidebarRow, true)
|
|
81
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
82
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
83
|
+
applyActive()
|
|
84
|
+
ensure()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('click', onClickSidebarRow, true)
|
|
88
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
89
|
+
waitObserver.disconnect()
|
|
90
|
+
unsubscribe()
|
|
91
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
92
|
+
root?.unmount()
|
|
93
|
+
root = undefined
|
|
94
|
+
container?.remove()
|
|
95
|
+
container = undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 改编模式:P0 分析 → P1 改编方案(勾选/填新值→映射表/规则/影响) → P2 术语替换执行。
|
|
3
|
+
*/
|
|
4
|
+
import { useRef, useState } from 'react'
|
|
5
|
+
import { FileText, Wand2, Upload, Download, ListChecks, Play } from 'lucide-react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import type { AdaptAnalyzeResponse, AdaptationProposal, AdaptExecuteResponse } from '../../protocol.ts'
|
|
8
|
+
import { readFileTextSmart } from '../text.ts'
|
|
9
|
+
import css from './panel.module.css'
|
|
10
|
+
|
|
11
|
+
const MUTABILITY_LABEL: Record<string, string> = {
|
|
12
|
+
locked: '🔒 建议保留', big: '🟡 可改影响大', small: '🟢 可改影响小', free: '🟣 可自由改', visual: '📦 仅视觉包装',
|
|
13
|
+
};
|
|
14
|
+
const MUTABILITY_CLS: Record<string, string> = {
|
|
15
|
+
locked: 'var(--nf-muted)', big: 'var(--nf-warn)', small: 'var(--nf-info)', free: 'var(--nf-accent)', visual: 'var(--nf-muted)',
|
|
16
|
+
};
|
|
17
|
+
const RISK_LABEL: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
|
|
18
|
+
|
|
19
|
+
interface EditState { enabled: boolean; target: string }
|
|
20
|
+
|
|
21
|
+
export function AdaptModeView({ api }: { api: NovelApi }) {
|
|
22
|
+
const [text, setText] = useState('');
|
|
23
|
+
const [result, setResult] = useState<AdaptAnalyzeResponse | null>(null);
|
|
24
|
+
const [edits, setEdits] = useState<Record<string, EditState>>({});
|
|
25
|
+
const [proposal, setProposal] = useState<AdaptationProposal | null>(null);
|
|
26
|
+
const [executed, setExecuted] = useState<AdaptExecuteResponse | null>(null);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [busyLabel, setBusyLabel] = useState('');
|
|
29
|
+
const [error, setError] = useState('');
|
|
30
|
+
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
31
|
+
|
|
32
|
+
const onFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
33
|
+
const file = e.target.files?.[0];
|
|
34
|
+
if (file === undefined) return;
|
|
35
|
+
try { setText(await readFileTextSmart(file)) } catch (err) { setError((err as Error).message) }
|
|
36
|
+
e.target.value = '';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const analyze = async (): Promise<void> => {
|
|
40
|
+
if (text.trim().length < 200) { setError('请粘贴或上传完整小说全文(至少 200 字符)'); return }
|
|
41
|
+
setBusy(true); setBusyLabel('分析中…'); setError(''); setProposal(null); setExecuted(null);
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.adaptAnalyze(text);
|
|
44
|
+
setResult(res);
|
|
45
|
+
const next: Record<string, EditState> = {};
|
|
46
|
+
for (const d of res.dimensions) {
|
|
47
|
+
next[d.key] = { enabled: d.mutability !== 'locked', target: d.candidates?.[0] ?? '' };
|
|
48
|
+
}
|
|
49
|
+
setEdits(next);
|
|
50
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const setEdit = (key: string, patch: Partial<EditState>): void => {
|
|
54
|
+
setEdits(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const selectedDims = result === null ? [] : result.dimensions.filter(d => {
|
|
58
|
+
const e = edits[d.key];
|
|
59
|
+
return e?.enabled === true && (e.target ?? '').trim() !== '';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const buildSelections = () => selectedDims.map(d => ({ key: d.key, title: d.title, current: d.current, target: (edits[d.key]?.target ?? '').trim(), mutability: d.mutability }));
|
|
63
|
+
|
|
64
|
+
const propose = async (): Promise<void> => {
|
|
65
|
+
if (result === null || text.trim().length < 200) { setError('请先分析'); return }
|
|
66
|
+
const selections = buildSelections();
|
|
67
|
+
if (selections.length === 0) { setError('请至少选择一条要改的维度并填写新值'); return }
|
|
68
|
+
setBusy(true); setBusyLabel('生成改编方案…'); setError(''); setExecuted(null);
|
|
69
|
+
try {
|
|
70
|
+
const res = await api.adaptPropose({ text, selections, dimensions: result.dimensions });
|
|
71
|
+
setProposal(res.proposal);
|
|
72
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const execute = async (): Promise<void> => {
|
|
76
|
+
if (proposal === null || proposal.mappings.length === 0) { setError('请先生成改编方案'); return }
|
|
77
|
+
setBusy(true); setBusyLabel('执行术语替换…'); setError('');
|
|
78
|
+
try {
|
|
79
|
+
const res = await api.adaptExecute({ text, mappings: proposal.mappings, mode: 'replace' });
|
|
80
|
+
setExecuted(res);
|
|
81
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const downloadAdapted = (): void => {
|
|
85
|
+
if (executed === null) return;
|
|
86
|
+
const blob = new Blob([executed.adaptedText], { type: 'text/markdown;charset=utf-8' });
|
|
87
|
+
const url = URL.createObjectURL(blob);
|
|
88
|
+
const a = document.createElement('a');
|
|
89
|
+
a.href = url;
|
|
90
|
+
const name = result?.bookName !== undefined && result.bookName !== '' ? result.bookName + '·改编版.md' : '改编版.md';
|
|
91
|
+
a.download = name;
|
|
92
|
+
a.click();
|
|
93
|
+
URL.revokeObjectURL(url);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className={css.authorPageBody}>
|
|
98
|
+
<div className={css.authorPageHeader}>
|
|
99
|
+
<div>
|
|
100
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}>🎬 改编模式</h2>
|
|
101
|
+
<span className={css.meta}>上传全文 → 分析可改范围 → 勾选/填新值 → 生成映射表 → 术语替换执行。</span>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className={css.adaptInputCard}>
|
|
106
|
+
<div className={css.rowEnd} style={{ justifyContent: 'space-between' }}>
|
|
107
|
+
<span className={css.meta}>粘贴全文,或上传 txt/md 文件</span>
|
|
108
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => fileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传文件</button>
|
|
109
|
+
</div>
|
|
110
|
+
<input ref={fileRef} type="file" accept=".txt,.md,text/plain,text/markdown" style={{ display: 'none' }} onChange={e => { void onFile(e) }} />
|
|
111
|
+
<textarea className={css.input + ' ' + css.adaptTextarea} value={text} onChange={e => setText(e.target.value)} placeholder="粘贴小说全文(含章节标题/分卷更好)…" />
|
|
112
|
+
<div className={css.rowEnd}><span className={css.meta}>已输入 {text.length} 字</span><button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void analyze() }} disabled={busy}><Wand2 size={14} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '分析中…' ? '分析中…' : '开始分析'}</button></div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
116
|
+
|
|
117
|
+
{result === null ? (
|
|
118
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
119
|
+
<span className={css.shelfEmptyIcon}><FileText size={30} /></span>
|
|
120
|
+
<span className={css.shelfEmptyTitle}>等待分析</span>
|
|
121
|
+
<span className={css.meta}>分析会读取全文并提炼「原文设定卡片」与「可改范围矩阵」。</span>
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<div className={css.adaptResult}>
|
|
125
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
126
|
+
<h3 style={{ margin: 0 }}>《{result.bookName}》 · {result.chapters} 章</h3>
|
|
127
|
+
<span className={css.meta}>{result.note}</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{result.outline !== undefined && result.outline !== '' && (
|
|
131
|
+
<details className={css.adaptOutline}><summary>反推大纲(点击展开)</summary><pre className={css.adaptOutlinePre}>{result.outline}</pre></details>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
135
|
+
<b>可改范围矩阵(勾选要改 + 填新值)</b>
|
|
136
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void propose() }} disabled={busy}><ListChecks size={13} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '生成改编方案…' ? '生成中…' : '生成改编方案'} ({selectedDims.length})</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className={css.adaptMatrix}>
|
|
140
|
+
{result.dimensions.map((d, i) => {
|
|
141
|
+
const e = edits[d.key];
|
|
142
|
+
const enabled = e?.enabled === true;
|
|
143
|
+
const target = e?.target ?? '';
|
|
144
|
+
return (
|
|
145
|
+
<div key={d.key + '-' + i} className={css.dimCard + (enabled ? ' ' + css.dimCardSelected : '')}>
|
|
146
|
+
<div className={css.dimCardHead}>
|
|
147
|
+
<span className={css.dimTitle}>{d.title}</span>
|
|
148
|
+
<span className={css.badge} style={{ color: MUTABILITY_CLS[d.mutability] ?? 'var(--nf-muted)' }}>{MUTABILITY_LABEL[d.mutability] ?? d.mutability}</span>
|
|
149
|
+
<span className={css.meta}>风险:{RISK_LABEL[d.risk] ?? d.risk}</span>
|
|
150
|
+
</div>
|
|
151
|
+
<div className={css.dimCurrent}><b>当前值</b>:{d.current}</div>
|
|
152
|
+
{d.mutability !== 'locked' && (
|
|
153
|
+
<div className={css.dimEdit}>
|
|
154
|
+
<input type="checkbox" checked={enabled} onChange={e2 => setEdit(d.key, { enabled: e2.target.checked })} />
|
|
155
|
+
<input className={css.input} value={target} disabled={!enabled} placeholder="改为什么?" onChange={e2 => setEdit(d.key, { target: e2.target.value })} />
|
|
156
|
+
{(d.candidates ?? []).length > 0 && <span className={css.meta}>候选:{(d.candidates ?? []).join(' / ')}</span>}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
{(d.evidence !== undefined && d.evidence !== '') && <div className={css.meta}>证据:{d.evidence}</div>}
|
|
160
|
+
<div className={css.meta}>影响:{d.impact}</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{proposal !== null && (
|
|
167
|
+
<div className={css.adaptProposal}>
|
|
168
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
169
|
+
<b>改编方案</b>
|
|
170
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void execute() }} disabled={busy}><Play size={13} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '执行术语替换…' ? '执行中…' : '执行改编(术语替换)'}</button>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<h4 style={{ margin: '10px 0 4px' }}>映射表</h4>
|
|
174
|
+
<table className={css.adaptTable}><thead><tr><th>原值</th><th>新值</th><th>范围</th></tr></thead><tbody>
|
|
175
|
+
{proposal.mappings.map((m, i2) => (<tr key={i2}><td>{m.source}</td><td>{m.target}</td><td>{m.scope}</td></tr>))}
|
|
176
|
+
</tbody></table>
|
|
177
|
+
|
|
178
|
+
<h4 style={{ margin: '10px 0 4px' }}>改编规则</h4>
|
|
179
|
+
<div className={css.meta}><b>保留:</b>{proposal.rules.preserve.join(';') || '—'}</div>
|
|
180
|
+
<div className={css.meta}><b>允许变:</b>{proposal.rules.change.join(';') || '—'}</div>
|
|
181
|
+
<div className={css.meta}><b>红线:</b>{proposal.rules.constraints.join(';') || '—'}</div>
|
|
182
|
+
|
|
183
|
+
<h4 style={{ margin: '10px 0 4px' }}>联动影响</h4>
|
|
184
|
+
<div className={css.adaptImpacts}>
|
|
185
|
+
{proposal.impacts.map((im, i3) => (<div key={i3} className={css.adaptImpact}><b style={{ color: im.risk === 'high' ? 'var(--nf-error)' : im.risk === 'medium' ? 'var(--nf-warn)' : 'var(--nf-info)' }}>[{RISK_LABEL[im.risk] ?? im.risk}]</b> {im.item}:{im.detail}{im.chapters !== undefined && im.chapters.length > 0 ? `(章:${im.chapters.join(',')})` : ''}</div>))}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{executed !== null && (
|
|
191
|
+
<div className={css.adaptProposal}>
|
|
192
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
193
|
+
<b>改编结果(术语替换)</b>
|
|
194
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={downloadAdapted}><Download size={13} style={{ verticalAlign: -2 }} /> 下载改编版</button>
|
|
195
|
+
</div>
|
|
196
|
+
<div className={css.meta}>命中:{executed.hits.filter(h => h.count > 0).map(h => h.source + '→' + h.target + '×' + h.count).join(',') || '无命中'}</div>
|
|
197
|
+
<details className={css.adaptOutline}><summary>改编正文预览(点击展开)</summary><pre className={css.adaptOutlinePre}>{executed.adaptedText.slice(0, 8000)}</pre></details>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|