@waterwx/dsh-novel-forge 1.3.0-alpha → 1.3.1-alpha

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.
@@ -68,9 +68,13 @@ function CurveChart({ points, onPick }: { points: TensionCurvePoint[]; onPick: (
68
68
 
69
69
  export interface TensionViewProps {
70
70
  api: NovelApi
71
+ /** 一键按建议修订(合并审稿/张力/时间线后改这一章;由父级统一发起)。 */
72
+ onRevise?: (chapterNo: number) => void
73
+ /** 外部忙碌态(修订进行中时禁用按钮)。 */
74
+ busy?: boolean
71
75
  }
72
76
 
73
- export default function TensionView({ api }: TensionViewProps): JSX.Element {
77
+ export default function TensionView({ api, onRevise, busy: outerBusy }: TensionViewProps): JSX.Element {
74
78
  const [data, setData] = useState<TensionResponse | null>(null)
75
79
  const [busy, setBusy] = useState(false)
76
80
  const [error, setError] = useState('')
@@ -168,6 +172,18 @@ export default function TensionView({ api }: TensionViewProps): JSX.Element {
168
172
  <span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{issue.severity === 'high' ? '高' : issue.severity === 'medium' ? '中' : '低'}</span>
169
173
  <span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
170
174
  {issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
175
+ {onRevise !== undefined && issue.chapters.length > 0 && (
176
+ <button
177
+ type="button"
178
+ className={`${css.button} ${css.buttonSmall}`}
179
+ style={{ marginLeft: 6, marginTop: 2 }}
180
+ disabled={busy || outerBusy === true}
181
+ title="把该章的张力偏差与审稿意见、时间线矛盾合并成一轮修订(只改这一章,改完自动复核)"
182
+ onClick={() => { onRevise(issue.chapters[0]!) }}
183
+ >
184
+ 一键按建议修订(第 {issue.chapters[0]} 章)
185
+ </button>
186
+ )}
171
187
  </div>
172
188
  ))}
173
189
  </div>
@@ -1,192 +1,208 @@
1
- /**
2
- * 故事时间线:把逐章抽取的「时间点 / 地点 / 在场角色 / 事件」按章节顺序排开,
3
- * 带规则初筛 + AI 复核的矛盾清单,并支持就地修正(手改后标记为 manual)。
4
- *
5
- * 与「编年录」的分工:编年录记事实状态,这里记**顺序**——专门抓时间倒流、
6
- * 地点瞬移、跨度不合理这类长篇最容易崩的地方。
7
- */
8
-
9
- import { useCallback, useEffect, useMemo, useState } from 'react'
10
- import type { NovelApi } from '../api.ts'
11
- import type { TimelineEvent, TimelineIssue } from '../../protocol.ts'
12
- import css from './panel.module.css'
13
-
14
- type Severity = TimelineIssue['severity']
15
-
16
- const SEVERITY_LABEL: Record<Severity, string> = { high: '高', medium: '中', low: '低' }
17
-
18
- export interface TimelineViewProps {
19
- api: NovelApi
20
- /** 章节号列表(用于「抽取本章」下拉)。 */
21
- chapters: number[]
22
- }
23
-
24
- export default function TimelineView({ api, chapters }: TimelineViewProps): JSX.Element {
25
- const [events, setEvents] = useState<TimelineEvent[]>([])
26
- const [issues, setIssues] = useState<TimelineIssue[]>([])
27
- const [busy, setBusy] = useState(false)
28
- const [error, setError] = useState('')
29
- const [notice, setNotice] = useState('')
30
- const [extractNo, setExtractNo] = useState<number | ''>(chapters[chapters.length - 1] ?? '')
31
- const [editing, setEditing] = useState<TimelineEvent | null>(null)
32
-
33
- const refresh = useCallback(async (showError = true): Promise<void> => {
34
- try {
35
- const r = await api.timeline()
36
- setEvents(r.events)
37
- setIssues(r.issues ?? [])
38
- } catch (err) {
39
- if (showError) setError((err as Error).message)
40
- }
41
- }, [api])
42
-
43
- useEffect(() => { void refresh(false) }, [refresh])
44
-
45
- const grouped = useMemo(() => {
46
- const map = new Map<number, TimelineEvent[]>()
47
- for (const e of events) {
48
- const list = map.get(e.chapterNo) ?? []
49
- list.push(e)
50
- map.set(e.chapterNo, list)
51
- }
52
- return [...map.entries()].sort((a, b) => a[0] - b[0])
53
- }, [events])
54
-
55
- const run = useCallback(async (label: string, fn: () => Promise<void>): Promise<void> => {
56
- setBusy(true); setError(''); setNotice('')
57
- try {
58
- await fn()
59
- setNotice(label)
60
- } catch (err) {
61
- setError((err as Error).message)
62
- } finally {
63
- setBusy(false)
64
- }
65
- }, [])
66
-
67
- const handleExtract = (): void => {
68
- if (extractNo === '') return
69
- void run(`已抽取第 ${extractNo} 章时间线`, async () => {
70
- await api.timelineExtract(Number(extractNo))
71
- await refresh()
72
- })
73
- }
74
-
75
- const handleCheck = (): void => {
76
- void run('时间线检查完成', async () => {
77
- const r = await api.timelineCheck()
78
- setIssues(r.issues ?? [])
79
- })
80
- }
81
-
82
- const handleSaveEdit = (): void => {
83
- if (editing === null) return
84
- void run('已保存修改', async () => {
85
- await api.timelineUpdate({ ...editing, source: 'manual' })
86
- setEditing(null)
87
- await refresh()
88
- })
89
- }
90
-
91
- const handleRemove = (id: string): void => {
92
- void run('已删除该事件', async () => {
93
- await api.timelineRemove(id)
94
- await refresh()
95
- })
96
- }
97
-
98
- return (
99
- <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
100
- <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
101
- <span className={css.cardTitle}>故事时间线</span>
102
- <span className={css.meta}>{events.length} 条事件 · {grouped.length} 章</span>
103
- </div>
104
-
105
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'center' }}>
106
- <select
107
- className={css.input}
108
- value={extractNo}
109
- onChange={e => setExtractNo(e.target.value === '' ? '' : Number(e.target.value))}
110
- title="选择要抽取时间线的章节"
111
- >
112
- <option value="">选择章节…</option>
113
- {chapters.map(no => <option key={no} value={no}>第 {no} 章</option>)}
114
- </select>
115
- <button type="button" className={css.button} disabled={busy || extractNo === ''} onClick={handleExtract}>抽取本章时间线</button>
116
- <button type="button" className={css.button} disabled={busy || events.length === 0} onClick={handleCheck}>检查时间线矛盾</button>
117
- </div>
118
-
119
- {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
120
- {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
121
-
122
- {issues.length > 0 && (
123
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, background: 'var(--nf-bg-inset)' }}>
124
- <b style={{ fontSize: 'var(--nf-fs-12)' }}>发现 {issues.length} 处时间线问题</b>
125
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
126
- {issues.map((issue, i) => (
127
- <div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
128
- <span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{SEVERITY_LABEL[issue.severity]}</span>
129
- <span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
130
- {issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
131
- </div>
132
- ))}
133
- </div>
134
- </div>
135
- )}
136
-
137
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', maxHeight: '52vh' }}>
138
- {events.length === 0 && <span className={css.meta}>还没有时间线事件。生成章节后会自动抽取,也可以在上面选一章手工抽取。</span>}
139
- {grouped.map(([no, list]) => (
140
- <div key={no}>
141
- <div className={css.meta} style={{ marginBottom: 4 }}>第 {no} 章 · {list.length} 条</div>
142
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
143
- {list.map(e => (
144
- <div key={e.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px', fontSize: 'var(--nf-fs-12)' }}>
145
- <div style={{ display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap' }}>
146
- <b>{e.time !== '' ? e.time : '(未标注时间)'}</b>
147
- {e.place !== '' && <span className={css.meta}>@{e.place}</span>}
148
- {e.characters.length > 0 && <span className={css.meta}>{e.characters.join('、')}</span>}
149
- {e.source === 'manual' && <span className={css.meta}>(手改)</span>}
150
- <span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
151
- <button type="button" className={css.iconButton} title="修正这条事件" onClick={() => setEditing({ ...e })}>✎</button>
152
- <button type="button" className={css.iconButton} title="删除这条事件" onClick={() => handleRemove(e.id)}>×</button>
153
- </span>
154
- </div>
155
- <div style={{ marginTop: 2 }}>{e.event}</div>
156
- </div>
157
- ))}
158
- </div>
159
- </div>
160
- ))}
161
- </div>
162
-
163
- {editing !== null && (
164
- <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setEditing(null)}>
165
- <div onClick={ev => ev.stopPropagation()} style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(560px, 94vw)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
166
- <b style={{ fontSize: 'var(--nf-fs-13)' }}>修正时间线事件 · 第 {editing.chapterNo} 章</b>
167
- <label className={css.field}>
168
- <span className={css.fieldLabel}>故事内时间</span>
169
- <input className={css.input} value={editing.time} onChange={e => setEditing({ ...editing, time: e.target.value })} placeholder="如:第三日黄昏 / 入宗三个月后" />
170
- </label>
171
- <label className={css.field}>
172
- <span className={css.fieldLabel}>地点</span>
173
- <input className={css.input} value={editing.place} onChange={e => setEditing({ ...editing, place: e.target.value })} />
174
- </label>
175
- <label className={css.field}>
176
- <span className={css.fieldLabel}>在场角色(顿号或逗号分隔)</span>
177
- <input className={css.input} value={editing.characters.join('、')} onChange={e => setEditing({ ...editing, characters: e.target.value.split(/[、,,\s]+/).filter(Boolean) })} />
178
- </label>
179
- <label className={css.field}>
180
- <span className={css.fieldLabel}>事件</span>
181
- <textarea className={css.textarea} rows={3} value={editing.event} onChange={e => setEditing({ ...editing, event: e.target.value })} />
182
- </label>
183
- <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
184
- <button type="button" className={css.button} onClick={() => setEditing(null)}>取消</button>
185
- <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={handleSaveEdit}>保存</button>
186
- </div>
187
- </div>
188
- </div>
189
- )}
190
- </div>
191
- )
192
- }
1
+ /**
2
+ * 故事时间线:把逐章抽取的「时间点 / 地点 / 在场角色 / 事件」按章节顺序排开,
3
+ * 带规则初筛 + AI 复核的矛盾清单,并支持就地修正(手改后标记为 manual)。
4
+ *
5
+ * 与「编年录」的分工:编年录记事实状态,这里记**顺序**——专门抓时间倒流、
6
+ * 地点瞬移、跨度不合理这类长篇最容易崩的地方。
7
+ */
8
+
9
+ import { useCallback, useEffect, useMemo, useState } from 'react'
10
+ import type { NovelApi } from '../api.ts'
11
+ import type { TimelineEvent, TimelineIssue } from '../../protocol.ts'
12
+ import css from './panel.module.css'
13
+
14
+ type Severity = TimelineIssue['severity']
15
+
16
+ const SEVERITY_LABEL: Record<Severity, string> = { high: '高', medium: '中', low: '低' }
17
+
18
+ export interface TimelineViewProps {
19
+ api: NovelApi
20
+ /** 章节号列表(用于「抽取本章」下拉)。 */
21
+ chapters: number[]
22
+ /** 一键按建议修订(合并审稿/时间线/张力后改这一章;由父级统一发起)。 */
23
+ onRevise?: (chapterNo: number) => void
24
+ /** 外部忙碌态(修订进行中时禁用按钮)。 */
25
+ busy?: boolean
26
+ }
27
+
28
+ export default function TimelineView({ api, chapters, onRevise, busy: outerBusy }: TimelineViewProps): JSX.Element {
29
+ const [events, setEvents] = useState<TimelineEvent[]>([])
30
+ const [issues, setIssues] = useState<TimelineIssue[]>([])
31
+ const [busy, setBusy] = useState(false)
32
+ const [error, setError] = useState('')
33
+ const [notice, setNotice] = useState('')
34
+ const [extractNo, setExtractNo] = useState<number | ''>(chapters[chapters.length - 1] ?? '')
35
+ const [editing, setEditing] = useState<TimelineEvent | null>(null)
36
+
37
+ const refresh = useCallback(async (showError = true): Promise<void> => {
38
+ try {
39
+ const r = await api.timeline()
40
+ setEvents(r.events)
41
+ setIssues(r.issues ?? [])
42
+ } catch (err) {
43
+ if (showError) setError((err as Error).message)
44
+ }
45
+ }, [api])
46
+
47
+ useEffect(() => { void refresh(false) }, [refresh])
48
+
49
+ const grouped = useMemo(() => {
50
+ const map = new Map<number, TimelineEvent[]>()
51
+ for (const e of events) {
52
+ const list = map.get(e.chapterNo) ?? []
53
+ list.push(e)
54
+ map.set(e.chapterNo, list)
55
+ }
56
+ return [...map.entries()].sort((a, b) => a[0] - b[0])
57
+ }, [events])
58
+
59
+ const run = useCallback(async (label: string, fn: () => Promise<void>): Promise<void> => {
60
+ setBusy(true); setError(''); setNotice('')
61
+ try {
62
+ await fn()
63
+ setNotice(label)
64
+ } catch (err) {
65
+ setError((err as Error).message)
66
+ } finally {
67
+ setBusy(false)
68
+ }
69
+ }, [])
70
+
71
+ const handleExtract = (): void => {
72
+ if (extractNo === '') return
73
+ void run(`已抽取第 ${extractNo} 章时间线`, async () => {
74
+ await api.timelineExtract(Number(extractNo))
75
+ await refresh()
76
+ })
77
+ }
78
+
79
+ const handleCheck = (): void => {
80
+ void run('时间线检查完成', async () => {
81
+ const r = await api.timelineCheck()
82
+ setIssues(r.issues ?? [])
83
+ })
84
+ }
85
+
86
+ const handleSaveEdit = (): void => {
87
+ if (editing === null) return
88
+ void run('已保存修改', async () => {
89
+ await api.timelineUpdate({ ...editing, source: 'manual' })
90
+ setEditing(null)
91
+ await refresh()
92
+ })
93
+ }
94
+
95
+ const handleRemove = (id: string): void => {
96
+ void run('已删除该事件', async () => {
97
+ await api.timelineRemove(id)
98
+ await refresh()
99
+ })
100
+ }
101
+
102
+ return (
103
+ <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
104
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
105
+ <span className={css.cardTitle}>故事时间线</span>
106
+ <span className={css.meta}>{events.length} 条事件 · {grouped.length} 章</span>
107
+ </div>
108
+
109
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'center' }}>
110
+ <select
111
+ className={css.input}
112
+ value={extractNo}
113
+ onChange={e => setExtractNo(e.target.value === '' ? '' : Number(e.target.value))}
114
+ title="选择要抽取时间线的章节"
115
+ >
116
+ <option value="">选择章节…</option>
117
+ {chapters.map(no => <option key={no} value={no}>第 {no} 章</option>)}
118
+ </select>
119
+ <button type="button" className={css.button} disabled={busy || extractNo === ''} onClick={handleExtract}>抽取本章时间线</button>
120
+ <button type="button" className={css.button} disabled={busy || events.length === 0} onClick={handleCheck}>检查时间线矛盾</button>
121
+ </div>
122
+
123
+ {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
124
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
125
+
126
+ {issues.length > 0 && (
127
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, background: 'var(--nf-bg-inset)' }}>
128
+ <b style={{ fontSize: 'var(--nf-fs-12)' }}>发现 {issues.length} 处时间线问题</b>
129
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
130
+ {issues.map((issue, i) => (
131
+ <div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
132
+ <span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{SEVERITY_LABEL[issue.severity]}</span>
133
+ <span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
134
+ {issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
135
+ {onRevise !== undefined && issue.chapters.length > 0 && (
136
+ <button
137
+ type="button"
138
+ className={`${css.button} ${css.buttonSmall}`}
139
+ style={{ marginLeft: 6, marginTop: 2 }}
140
+ disabled={busy || outerBusy === true}
141
+ title="把该章的时间线矛盾与审稿意见、张力偏差合并成一轮修订(只改这一章,改完自动复核)"
142
+ onClick={() => { onRevise(issue.chapters[0]!) }}
143
+ >
144
+ 一键按建议修订(第 {issue.chapters[0]} 章)
145
+ </button>
146
+ )}
147
+ </div>
148
+ ))}
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', maxHeight: '52vh' }}>
154
+ {events.length === 0 && <span className={css.meta}>还没有时间线事件。生成章节后会自动抽取,也可以在上面选一章手工抽取。</span>}
155
+ {grouped.map(([no, list]) => (
156
+ <div key={no}>
157
+ <div className={css.meta} style={{ marginBottom: 4 }}>第 {no} 章 · {list.length} 条</div>
158
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
159
+ {list.map(e => (
160
+ <div key={e.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px', fontSize: 'var(--nf-fs-12)' }}>
161
+ <div style={{ display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap' }}>
162
+ <b>{e.time !== '' ? e.time : '(未标注时间)'}</b>
163
+ {e.place !== '' && <span className={css.meta}>@{e.place}</span>}
164
+ {e.characters.length > 0 && <span className={css.meta}>{e.characters.join('、')}</span>}
165
+ {e.source === 'manual' && <span className={css.meta}>(手改)</span>}
166
+ <span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
167
+ <button type="button" className={css.iconButton} title="修正这条事件" onClick={() => setEditing({ ...e })}>✎</button>
168
+ <button type="button" className={css.iconButton} title="删除这条事件" onClick={() => handleRemove(e.id)}>×</button>
169
+ </span>
170
+ </div>
171
+ <div style={{ marginTop: 2 }}>{e.event}</div>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ </div>
176
+ ))}
177
+ </div>
178
+
179
+ {editing !== null && (
180
+ <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setEditing(null)}>
181
+ <div onClick={ev => ev.stopPropagation()} style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(560px, 94vw)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
182
+ <b style={{ fontSize: 'var(--nf-fs-13)' }}>修正时间线事件 · 第 {editing.chapterNo} 章</b>
183
+ <label className={css.field}>
184
+ <span className={css.fieldLabel}>故事内时间</span>
185
+ <input className={css.input} value={editing.time} onChange={e => setEditing({ ...editing, time: e.target.value })} placeholder="如:第三日黄昏 / 入宗三个月后" />
186
+ </label>
187
+ <label className={css.field}>
188
+ <span className={css.fieldLabel}>地点</span>
189
+ <input className={css.input} value={editing.place} onChange={e => setEditing({ ...editing, place: e.target.value })} />
190
+ </label>
191
+ <label className={css.field}>
192
+ <span className={css.fieldLabel}>在场角色(顿号或逗号分隔)</span>
193
+ <input className={css.input} value={editing.characters.join('、')} onChange={e => setEditing({ ...editing, characters: e.target.value.split(/[、,,\s]+/).filter(Boolean) })} />
194
+ </label>
195
+ <label className={css.field}>
196
+ <span className={css.fieldLabel}>事件</span>
197
+ <textarea className={css.textarea} rows={3} value={editing.event} onChange={e => setEditing({ ...editing, event: e.target.value })} />
198
+ </label>
199
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
200
+ <button type="button" className={css.button} onClick={() => setEditing(null)}>取消</button>
201
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={handleSaveEdit}>保存</button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ )}
206
+ </div>
207
+ )
208
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Tiny translation helper for the panel: reads the zh dict with the en dict
3
- * as fallback (the family plugins use a full locale registry; the panel keeps
4
- * a dependency-free helper so the client bundle stays self-contained).
2
+ * Tiny translation helper for the panel.
3
+ *
4
+ * 面板是中文单语界面:文案只在 `locales.ts` 定义一次,这里按类型化键位取值
5
+ * (键位不匹配会在编译期报错),保持客户端 bundle 自包含、不引入运行时语言服务。
6
+ * 需要多语言时再接入 DSH 的 locale 服务,届时只需替换本函数实现。
5
7
  */
6
- import { zh, en, type NovelKey } from '../locales.ts'
8
+ import { zh, type NovelKey } from '../locales.ts'
7
9
 
8
10
  /** Translate one key with optional {placeholder} substitution. */
9
11
  export function tt(key: NovelKey, params?: Record<string, string | number>): string {
10
- let text: string = zh[key] ?? en[key] ?? key
12
+ let text: string = zh[key] ?? key
11
13
  if (params !== undefined) {
12
14
  for (const [name, value] of Object.entries(params)) {
13
15
  text = text.replaceAll(`{${name}}`, String(value))