dsh-lifecard 0.1.1 → 0.2.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 CHANGED
@@ -727,7 +727,7 @@ const SECTIONS_REL = [
727
727
 
728
728
  // ── 主界面 ────────────────────────────────────────────────────────
729
729
 
730
- const VIEWS = { doors: 'doors', novel: 'novel', tavern: 'tavern', tavernChat: 'tavernChat', home: 'home', card: 'card', interview: 'interview', import: 'import', analyze: 'analyze', doctor: 'doctor', setup: 'setup', newCard: 'newCard', world: 'world', memory: 'memory', jobs: 'jobs' }
730
+ const VIEWS = { doors: 'doors', novel: 'novel', book: 'book', game: 'game', career: 'career', tavern: 'tavern', tavernChat: 'tavernChat', home: 'home', card: 'card', interview: 'interview', import: 'import', analyze: 'analyze', doctor: 'doctor', setup: 'setup', newCard: 'newCard', world: 'world', memory: 'memory', jobs: 'jobs' }
731
731
 
732
732
  /**
733
733
  * tab 组件。接收宿主给的 props(ctx / store / scope / tab / visible)。
@@ -950,13 +950,40 @@ function LifeCardTab(props) {
950
950
  // 按号码摆状态用着,中间插一个会让那一整排全部错位。
951
951
  const [chat, setChat] = useState(null)
952
952
  const [chats, setChats] = useState([])
953
+ // 回到「写小说」那扇门就重新拿一遍书架:刚写完一章、刚新建一本再回来,
954
+ // 还挂着进这一屏之前的旧列表,用户会以为刚才那一步没生效。
955
+ useEffect(() => {
956
+ if (view !== VIEWS.novel) return
957
+ let gone = false
958
+ call('novel.books', {})
959
+ .then((r) => { if (!gone && r?.ok === true && Array.isArray(r.books)) setBooks(r.books) })
960
+ .catch(() => { /* 取不到就空着,不影响新建 */ })
961
+ return () => { gone = true }
962
+ }, [view])
963
+
953
964
  // 「我现在在哪扇门里」必须自己记住,不能靠「当前在哪一屏」去猜。
954
965
  // 以前 mode 是从 view 推的:一进「聊着建」,当前屏就变成 interview,
955
966
  // 于是不管从哪扇门进来,都退回 real——三扇门点进去全都说
956
967
  // 「你在记录一个现实里的人」。子页面不是门,门要跟着人走。
957
968
  const [door, setDoor] = useState('real')
969
+ // 写到第几本书(book 视图用)。null = 还没打开任何一本。
970
+ const [activeBook, setActiveBook] = useState(null)
971
+ // 书架上的书(写小说那扇门要列出来)
972
+ const [books, setBooks] = useState([])
973
+ // 哪些功能装了。null = 还没问到(先都按「装了」显示,别闪一下少一扇门)。
974
+ //
975
+ // 这个插件的每一块都能整个删掉:删了酒馆的文件,酒馆就没了,别的照常。
976
+ // 所以界面不能写死「一定有三扇门」,得先问一句「现在有哪些」。
977
+ const [features, setFeatures] = useState(null)
978
+ useEffect(() => {
979
+ let gone = false
980
+ call('features.list', {})
981
+ .then((r) => { if (!gone && r?.ok === true && r.features !== undefined) setFeatures(r.features) })
982
+ .catch(() => { /* 问不到就按「都装了」显示,不影响用 */ })
983
+ return () => { gone = true }
984
+ }, [])
958
985
  // 门自己的那两屏直接说了算;其余屏(聊着建、卡片、分析…)沿用上次选的门。
959
- const mode = view === VIEWS.novel ? 'novel' : (view === VIEWS.tavern || view === VIEWS.tavernChat) ? 'tavern' : door
986
+ const mode = (view === VIEWS.novel || view === VIEWS.book || view === VIEWS.game) ? 'novel' : (view === VIEWS.tavern || view === VIEWS.tavernChat) ? 'tavern' : door
960
987
  // 回到酒馆那两屏就重新拿一遍说过的场次:刚说完、刚删完再回来,
961
988
  // 还挂着进这一屏之前的旧列表,用户会以为刚才那一步没生效。
962
989
  useEffect(() => {
@@ -968,6 +995,25 @@ function LifeCardTab(props) {
968
995
  return () => { gone = true }
969
996
  }, [view])
970
997
 
998
+ /** 打开一本书,进到那一屏。 */
999
+ const openBook = (book) => {
1000
+ setActiveBook({ id: book?.id ?? '', title: book?.title ?? '' })
1001
+ setView(VIEWS.book)
1002
+ }
1003
+
1004
+ /** 新开一本:问一个书名就够,别的都能后面补。 */
1005
+ const createBook = async () => {
1006
+ const ask = typeof window !== 'undefined' && typeof window.prompt === 'function' ? window.prompt : null
1007
+ const title = ask === null ? '新书' : String(ask('这本书叫什么?') ?? '').trim()
1008
+ if (title === '') return
1009
+ try {
1010
+ const r = await call('novel.create', { title })
1011
+ if (r?.ok !== true) { setError(r?.error ?? '这本书没能建起来'); return }
1012
+ setActiveBook({ id: r.id, title: r.title })
1013
+ setView(VIEWS.book)
1014
+ } catch (e) { setError(humanizeError(e)) }
1015
+ }
1016
+
971
1017
  const pickDoor = (next) => {
972
1018
  setDoor(next)
973
1019
  setError(null)
@@ -1036,7 +1082,7 @@ function LifeCardTab(props) {
1036
1082
  try {
1037
1083
  const r = await call('tavern.get', { id: row?.id })
1038
1084
  if (r?.ok !== true) throw new Error(r?.error ?? '这一场打不开')
1039
- setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], err: null, busy: false })
1085
+ setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], notes: r.notes ?? [], err: null, busy: false })
1040
1086
  setView(VIEWS.tavernChat)
1041
1087
  } catch (e) {
1042
1088
  setError(humanizeError(e))
@@ -1051,7 +1097,7 @@ function LifeCardTab(props) {
1051
1097
  try {
1052
1098
  const r = await call('tavern.start', { card: card?.path ?? String(card?.id ?? '') })
1053
1099
  if (r?.ok !== true) throw new Error(r?.error ?? '这一场没开起来')
1054
- setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], err: null, busy: false })
1100
+ setChat({ id: r.id, card_name: r.card_name, world_name: r.world_name, turns: r.turns ?? [], notes: r.notes ?? [], err: null, busy: false })
1055
1101
  setView(VIEWS.tavernChat)
1056
1102
  } catch (e) {
1057
1103
  setError(humanizeError(e))
@@ -1080,6 +1126,9 @@ function LifeCardTab(props) {
1080
1126
  [VIEWS.doors]: '你要用哪个?',
1081
1127
  [VIEWS.home]: '真实人物记录',
1082
1128
  [VIEWS.novel]: '写小说',
1129
+ [VIEWS.game]: '玩一段',
1130
+ [VIEWS.career]: '简历与规划',
1131
+ [VIEWS.book]: activeBook?.title === undefined || String(activeBook.title).trim() === '' ? '这本书' : String(activeBook.title),
1083
1132
  [VIEWS.tavern]: '酒馆',
1084
1133
  [VIEWS.tavernChat]: chat === null ? '聊一场' : (String(chat.card_name ?? '').trim() === '' ? '聊一场' : String(chat.card_name)),
1085
1134
  [VIEWS.card]: activeCard?.data?.name ?? '这张卡',
@@ -1151,7 +1200,7 @@ function LifeCardTab(props) {
1151
1200
  })
1152
1201
  }
1153
1202
  // 门还没选:先问「你要用哪个」。选完才进首页,首页再按门决定露出什么。
1154
- if (view === VIEWS.doors) return h(DoorsView, { state, onPick: pickDoor })
1203
+ if (view === VIEWS.doors) return h(DoorsView, { state, features, onPick: pickDoor })
1155
1204
  if (view === VIEWS.tavernChat) {
1156
1205
  return h(TavernView, {
1157
1206
  chat,
@@ -1184,7 +1233,12 @@ function LifeCardTab(props) {
1184
1233
  return h(InterviewView, {
1185
1234
  state, listing, preset: interviewFor, onRecorded: refresh, flash, mode,
1186
1235
  onDone: () => { setInterviewFor(null); setMaterialFor(null); setView(VIEWS.home); refresh() },
1187
- onAnalyze: (about) => { setInterviewFor(about ?? null); setView(VIEWS.analyze) },
1236
+ onAnalyze: (about) => {
1237
+ const list = Array.isArray(about?.materials) ? about.materials.filter((x) => typeof x === 'string' && x !== '') : []
1238
+ setMaterialFor(list.length > 0 ? list : null)
1239
+ setInterviewFor(about ?? null)
1240
+ setView(VIEWS.analyze)
1241
+ },
1188
1242
  })
1189
1243
  case VIEWS.memory:
1190
1244
  // 提取完直接跳到「让 AI 读」,省掉用户自己再想「我刚存的材料去哪了」。
@@ -1206,6 +1260,14 @@ function LifeCardTab(props) {
1206
1260
  onProposal: showProposal,
1207
1261
  onBack: () => { setInterviewFor(null); setMaterialFor(null); setView(VIEWS.home) },
1208
1262
  onNeedMaterial: () => setView(VIEWS.import),
1263
+ // 一键建成卡之后直接把人带到那张卡上:建完就站在门口看着,
1264
+ // 不知道它在哪儿、还得自己回列表找,等于没建。
1265
+ onAppliedCard: async (path) => {
1266
+ const hit = (listing?.cards ?? []).find((c) => c.path === path)
1267
+ await refresh(state?.workspace)
1268
+ if (hit !== undefined) await openCard(hit)
1269
+ else setView(VIEWS.home)
1270
+ },
1209
1271
  })
1210
1272
  case VIEWS.card:
1211
1273
  // 首页那条「还等着你看的建议」点进来时,如果这个人还没有卡(很正常:
@@ -1213,7 +1275,7 @@ function LifeCardTab(props) {
1213
1275
  // 直接退回首页,用户点了没反应,还以为坏了。现在把清单单独摊开。
1214
1276
  return activeCard === null
1215
1277
  ? proposal === null
1216
- ? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard })
1278
+ ? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard, books, onOpenBook: openBook, onCreateBook: createBook })
1217
1279
  : h('div', { className: 'lc-pad lc-stack' },
1218
1280
  proposal.about === undefined || String(proposal.about).trim() === ''
1219
1281
  ? null
@@ -1272,18 +1334,32 @@ function LifeCardTab(props) {
1272
1334
  })
1273
1335
  case VIEWS.world:
1274
1336
  return activeWorld === null
1275
- ? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard })
1337
+ ? h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard, books, onOpenBook: openBook, onCreateBook: createBook })
1276
1338
  : h(WorldView, {
1277
1339
  world: activeWorld,
1278
1340
  listing,
1279
1341
  onOpenCard: openCard,
1280
1342
  })
1343
+ case VIEWS.game:
1344
+ return h(GameView, {
1345
+ bookId: String(activeBook?.id ?? ''),
1346
+ flash,
1347
+ onBack: () => { setActiveBook(null); setView(VIEWS.novel) },
1348
+ })
1349
+ case VIEWS.book:
1350
+ return h(BookView, {
1351
+ bookId: String(activeBook?.id ?? ''),
1352
+ flash,
1353
+ onBack: () => { setActiveBook(null); setView(VIEWS.novel) },
1354
+ })
1281
1355
  case VIEWS.doctor:
1282
1356
  return h(DoctorView, { report: doctor, onOpenCard: openCard, onRun: () => runDoctor(true) })
1357
+ case VIEWS.career:
1358
+ return h(CareerView, { flash, onBack: () => setView(VIEWS.home) })
1283
1359
  case VIEWS.jobs:
1284
1360
  return h(JobsView, { onBack: () => setView(VIEWS.home), flash })
1285
1361
  default:
1286
- return h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard })
1362
+ return h(HomeView, { state, listing, mode, onOpen: openFromList, chats, onOpenChat: openChat, onOpenWorld: openWorld, onNavigate: go, onMore: () => setShowMore(!showMore), showMore, onProposal: openProposal, busy, intro: introSeen === false, onIntroDone: dismissIntro, onMaterial: (m) => { setMaterialFor(m.path); setView(VIEWS.analyze) }, onDeleteMaterial: deleteMaterial, onDoDoctor: () => runDoctor(), onForkCard: forkForTavern, onDeleteCard: deleteCard, books, onOpenBook: openBook, onCreateBook: createBook })
1287
1363
  }
1288
1364
  }
1289
1365
 
@@ -1396,15 +1472,37 @@ const DOORS = [
1396
1472
  },
1397
1473
  ]
1398
1474
 
1399
- function DoorsView({ onPick, state }) {
1475
+ /**
1476
+ * 进门那一屏:三扇**立着的门**。
1477
+ *
1478
+ * 为什么做成门的样子而不是三张卡片:卡片是「选一个设置」,门是「走进去」。
1479
+ * 这一屏是这个插件唯一的分岔口,用户在这儿做的决定(记真人 / 写小说 / 演)
1480
+ * 会决定后面所有屏长什么样,值得给一个够重的画面。
1481
+ *
1482
+ * 门该有的东西:门框、门楣、门牌、把手、门槛。这里都做了——
1483
+ * 顶上一条门楣色块、中间竖着写门名、底部一道门槛写着「推门进去」。
1484
+ * 三扇并排、各占三分之一宽、高度撑满;窄窗口自动换行。
1485
+ */
1486
+ function DoorsView({ onPick, state, features }) {
1400
1487
  const workspace = String(state?.workspace ?? '')
1401
- // 三扇**立着的门**并排站满整屏:每一块都是一个竖长的门框,
1402
- // 高度撑到整屏、宽度三等分。不是三个横躺的卡片叠着——那样看着像
1403
- // 一列设置项,不像「推门进去」。
1488
+ // 没装的门不摆出来。`features === null` 时全摆(还没问到,别闪一下少一扇)。
1489
+ const doors = (features === null || features === undefined)
1490
+ ? DOORS
1491
+ : DOORS.filter((d) => (d.id === 'real' ? true : d.id === 'novel' ? features.novel !== false : features.tavern !== false))
1404
1492
  const tint = {
1405
- real: { fg: 'var(--lc-interview)', bg: 'var(--lc-interview-soft)', icon: 'people' },
1406
- novel: { fg: 'var(--lc-import)', bg: 'var(--lc-import-soft)', icon: 'book' },
1407
- tavern: { fg: 'var(--lc-analyze)', bg: 'var(--lc-analyze-soft)', icon: 'chat' },
1493
+ real: {
1494
+ fg: 'var(--lc-interview)', bg: 'var(--lc-interview-soft)', icon: 'people',
1495
+ // 门里那点光:不一样的暖冷,一眼能分出来
1496
+ glow: 'rgba(156, 123, 245, .18)',
1497
+ },
1498
+ novel: {
1499
+ fg: 'var(--lc-import)', bg: 'var(--lc-import-soft)', icon: 'book',
1500
+ glow: 'rgba(59, 201, 219, .18)',
1501
+ },
1502
+ tavern: {
1503
+ fg: 'var(--lc-analyze)', bg: 'var(--lc-analyze-soft)', icon: 'chat',
1504
+ glow: 'rgba(232, 185, 59, .18)',
1505
+ },
1408
1506
  }
1409
1507
  return h('div', {
1410
1508
  style: {
@@ -1412,18 +1510,18 @@ function DoorsView({ onPick, state }) {
1412
1510
  minHeight: '100%', boxSizing: 'border-box', padding: 12,
1413
1511
  },
1414
1512
  },
1415
- h('div', { style: { flex: '0 0 auto', paddingBottom: 10 } },
1416
- h('h2', { style: { margin: '2px 0 0', fontSize: 22 } }, '你要用哪个?'),
1417
- h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '推一扇门进去。随时能换——卡和世界书都在同一个文件夹里。'),
1513
+ h('div', { style: { flex: '0 0 auto', paddingBottom: 12 } },
1514
+ h('h2', { style: { margin: '2px 0 0', fontSize: 22, letterSpacing: '-.01em' } }, '你要用哪个?'),
1515
+ h('div', { className: 'lc-meta', style: { marginTop: 5 } }, '推一扇门进去。随时能换——卡和世界书都在同一个文件夹里。'),
1418
1516
  ),
1419
- // 三扇门横向并排;窄窗口时自动换行(flexWrap),免得挤成三条缝
1517
+ // 三扇门横向并排;窄窗口自动换行(flexWrap),免得挤成三条缝
1420
1518
  h('div', {
1421
1519
  style: {
1422
1520
  flex: '1 1 auto', display: 'flex', gap: 10,
1423
- alignItems: 'stretch', flexWrap: 'wrap', minHeight: 260,
1521
+ alignItems: 'stretch', flexWrap: 'wrap', minHeight: 280,
1424
1522
  },
1425
1523
  },
1426
- DOORS.map((door) => {
1524
+ doors.map((door) => {
1427
1525
  const t = tint[door.id] ?? tint.real
1428
1526
  return h('button', {
1429
1527
  key: door.id,
@@ -1432,50 +1530,84 @@ function DoorsView({ onPick, state }) {
1432
1530
  onClick: () => onPick(door.id),
1433
1531
  style: {
1434
1532
  // 每扇门平分宽度、各自撑满整高(门是竖的,高度要顶天立地)
1435
- flex: '1 1 30%', minWidth: 150, minHeight: 240,
1436
- display: 'flex', flexDirection: 'column', alignItems: 'center',
1437
- justifyContent: 'flex-start', gap: 10,
1438
- padding: '18px 12px', textAlign: 'center', cursor: 'pointer',
1533
+ flex: '1 1 30%', minWidth: 156, minHeight: 260,
1534
+ display: 'flex', flexDirection: 'column', alignItems: 'stretch',
1535
+ gap: 0, padding: 0, textAlign: 'center', cursor: 'pointer',
1439
1536
  border: '1px solid var(--lc-line)', borderRadius: 16,
1440
- background: 'var(--lc-panel)', boxShadow: 'var(--lc-shadow)',
1441
- font: 'inherit', color: 'inherit',
1537
+ // 门里透出来的光:上深下浅,像门后头有灯
1538
+ background: `linear-gradient(180deg, ${t.glow} 0%, var(--lc-panel) 55%, var(--lc-panel) 100%)`,
1539
+ boxShadow: 'var(--lc-shadow)', font: 'inherit', color: 'inherit',
1540
+ overflow: 'hidden', position: 'relative',
1442
1541
  },
1443
1542
  },
1444
- // 门顶上的那块色:像一个门楣
1543
+ // ── 门楣:顶上那条色带 ──
1445
1544
  h('div', {
1446
1545
  style: {
1447
- flex: '0 0 auto', width: 52, height: 52, borderRadius: 14,
1448
- display: 'flex', alignItems: 'center', justifyContent: 'center',
1449
- background: t.bg, color: t.fg, marginTop: 4,
1546
+ flex: '0 0 auto', height: 6, width: '100%',
1547
+ background: t.fg, opacity: .85,
1548
+ },
1549
+ }),
1550
+ // ── 门牌:图标 + 门名 ──
1551
+ h('div', {
1552
+ style: {
1553
+ flex: '0 0 auto', display: 'flex', flexDirection: 'column',
1554
+ alignItems: 'center', gap: 8, padding: '16px 12px 10px',
1555
+ },
1556
+ },
1557
+ h('div', {
1558
+ style: {
1559
+ width: 50, height: 50, borderRadius: 14,
1560
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1561
+ background: t.bg, color: t.fg,
1562
+ border: `1px solid ${t.fg}`, boxSizing: 'border-box',
1563
+ },
1564
+ }, h(Icon, { name: t.icon, size: 25 })),
1565
+ h('div', { style: { fontSize: 16.5, fontWeight: 700, lineHeight: 1.3 } }, door.title),
1566
+ h('div', { className: 'lc-meta', style: { lineHeight: 1.6, padding: '0 2px' } }, door.desc),
1567
+ ),
1568
+ // ── 门里有什么 ──
1569
+ h('div', {
1570
+ style: {
1571
+ flex: '1 1 auto', display: 'flex', flexDirection: 'column',
1572
+ gap: 4, padding: '0 12px 10px', textAlign: 'left', justifyContent: 'center',
1450
1573
  },
1451
- }, h(Icon, { name: t.icon, size: 26 })),
1452
- h('div', { style: { fontSize: 17.5, fontWeight: 700, lineHeight: 1.35 } }, door.title),
1453
- h('div', { className: 'lc-meta', style: { lineHeight: 1.6 } }, door.desc),
1454
- h('div', { style: { flex: '1 1 auto' } }),
1455
- (door.bullets ?? []).length === 0 ? null : h('div', {
1456
- style: { display: 'flex', flexDirection: 'column', gap: 3, textAlign: 'left', width: '100%' },
1457
1574
  },
1458
- door.bullets.map((line, i) => h('div', {
1575
+ (door.bullets ?? []).map((line, i) => h('div', {
1459
1576
  key: i,
1460
1577
  className: 'lc-meta',
1461
- style: { display: 'flex', gap: 5, lineHeight: 1.5 },
1462
- }, h('span', { style: { color: t.fg, flex: '0 0 auto' } }, '·'), h('span', null, line)))),
1463
- // 门底那道横杠:推门的那一下
1578
+ style: { display: 'flex', gap: 6, lineHeight: 1.55, alignItems: 'flex-start' },
1579
+ },
1580
+ h('span', { style: { color: t.fg, flex: '0 0 auto', fontWeight: 700 } }, '·'),
1581
+ h('span', null, line),
1582
+ )),
1583
+ ),
1584
+ // ── 门槛:底部那条,写着推门 ──
1464
1585
  h('div', {
1465
1586
  style: {
1466
- flex: '0 0 auto', marginTop: 4, paddingTop: 10, width: '100%',
1587
+ flex: '0 0 auto', marginTop: 'auto',
1467
1588
  borderTop: '1px solid var(--lc-line)',
1468
- color: t.fg, fontWeight: 700, fontSize: 15,
1589
+ padding: '10px 8px', width: '100%', boxSizing: 'border-box',
1590
+ color: t.fg, fontWeight: 700, fontSize: 13.5,
1591
+ background: t.bg,
1469
1592
  },
1470
1593
  }, '推门进去 →'),
1594
+ // ── 把手:右下角一个小圆点,门的那个意思 ──
1595
+ h('div', {
1596
+ 'aria-hidden': 'true',
1597
+ style: {
1598
+ position: 'absolute', right: 10, bottom: 52,
1599
+ width: 7, height: 7, borderRadius: 999,
1600
+ background: t.fg, opacity: .55,
1601
+ },
1602
+ }),
1471
1603
  )
1472
1604
  }),
1473
1605
  ),
1474
- workspace === '' ? null : h('div', { className: 'lc-meta', style: { flex: '0 0 auto', paddingTop: 10 } }, `档案在:${workspace}`),
1606
+ workspace === '' ? null : h('div', { className: 'lc-meta', style: { flex: '0 0 auto', paddingTop: 10 } }, '档案就在你选的那个文件夹里'),
1475
1607
  )
1476
1608
  }
1477
1609
 
1478
- function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onProposal, busy, onDoDoctor, showMore, onMore, intro, onIntroDone, onMaterial, onDeleteMaterial, chats, onOpenChat, onForkCard, onDeleteCard }) {
1610
+ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onProposal, busy, onDoDoctor, showMore, onMore, intro, onIntroDone, onMaterial, onDeleteMaterial, chats, onOpenChat, onForkCard, onDeleteCard, books, onOpenBook, onCreateBook }) {
1479
1611
  // 这扇门是不是「真实人物记录」。写小说、酒馆那两扇门里不该出现
1480
1612
  // 「贴一份招聘说明」「从记忆里认人」这类动作——那是记录真人才用得上的。
1481
1613
  const isReal = mode !== 'novel' && mode !== 'tavern'
@@ -1493,6 +1625,17 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1493
1625
  // 用户每次回首页都以为自己还有事没做。
1494
1626
  const allProposals = listing?.proposals ?? []
1495
1627
  const proposals = allProposals.filter((p) => (p.adopted ?? 0) < (p.accepted ?? 0))
1628
+ // 「还等着你看的建议」只在**它属于的那扇门**里出现。
1629
+ //
1630
+ // 以前这是个跨屏提醒:在酒馆里写着小说,头顶一直挂着「关于『妈妈』的建议」;
1631
+ // 记录真人的时候又挂着小说角色的建议。建议本身没错,错在它不属于这一屏。
1632
+ // 判据是这条建议说的是谁——那个人有卡就按卡的 kind,没卡(还没建卡)算现实。
1633
+ const pendingProposals = proposals.filter((p) => {
1634
+ const name = String(p.about ?? '').trim()
1635
+ const hit = name === '' ? undefined : cards.find((c) => String(c.id ?? '') === name || String(c.name ?? '').trim() === name)
1636
+ const isFiction = hit !== undefined && hit.kind === 'fictional'
1637
+ return isFiction === (mode !== 'real')
1638
+ })
1496
1639
  const materials = listing?.materials ?? []
1497
1640
  // 搜索也要筛材料:搜一个人的名字,上面卡筛掉了、下面材料还整整齐齐列着,
1498
1641
  // 看着像「搜到了这一份」,其实根本没筛。
@@ -1612,6 +1755,9 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1612
1755
  isReal
1613
1756
  ? h(Btn, { tone: 'primary', size: 'sm', icon: 'briefcase', onClick: () => onNavigate(VIEWS.jobs), title: '贴一份招聘说明,看看该不该投;或者练一轮面试' }, '下一步')
1614
1757
  : null,
1758
+ isReal
1759
+ ? h(Btn, { size: 'sm', icon: 'book', onClick: () => onNavigate(VIEWS.career), title: '把自己的情况写清楚,让 AI 帮你看简历、算差距、规划怎么走' }, '简历与规划')
1760
+ : null,
1615
1761
  h('div', { className: 'lc-grow' }),
1616
1762
  // 「贴一段文章让 AI 读」不是只有记录真人才用得上:写小说、酒馆里
1617
1763
  // 手里有一段人物设定/小说片段,一样想让它读成人设。所以三扇门都给。
@@ -1620,6 +1766,67 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1620
1766
  : null,
1621
1767
  ),
1622
1768
 
1769
+ // 一眼看到「这里有多少东西」:以前首页只有标题和一排按钮,一大片空。
1770
+ // 数字本身就是内容,也让人知道还有多少没用起来。
1771
+ h('div', { className: 'lc-sec', style: { paddingTop: 2 } },
1772
+ h('div', { className: 'lc-row', style: { flexWrap: 'wrap', gap: 14 } },
1773
+ h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
1774
+ h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String(cards.length)),
1775
+ h('div', { className: 'lc-meta' }, isReal ? '认识的人' : '角色'),
1776
+ ),
1777
+ h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
1778
+ h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String(materials.length)),
1779
+ h('div', { className: 'lc-meta' }, '材料'),
1780
+ ),
1781
+ h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
1782
+ h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String(pendingProposals.length)),
1783
+ h('div', { className: 'lc-meta' }, '待你看'),
1784
+ ),
1785
+ mode === 'novel'
1786
+ ? h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
1787
+ h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String((books ?? []).length)),
1788
+ h('div', { className: 'lc-meta' }, '书'),
1789
+ )
1790
+ : null,
1791
+ mode === 'tavern'
1792
+ ? h('div', { className: 'lc-stack', style: { gap: 1, minWidth: 62 } },
1793
+ h('div', { style: { fontSize: 22, fontWeight: 700, lineHeight: 1.2 } }, String((chats ?? []).length)),
1794
+ h('div', { className: 'lc-meta' }, '聊过的场'),
1795
+ )
1796
+ : null,
1797
+ ),
1798
+ ),
1799
+
1800
+ // 写小说那扇门:先列书架。没有它,用户建了一本书再回来就找不到了。
1801
+ mode === 'novel'
1802
+ ? h('div', null,
1803
+ h(GroupTitle, { tone: 'import', icon: 'book' }, `我的书 · ${(books ?? []).length}`),
1804
+ (books ?? []).length === 0
1805
+ ? h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } },
1806
+ '还没有书。新建一本,让 AI 先想大纲,再一章一章写。')
1807
+ : h('div', { className: 'lc-stack' }, (books ?? []).map((b) => h('button', {
1808
+ key: b.id,
1809
+ className: 'lc-card-item',
1810
+ type: 'button',
1811
+ onClick: () => onOpenBook?.(b),
1812
+ style: { textAlign: 'left', font: 'inherit', color: 'inherit', width: '100%' },
1813
+ },
1814
+ h('div', { className: 'lc-avatar', style: { background: 'var(--lc-import-soft)', color: 'var(--lc-import)' } }, h(Icon, { name: 'book', size: 14 })),
1815
+ h('div', { className: 'lc-grow', style: { minWidth: 0 } },
1816
+ h('div', { className: 'lc-ellipsis', style: { fontWeight: 650 } }, String(b.title ?? b.id)),
1817
+ h('div', { className: 'lc-meta' },
1818
+ [`${b.chapters ?? 0} 章`, `${Math.max(1, Math.round((b.words ?? 0) / 1000))} 千字`]
1819
+ .concat(String(b.genre ?? '').trim() === '' ? [] : [String(b.genre)])
1820
+ .join(' · ')),
1821
+ String(b.idea ?? '').trim() === '' ? null : h('div', { className: 'lc-meta lc-ellipsis' }, String(b.idea)),
1822
+ ),
1823
+ ))),
1824
+ h('div', { className: 'lc-row', style: { marginTop: 8 } },
1825
+ h(Btn, { size: 'sm', tone: 'import', icon: 'plus', onClick: () => onCreateBook?.() }, '新建一本书'),
1826
+ ),
1827
+ )
1828
+ : null,
1829
+
1623
1830
  // 酒馆门里先列「说过的场次」:这是回来的路。没有它,用户退出一场
1624
1831
  // 就再也找不回来——只能重新开一场,前面那些话看着像丢了。
1625
1832
  chatRows.length > 0
@@ -1724,10 +1931,16 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1724
1931
  h('div', { className: 'lc-stack' }, worlds.map((w) => h(WorldRow, { key: w.path, world: w, onOpen: onOpenWorld, photo: avatars[w.id] }))),
1725
1932
  ),
1726
1933
 
1727
- proposals.length > 0
1934
+ // 「还等着你看的建议」只在**它属于的那扇门**里出现。
1935
+ //
1936
+ // 以前这是个跨屏提醒:在酒馆里写着小说,头顶一直挂着「关于『妈妈』的建议」;
1937
+ // 记录真人的时候又挂着小说角色的建议。建议本身没错,错在它不属于这一屏。
1938
+ // 判据是:这条建议说的是谁——那个人有卡就按卡的 kind(虚构/现实),
1939
+ // 没卡就按「还没建卡」算现实(绝大多数情况是给人建卡时读出来的)。
1940
+ pendingProposals.length > 0
1728
1941
  ? h('div', null,
1729
- h(GroupTitle, { tone: 'analyze', icon: 'spark' }, `还等着你看的建议 · ${proposals.length}`),
1730
- h('div', { className: 'lc-stack' }, proposals.slice(0, 6).map((p) => h('button', {
1942
+ h(GroupTitle, { tone: 'analyze', icon: 'spark' }, `还等着你看的建议 · ${pendingProposals.length}`),
1943
+ h('div', { className: 'lc-stack' }, pendingProposals.slice(0, 6).map((p) => h('button', {
1731
1944
  key: p.json, className: 'lc-card-item', type: 'button',
1732
1945
  onClick: () => onProposal(p.json),
1733
1946
  },
@@ -1743,8 +1956,8 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1743
1956
  ))),
1744
1957
  // 一次只摆 6 份。不说明的话,第 7 份起就永远看不见——
1745
1958
  // 用户会以为那些建议丢了。
1746
- proposals.length > 6
1747
- ? h('div', { className: 'lc-meta' }, `还有 ${proposals.length - 6} 份没列出来。处理掉上面任意一份,它就会顶上来。`)
1959
+ pendingProposals.length > 6
1960
+ ? h('div', { className: 'lc-meta' }, `还有 ${pendingProposals.length - 6} 份没列出来。处理掉上面任意一份,它就会顶上来。`)
1748
1961
  : null,
1749
1962
  )
1750
1963
  : null,
@@ -1775,7 +1988,7 @@ function HomeView({ state, listing, mode, onOpen, onOpenWorld, onNavigate, onPro
1775
1988
  h(Btn, { size: 'sm', ghost: true, icon: 'folder', onClick: () => onNavigate(VIEWS.setup) }, '换个文件夹'),
1776
1989
  // 路径照实写出来:这是唯一一处「档案到底在哪儿」的线索。
1777
1990
  // 藏起来,选错文件夹的人就永远找不回去了。
1778
- h('span', { className: 'lc-meta lc-ellipsis' }, state?.workspace === undefined || state?.workspace === '' ? '' : `档案在:${state.workspace}`),
1991
+ h('span', { className: 'lc-meta lc-ellipsis' }, state?.workspace === undefined || state?.workspace === '' ? '' : '档案就在你选的那个文件夹里'),
1779
1992
  ),
1780
1993
  )
1781
1994
  : null,
@@ -1951,7 +2164,667 @@ function WorldView({ world, listing, onOpenCard }) {
1951
2164
  * 不能因为这个丢掉;
1952
2165
  * 3. 退出就是退出,不弹「确定放弃吗」——每一轮都已经在文件里了。
1953
2166
  */
1954
- function TavernView({ chat, onChat, onBack }) {
2167
+ /**
2168
+ * 一本书:大纲 → 一章一章写 → 审。
2169
+ *
2170
+ * 这一屏是「写小说」那扇门真正干活的地方。三段对应三件事:
2171
+ * 1. **先想清楚**:让 AI 把整本书的骨架搭出来,落成一份大纲;人能直接改。
2172
+ * 2. **写这一章**:拿大纲里这一章那一句 + 上一章结尾 + 设定,让它写正文。
2173
+ * 3. **挑毛病**:写完让它只挑问题、不夸,人照着改。
2174
+ *
2175
+ * 刻意不做的事:
2176
+ * - 不自动一章接一章往下写。写完一章就停下来等人看——一次生成十章的书
2177
+ * 没人会读,也没人改得动。
2178
+ * - 不问「要不要继续」。按钮在那儿,要不要点是人自己的事。
2179
+ */
2180
+ /**
2181
+ * 预设选择条:三种玩法共用。
2182
+ *
2183
+ * 为什么做成一行小按钮而不是下拉框:这些名字(慢热 / 直球 / 冷峻白描 /
2184
+ * 查一件事)本身就说明了效果,扫一眼就能选。下拉框要点两下才看见有什么。
2185
+ */
2186
+ function PresetBar({ mode, value, onChange, onManage }) {
2187
+ const [groups, setGroups] = useState(null)
2188
+ const [err, setErr] = useState(null)
2189
+ useEffect(() => {
2190
+ let gone = false
2191
+ call('preset.list', {})
2192
+ .then((r) => { if (!gone && r?.ok === true) setGroups(Array.isArray(r.groups) ? r.groups : []) })
2193
+ .catch((e) => { if (!gone) setErr(humanizeError(e)) })
2194
+ return () => { gone = true }
2195
+ }, [])
2196
+ if (groups === null) return null
2197
+ const mine = groups.find((g) => String(g.mode) === mode)
2198
+ const items = mine === undefined ? [] : (mine.items ?? [])
2199
+ if (items.length === 0) return null
2200
+ return h('div', { className: 'lc-stack', style: { gap: 6 } },
2201
+ h('div', { className: 'lc-row' },
2202
+ h('div', { className: 'lc-grow lc-meta' }, '演法 / 写法(选一个,能不选)'),
2203
+ onManage === undefined ? null : h(Btn, { size: 'sm', ghost: true, icon: 'edit', onClick: onManage }, '管理'),
2204
+ ),
2205
+ h('div', { className: 'lc-row', style: { flexWrap: 'wrap', gap: 6 } },
2206
+ items.map((p) => h(Btn, {
2207
+ key: p.id,
2208
+ size: 'sm',
2209
+ tone: value === p.id ? 'primary' : undefined,
2210
+ onClick: () => onChange(value === p.id ? '' : p.id),
2211
+ title: String(p.desc ?? ''),
2212
+ }, String(p.name ?? p.id))),
2213
+ ),
2214
+ err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
2215
+ )
2216
+ }
2217
+
2218
+ /**
2219
+ * 文字小说游戏:进到一本书的某一段情节里,你来选,它来推。
2220
+ *
2221
+ * 和「写小说」摆在一起但不是一个东西:那边是你在写,这边是你在里面。
2222
+ * 所以这一屏的重点是**局面清楚**——现在在哪、手上有什么、这一步要付什么代价。
2223
+ */
2224
+ function GameView({ bookId, onBack, flash }) {
2225
+ const [play, setPlay] = useState(null)
2226
+ const [move, setMove] = useState('')
2227
+ const [busy, setBusy] = useState('')
2228
+ const [err, setErr] = useState(null)
2229
+ const [preset, setPreset] = useState('')
2230
+ const [chapter, setChapter] = useState(0)
2231
+ const [chapterList, setChapterList] = useState([])
2232
+ // 「把这一局写成小说的一章」。这几个 state 放在所有 hook 之后:
2233
+ // 上面那几个槽位有测试按号码摆状态,插在中间会全体错位。
2234
+ const [toOpen, setToOpen] = useState(false)
2235
+ const [toNote, setToNote] = useState('')
2236
+ const [toBusy, setToBusy] = useState(false)
2237
+ // 以前这一局退出就找不回来了,而界面还写着「退出再进来还能接着走」。
2238
+ // 存档后台一直都有(game.list),缺的就是这半截入口。
2239
+ const [plays, setPlays] = useState([])
2240
+
2241
+ const loadPlays = async () => {
2242
+ try {
2243
+ const r = await call('game.list', { book: bookId })
2244
+ if (r?.ok === true) setPlays(Array.isArray(r.items) ? r.items : [])
2245
+ } catch { /* 列不出来不影响新开一局 */ }
2246
+ }
2247
+
2248
+ const toChapter = async () => {
2249
+ if (play === null) return
2250
+ setToBusy(true); setErr(null)
2251
+ try {
2252
+ const r = await call('game.toChapter', { book: bookId, id: play.id, note: toNote })
2253
+ if (r?.ok !== true) { setErr(r?.error ?? '没写成'); return }
2254
+ setToOpen(false); setToNote('')
2255
+ if (typeof flash === 'function') flash(`写成了第 ${r.chapter ?? '?'} 章`)
2256
+ } catch (e) { setErr(humanizeError(e)) } finally { setToBusy(false) }
2257
+ }
2258
+
2259
+ const open = async (row) => {
2260
+ setErr(null)
2261
+ try {
2262
+ const r = await call('game.get', { book: bookId, id: row.id })
2263
+ if (r?.ok !== true) { setErr(r?.error ?? '这一局打不开'); return }
2264
+ setPlay({ id: row.id, turns: r.turns ?? [], choices: r.choices ?? [], scene: r.scene ?? '', holding: r.holding ?? [], chapter: r.chapter })
2265
+ } catch (e) { setErr(humanizeError(e)) }
2266
+ }
2267
+
2268
+ useEffect(() => {
2269
+ let gone = false
2270
+ loadPlays()
2271
+ call('novel.get', { id: bookId })
2272
+ .then((r) => { if (!gone && r?.ok === true) setChapterList(Array.isArray(r.chapters) ? r.chapters : []) })
2273
+ .catch(() => {})
2274
+ return () => { gone = true }
2275
+ }, [bookId])
2276
+
2277
+ const start = async () => {
2278
+ setBusy('start'); setErr(null)
2279
+ try {
2280
+ const r = await call('game.start', { book: bookId, chapter, preset })
2281
+ if (r?.ok !== true) { setErr(r?.error ?? '这一局开不起来'); return }
2282
+ setPlay({ id: r.id, turns: r.turns ?? [], choices: r.choices ?? [], scene: '', holding: [], chapter: r.chapter })
2283
+ flash?.('开局了')
2284
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2285
+ }
2286
+
2287
+ const step = async (text) => {
2288
+ const said = String(text ?? '').trim()
2289
+ if (said === '' || play === null) return
2290
+ setBusy('step'); setErr(null)
2291
+ try {
2292
+ const r = await call('game.say', { book: bookId, id: play.id, move: said })
2293
+ if (Array.isArray(r?.turns)) setPlay({ ...play, turns: r.turns, choices: r.choices ?? [] })
2294
+ if (r?.ok !== true) { setErr(r?.error ?? '这一步没成'); return }
2295
+ setMove('')
2296
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2297
+ }
2298
+
2299
+ if (play === null) {
2300
+ return h('div', { className: 'lc-pad lc-stack' },
2301
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
2302
+ h('h3', { style: { margin: '4px 0 0' } }, '挑一个地方进去'),
2303
+ h('div', { className: 'lc-meta' }, '从哪一章接着演都行。没有正文也行,它会自己起头。'),
2304
+ h('div', { className: 'lc-row', style: { flexWrap: 'wrap', gap: 6 } },
2305
+ h(Btn, { size: 'sm', tone: chapter === 0 ? 'primary' : undefined, onClick: () => setChapter(0) }, '从头开始'),
2306
+ chapterList.map((c) => h(Btn, {
2307
+ key: c.index, size: 'sm', tone: chapter === c.index ? 'primary' : undefined,
2308
+ onClick: () => setChapter(c.index),
2309
+ }, `第 ${c.index} 章`)),
2310
+ ),
2311
+ h(PresetBar, { mode: 'game', value: preset, onChange: setPreset }),
2312
+ h(Btn, { tone: 'primary', block: true, icon: 'spark', disabled: busy !== '', onClick: start },
2313
+ busy === 'start' ? '正在开局…' : '进这一段'),
2314
+ plays.length === 0
2315
+ ? null
2316
+ : h('div', { className: 'lc-stack', style: { gap: 6, marginTop: 10 } },
2317
+ h('div', { className: 'lc-fhint' }, `之前玩到一半的 · ${plays.length}`),
2318
+ plays.map((row) => h(Btn, {
2319
+ key: row.id, block: true, size: 'sm', icon: 'history',
2320
+ onClick: () => open(row),
2321
+ }, `${String(row.scene ?? '').trim() === '' ? '那一局' : String(row.scene)} · 走了 ${Number(row.turns ?? 0)} 步`)),
2322
+ ),
2323
+ err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
2324
+ )
2325
+ }
2326
+
2327
+ const narrations = (play.turns ?? []).filter((t) => t.kind === 'narrate' || t.kind === 'choose')
2328
+ return h('div', { className: 'lc-pad lc-stack', style: { gap: 10 } },
2329
+ h('div', { className: 'lc-row' },
2330
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '这一局先放着'),
2331
+ h('div', { className: 'lc-grow lc-meta' },
2332
+ [play.chapter > 0 ? `从第 ${play.chapter} 章进来的` : '从头开的',
2333
+ `已经走了 ${(play.turns ?? []).filter((t) => t.kind === 'choose').length} 步`].join(' · ')),
2334
+ h(Btn, {
2335
+ size: 'sm', icon: 'book',
2336
+ disabled: busy !== '' || (play.turns ?? []).length === 0,
2337
+ onClick: () => setToOpen(true),
2338
+ }, '写成小说的一章'),
2339
+ ),
2340
+ toOpen === true
2341
+ ? h('div', { className: 'lc-sec lc-stack' },
2342
+ h('div', { className: 'lc-fhint' }, '这一局会改写成《' + bookId + '》的下一章'),
2343
+ h('input', {
2344
+ className: 'lc-input', value: toNote, spellCheck: false,
2345
+ placeholder: '这一章要往哪走(可以不写)',
2346
+ onChange: (e) => setToNote(e.target.value),
2347
+ }),
2348
+ h('div', { className: 'lc-row' },
2349
+ h(Btn, { tone: 'primary', disabled: toBusy, onClick: toChapter }, toBusy ? '写着…' : '写成这一章'),
2350
+ h(Btn, { ghost: true, disabled: toBusy, onClick: () => setToOpen(false) }, '先不变'),
2351
+ ),
2352
+ )
2353
+ : null,
2354
+ String(play.scene ?? '').trim() === '' ? null : h(Note, { icon: 'book' }, play.scene),
2355
+ Array.isArray(play.holding) && play.holding.length > 0
2356
+ ? h('div', { className: 'lc-meta' }, `手上的:${play.holding.join('、')}`)
2357
+ : null,
2358
+ h('div', { className: 'lc-stack', style: { gap: 10 } },
2359
+ narrations.map((t, i) => h('div', {
2360
+ key: i,
2361
+ style: {
2362
+ whiteSpace: 'pre-wrap', lineHeight: 1.85, fontSize: 14.5,
2363
+ padding: '10px 12px', borderRadius: 10,
2364
+ background: t.kind === 'choose' ? 'var(--lc-accent-soft)' : 'var(--lc-panel)',
2365
+ border: `1px solid ${t.kind === 'choose' ? 'var(--lc-accent)' : 'var(--lc-line)'}`,
2366
+ color: t.kind === 'choose' ? 'var(--lc-accent)' : undefined,
2367
+ },
2368
+ }, t.kind === 'choose' ? `我:${t.text}${String(t.result ?? '') === '' ? '' : `\n\n${t.result}`}` : String(t.text ?? ''))),
2369
+ ),
2370
+ (play.choices ?? []).length > 0
2371
+ ? h('div', { className: 'lc-stack', style: { gap: 6 } },
2372
+ h('div', { className: 'lc-meta' }, '这一步可以走:'),
2373
+ (play.choices ?? []).map((c, i) => h(Btn, {
2374
+ key: i, block: true, disabled: busy !== '', onClick: () => step(c.text),
2375
+ }, `${c.label}. ${c.text}`)),
2376
+ )
2377
+ : null,
2378
+ h('div', { className: 'lc-row' },
2379
+ h('input', {
2380
+ className: 'lc-input', value: move, spellCheck: false,
2381
+ placeholder: '或者自己写一句这一步怎么做',
2382
+ onChange: (e) => setMove(e.target.value),
2383
+ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); step(move) } },
2384
+ }),
2385
+ h(Btn, { tone: 'primary', disabled: busy !== '' || move.trim() === '', onClick: () => step(move) },
2386
+ busy === 'step' ? '等一下…' : '走这一步'),
2387
+ ),
2388
+ err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
2389
+ h('div', { className: 'lc-meta' }, '每一步都会存下来,退出再进来还能接着走。'),
2390
+ )
2391
+ }
2392
+
2393
+ /**
2394
+ * 简历与规划。放在「真实人物记录」那扇门里(写小说和酒馆用不上)。
2395
+ *
2396
+ * 为什么分成「先把自己写清楚」和「再让 AI 帮忙」两步:
2397
+ * 简历这东西最怕 AI 编。先把真实情况填进去,AI 只做整理和措辞,
2398
+ * 不编经历——不然投出去被问一句就露了。
2399
+ */
2400
+ function CareerView({ onBack, flash }) {
2401
+ const [profile, setProfile] = useState(null)
2402
+ const [target, setTarget] = useState('')
2403
+ const [goal, setGoal] = useState('')
2404
+ const [horizon, setHorizon] = useState('1年')
2405
+ const [out, setOut] = useState(null)
2406
+ const [busy, setBusy] = useState('')
2407
+ const [err, setErr] = useState(null)
2408
+ const [dirty, setDirty] = useState(false)
2409
+
2410
+ useEffect(() => {
2411
+ let gone = false
2412
+ call('career.get', {})
2413
+ .then((r) => {
2414
+ if (gone) return
2415
+ if (r?.ok !== true) { setErr(r?.error ?? '你的简历这一份读不出来'); return }
2416
+ setProfile(buildCareerForm(r.profileSections ?? {}))
2417
+ setGoal(String(r.plan?.goal ?? ''))
2418
+ setHorizon(String(r.plan?.horizon ?? '1年'))
2419
+ })
2420
+ .catch((e) => { if (!gone) setErr(humanizeError(e)) })
2421
+ return () => { gone = true }
2422
+ }, [])
2423
+
2424
+ const setField = (key, value) => { setProfile({ ...(profile ?? {}), [key]: value }); setDirty(true) }
2425
+
2426
+ const save = async () => {
2427
+ setBusy('save'); setErr(null)
2428
+ try {
2429
+ const r = await call('career.profile.save', { profile: profile ?? {} })
2430
+ if (r?.ok !== true) { setErr(r?.error ?? '没存下来'); return }
2431
+ setDirty(false); flash?.('存好了')
2432
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2433
+ }
2434
+
2435
+ const run = async (kind) => {
2436
+ if (dirty) { setErr('简历改过了,先点「存好」再让它做。'); return }
2437
+ setBusy(kind); setErr(null); setOut(null)
2438
+ try {
2439
+ const r = kind === 'resume'
2440
+ ? await call('career.resume', { target })
2441
+ : kind === 'gap'
2442
+ ? await call('career.gap', { target })
2443
+ : await call('career.path', { goal, horizon })
2444
+ if (r?.ok !== true) { setErr(r?.error ?? '没做成'); return }
2445
+ setOut({ kind, text: String(r.text ?? ''), steps: r.steps ?? [] })
2446
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2447
+ }
2448
+
2449
+ // 先看有没有出错:出错的渲染在下面(要 profile 读出来才轮得到),
2450
+ // 所以这里必须自己先把错说出来,否则用户看到的是一辈子转圈。
2451
+ if (profile === null) {
2452
+ return h('div', { className: 'lc-pad lc-stack' },
2453
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回首页'),
2454
+ err === null
2455
+ ? h(Spinner)
2456
+ : h('div', { className: 'lc-stack' },
2457
+ h(Note, { tone: 'err', icon: 'warning' }, err),
2458
+ h(Btn, { block: true, onClick: () => { setErr(null); call('career.get', {}) } }, '再试一次'),
2459
+ ),
2460
+ )
2461
+ }
2462
+
2463
+ const field = (key, label, rows, placeholder) => h('div', { className: 'lc-stack', style: { gap: 6 } },
2464
+ h('div', { className: 'lc-fhint' }, label),
2465
+ h('textarea', {
2466
+ className: 'lc-textarea', rows, value: String(profile[key] ?? ''), placeholder, spellCheck: false,
2467
+ onChange: (e) => setField(key, e.target.value),
2468
+ }),
2469
+ )
2470
+
2471
+ return h('div', { className: 'lc-pad lc-stack', style: { gap: 12 } },
2472
+ h('div', { className: 'lc-row' },
2473
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回首页'),
2474
+ h('div', { className: 'lc-grow' }),
2475
+ h(Btn, { size: 'sm', tone: dirty ? 'primary' : undefined, icon: 'check', disabled: busy !== '', onClick: save },
2476
+ busy === 'save' ? '存着…' : (dirty ? '存好' : '存好了')),
2477
+ ),
2478
+ h('div', { className: 'lc-meta' }, '先把真事写在这儿。AI 只帮你整理,不会替你编经历。'),
2479
+
2480
+ h('div', { className: 'lc-sec lc-stack' },
2481
+ field('基本情况', '基本情况', 3, '哪个学校、什么专业、几年级、现在在哪'),
2482
+ field('学过什么', '学过什么(一行一门)', 4, '现代汉语|能讲清音韵那套\n写作|能改到能发'),
2483
+ field('做过什么', '做过什么(一行一件)', 4, '校刊编辑|2025 秋|出了两期\n家教|2025 夏|带过一个高三'),
2484
+ field('会用哪些工具', '会用哪些工具(一行一个)', 2, 'Word\n剪映'),
2485
+ field('想去哪', '想去哪', 3, '公司加岗位,或者学校加专业。写具体一点。'),
2486
+ field('还差什么', '你自己觉得还差什么(一行一条)', 3, '六级还没过\n没写过正式的报告'),
2487
+ ),
2488
+
2489
+ h('div', { className: 'lc-sec lc-stack' },
2490
+ h('div', { className: 'lc-fhint' }, '让 AI 帮忙'),
2491
+ h('input', {
2492
+ className: 'lc-input', value: target, spellCheck: false,
2493
+ placeholder: '要投哪儿:公司加岗位,或者学校加专业',
2494
+ onChange: (e) => setTarget(e.target.value),
2495
+ }),
2496
+ h('div', { className: 'lc-row', style: { flexWrap: 'wrap' } },
2497
+ h(Btn, { disabled: busy !== '' || target.trim() === '', onClick: () => run('resume') }, busy === 'resume' ? '写着…' : '照这个写简历'),
2498
+ h(Btn, { disabled: busy !== '' || target.trim() === '', onClick: () => run('gap') }, busy === 'gap' ? '算着…' : '看差在哪'),
2499
+ ),
2500
+ h('div', { className: 'lc-row' },
2501
+ h('input', {
2502
+ className: 'lc-input', value: goal, spellCheck: false,
2503
+ placeholder: '想到哪儿去(一句具体的)',
2504
+ onChange: (e) => setGoal(e.target.value),
2505
+ }),
2506
+ h('select', { className: 'lc-select', value: horizon, onChange: (e) => setHorizon(e.target.value), style: { maxWidth: 110 } },
2507
+ ['3个月', '1年', '4年'].map((x) => h('option', { key: x, value: x }, x))),
2508
+ ),
2509
+ h(Btn, { block: true, disabled: busy !== '' || goal.trim() === '', onClick: () => run('path') },
2510
+ busy === 'path' ? '规划着…' : '规划怎么走'),
2511
+ ),
2512
+
2513
+ err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
2514
+ out === null ? null : h('div', { className: 'lc-sec lc-stack' },
2515
+ h('div', { className: 'lc-row' },
2516
+ h('div', { className: 'lc-grow lc-fhint' },
2517
+ out.kind === 'resume' ? '写好的简历' : out.kind === 'gap' ? '差在哪' : '怎么走'),
2518
+ h(Btn, { size: 'sm', icon: 'download', onClick: () => copyText(out.text) }, '复制'),
2519
+ ),
2520
+ h('div', { style: { whiteSpace: 'pre-wrap', lineHeight: 1.8, fontSize: 14.5 } }, out.text),
2521
+ out.steps.length === 0 ? null : h('div', { className: 'lc-meta' }, `认出 ${out.steps.length} 步`),
2522
+ ),
2523
+ )
2524
+ }
2525
+
2526
+ /** 把后端读出来的小节拍平成表单能直接用的字段。 */
2527
+ function buildCareerForm(sections) {
2528
+ const pick = (key) => {
2529
+ const v = sections?.[key]
2530
+ if (Array.isArray(v)) return v.join('\n')
2531
+ return String(v ?? '')
2532
+ }
2533
+ return {
2534
+ 基本情况: pick('基本情况'),
2535
+ 学过什么: pick('学过什么'),
2536
+ 做过什么: pick('做过什么'),
2537
+ 会用哪些工具: pick('会用哪些工具'),
2538
+ 想去哪: pick('想去哪'),
2539
+ 还差什么: pick('还差什么'),
2540
+ }
2541
+ }
2542
+
2543
+ /** 复制到剪贴板;不支持就退回「选中让用户自己复制」。 */
2544
+ function copyText(text) {
2545
+ const value = String(text ?? '')
2546
+ if (typeof navigator !== 'undefined' && navigator?.clipboard?.writeText !== undefined) {
2547
+ navigator.clipboard.writeText(value).catch(() => {})
2548
+ return
2549
+ }
2550
+ if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
2551
+ const box = document.createElement('textarea')
2552
+ box.value = value
2553
+ document.body.appendChild(box)
2554
+ box.select()
2555
+ try { document.execCommand('copy') } catch { /* 复制不了就算了 */ }
2556
+ document.body.removeChild(box)
2557
+ }
2558
+ }
2559
+
2560
+ function BookView({ bookId, onBack, flash }) {
2561
+ const [book, setBook] = useState(null)
2562
+ const [outline, setOutline] = useState(null)
2563
+ const [chapterIndex, setChapterIndex] = useState(0)
2564
+ const [body, setBody] = useState('')
2565
+ const [draft, setDraft] = useState(null)
2566
+ const [review, setReview] = useState(null)
2567
+ const [busy, setBusy] = useState('')
2568
+ const [err, setErr] = useState(null)
2569
+ const [ask, setAsk] = useState('')
2570
+ const [editingOutline, setEditingOutline] = useState(false)
2571
+ const [outlineText, setOutlineText] = useState('')
2572
+ // 这一章的正文有没有还没存下的改动。切章之前必须先处理,否则写的东西会没。
2573
+ const [bodyDirty, setBodyDirty] = useState(false)
2574
+
2575
+ const load = async (want) => {
2576
+ setErr(null)
2577
+ try {
2578
+ const r = await call('novel.get', { id: bookId })
2579
+ if (r?.ok !== true) { setErr(r?.error ?? '这本书打不开'); return }
2580
+ setBook(r)
2581
+ const o = await call('novel.outline.get', { id: bookId })
2582
+ if (o?.ok === true) { setOutline(o); setOutlineText(String(o.text ?? '')) }
2583
+ // 默认打开:有章节就打开最后一章,没有就从第一章开始
2584
+ const target = Number(want) > 0 ? Number(want) : (r.chapters.length === 0 ? 1 : r.chapters[r.chapters.length - 1].index)
2585
+ setChapterIndex(target)
2586
+ await openChapter(target)
2587
+ } catch (e) { setErr(humanizeError(e)) }
2588
+ }
2589
+
2590
+ const openChapter = async (index) => {
2591
+ setErr(null); setReview(null); setDraft(null)
2592
+ try {
2593
+ const r = await call('novel.chapter.get', { id: bookId, index })
2594
+ setBody(r?.ok === true ? String(r.body ?? '') : '')
2595
+ } catch (e) { setErr(humanizeError(e)) }
2596
+ }
2597
+
2598
+ // 切章之前先把没存的正文收好。存不上就留在这一章,别让人以为写进去了。
2599
+ const keepBody = async () => {
2600
+ if (bodyDirty !== true) return true
2601
+ if (body.trim() === '') { setBodyDirty(false); return true }
2602
+ try {
2603
+ const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body })
2604
+ if (r?.ok !== true) { setErr(r?.error ?? '这一章没存上,先别走'); return false }
2605
+ setBodyDirty(false)
2606
+ return true
2607
+ } catch (e) { setErr(humanizeError(e)); return false }
2608
+ }
2609
+
2610
+ const goChapter = async (index) => {
2611
+ if ((await keepBody()) !== true) return
2612
+ setChapterIndex(index)
2613
+ await openChapter(index)
2614
+ }
2615
+
2616
+ useEffect(() => { load(0) }, [bookId])
2617
+
2618
+ const doPlan = async () => {
2619
+ setBusy('plan'); setErr(null)
2620
+ try {
2621
+ const r = await call('novel.outline.draft', { id: bookId, chapters: 12 })
2622
+ if (r?.ok !== true) { setErr(r?.error ?? '这次没想出大纲'); return }
2623
+ setOutline(r); setOutlineText(String(r.text ?? ''))
2624
+ flash?.('大纲想好了,你可以直接改')
2625
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2626
+ }
2627
+
2628
+ const saveOutline = async () => {
2629
+ setBusy('outline'); setErr(null)
2630
+ try {
2631
+ const r = await call('novel.outline.save', { id: bookId, text: outlineText })
2632
+ if (r?.ok !== true) { setErr(r?.error ?? '大纲没存上'); return }
2633
+ // 只取存盘结果里跟大纲正文有关的那几样。服务端回的 chapters 是「几条」这个数字,
2634
+ // 整包铺出去会把本地那份逐章列表盖掉,下一屏渲染就会拿数字去 find。
2635
+ setOutline({
2636
+ ok: true,
2637
+ path: r.path,
2638
+ text: outlineText,
2639
+ theme: r.theme,
2640
+ ending: r.ending,
2641
+ chapters: Array.isArray(outline?.chapters) ? outline.chapters : [],
2642
+ })
2643
+ setEditingOutline(false)
2644
+ flash?.('大纲存好了')
2645
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2646
+ }
2647
+
2648
+ const doWrite = async () => {
2649
+ setBusy('write'); setErr(null); setReview(null)
2650
+ try {
2651
+ const r = await call('novel.chapter.draft', { id: bookId, index: chapterIndex, ask })
2652
+ if (r?.ok !== true) { setErr(r?.error ?? '这次没写出来'); return }
2653
+ setDraft(String(r.draft ?? ''))
2654
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2655
+ }
2656
+
2657
+ const acceptDraft = async () => {
2658
+ if (draft === null) return
2659
+ setBusy('save'); setErr(null)
2660
+ try {
2661
+ const merged = body.trim() === '' ? draft : `${body.trim()}\n\n${draft}`
2662
+ const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body: merged })
2663
+ if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
2664
+ setBody(merged); setDraft(null); setBodyDirty(false)
2665
+ flash?.(`第 ${chapterIndex} 章存好了`)
2666
+ await load(chapterIndex)
2667
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2668
+ }
2669
+
2670
+ const saveBody = async () => {
2671
+ setBusy('save'); setErr(null)
2672
+ try {
2673
+ const r = await call('novel.chapter.save', { id: bookId, index: chapterIndex, body })
2674
+ if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
2675
+ setBodyDirty(false)
2676
+ flash?.(`第 ${chapterIndex} 章存好了`)
2677
+ await load(chapterIndex)
2678
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2679
+ }
2680
+
2681
+ const doReview = async () => {
2682
+ // 挑毛病看的是存下来的那一版,所以正文有没存的改动就先存一次,
2683
+ // 否则你会以为它评的是刚写的那段。
2684
+ if ((await keepBody()) !== true) return
2685
+ setBusy('review'); setErr(null)
2686
+ try {
2687
+ const r = await call('novel.chapter.review', { id: bookId, index: chapterIndex })
2688
+ if (r?.ok !== true) { setErr(r?.error ?? '这次没审成'); return }
2689
+ setReview(String(r.review ?? ''))
2690
+ } catch (e) { setErr(humanizeError(e)) } finally { setBusy('') }
2691
+ }
2692
+
2693
+ if (book === null) {
2694
+ return h('div', { className: 'lc-pad lc-stack' },
2695
+ h(Btn, { size: 'sm', icon: 'back', onClick: onBack }, '回书架'),
2696
+ err === null ? h(Spinner) : h(Note, { tone: 'err', icon: 'warning' }, err),
2697
+ )
2698
+ }
2699
+
2700
+ const chapters = Array.isArray(book.chapters) ? book.chapters : []
2701
+ const plan = outline?.ok === true && Array.isArray(outline.chapters) ? outline.chapters : []
2702
+ const planItem = plan.find((c) => c.index === chapterIndex)
2703
+ const title = String(book.data?.title ?? bookId)
2704
+
2705
+ return h('div', { className: 'lc-pad lc-stack', style: { gap: 12 } },
2706
+ h('div', { className: 'lc-row' },
2707
+ h(Btn, { size: 'sm', icon: 'back', onClick: async () => { if ((await keepBody()) === true) onBack() } }, '回书架'),
2708
+ h('div', { className: 'lc-grow lc-ellipsis', style: { fontWeight: 700, fontSize: 16.5 } }, `《${title}》`),
2709
+ h('span', { className: 'lc-meta' }, `${chapters.length} 章`),
2710
+ ),
2711
+ String(book.idea ?? '').trim() === '' ? null : h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } }, String(book.idea).trim()),
2712
+
2713
+ // ── 第一段:规划 ──
2714
+ h('div', { className: 'lc-sec' },
2715
+ h('div', { className: 'lc-row' },
2716
+ h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, '① 先想清楚这本书怎么走')),
2717
+ h(Btn, { size: 'sm', tone: 'import', icon: 'spark', disabled: busy !== '', onClick: doPlan },
2718
+ busy === 'plan' ? '正在想…' : (plan.length === 0 ? '让 AI 想大纲' : '重新想一版')),
2719
+ plan.length === 0 ? null : h(Btn, { size: 'sm', ghost: true, icon: 'edit', onClick: () => setEditingOutline(!editingOutline) },
2720
+ editingOutline ? '收起' : '自己改'),
2721
+ ),
2722
+ plan.length === 0
2723
+ ? h('div', { className: 'lc-meta', style: { marginTop: 6, lineHeight: 1.7 } },
2724
+ '先让它把整本书的骨架搭出来:主线、每章要发生什么、结局。',
2725
+ h('br'),
2726
+ '搭完你能直接改——改一行,后面写出来的东西就跟着变。')
2727
+ : h('div', { className: 'lc-stack', style: { marginTop: 6 } },
2728
+ outline.theme === '' ? null : h('div', { className: 'lc-meta' }, `主线:${outline.theme}`),
2729
+ h('div', { className: 'lc-stack', style: { gap: 2 } }, plan.map((c) => h('div', {
2730
+ key: c.index,
2731
+ className: 'lc-meta',
2732
+ style: {
2733
+ padding: '3px 6px', borderRadius: 6, cursor: 'pointer', lineHeight: 1.6,
2734
+ background: c.index === chapterIndex ? 'var(--lc-accent-soft)' : 'transparent',
2735
+ color: c.index === chapterIndex ? 'var(--lc-accent)' : undefined,
2736
+ },
2737
+ onClick: () => { goChapter(c.index) },
2738
+ }, `第 ${c.index} 章 · ${c.plan}`))),
2739
+ outline.ending === '' ? null : h('div', { className: 'lc-meta' }, `结局:${outline.ending}`),
2740
+ ),
2741
+ editingOutline
2742
+ ? h('div', { className: 'lc-stack', style: { marginTop: 6 } },
2743
+ h('textarea', {
2744
+ className: 'lc-textarea', value: outlineText, style: { minHeight: 180 },
2745
+ onChange: (e) => setOutlineText(e.target.value), spellCheck: false,
2746
+ }),
2747
+ h('div', { className: 'lc-row' },
2748
+ h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveOutline }, busy === 'outline' ? '正在存…' : '存好'),
2749
+ h('div', { className: 'lc-meta' }, '每行一章,写清这一章要发生什么。'),
2750
+ ),
2751
+ )
2752
+ : null,
2753
+ ),
2754
+
2755
+ // ── 第二段:写这一章 ──
2756
+ h('div', { className: 'lc-sec' },
2757
+ h('div', { className: 'lc-row' },
2758
+ h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, `② 写第 ${chapterIndex} 章`)),
2759
+ h(Btn, { size: 'sm', tone: 'analyze', icon: 'spark', disabled: busy !== '', onClick: doWrite },
2760
+ busy === 'write' ? '正在写…' : '让 AI 写这一章'),
2761
+ ),
2762
+ planItem === undefined
2763
+ ? h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '大纲里还没有这一章。先让 AI 想一版大纲,或者直接写也行。')
2764
+ : h('div', { className: 'lc-meta', style: { marginTop: 4, lineHeight: 1.6 } }, `这一章要写:${planItem.plan}`),
2765
+ h('div', { className: 'lc-row', style: { marginTop: 6 } },
2766
+ h('input', {
2767
+ className: 'lc-input', value: ask, spellCheck: false,
2768
+ placeholder: '(可选)这一章你想强调什么?比如「写他们吵架那场」',
2769
+ onChange: (e) => setAsk(e.target.value),
2770
+ }),
2771
+ ),
2772
+ draft === null
2773
+ ? null
2774
+ : h('div', { className: 'lc-stack', style: { marginTop: 8 } },
2775
+ h(Note, { tone: 'ok', icon: 'spark' }, '写好了。看一眼,满意就收下,不满意可以不收。'),
2776
+ h('div', {
2777
+ style: {
2778
+ whiteSpace: 'pre-wrap', lineHeight: 1.85, fontSize: 14.5,
2779
+ padding: '10px 12px', borderRadius: 10,
2780
+ background: 'var(--lc-analyze-soft)', border: '1px solid var(--lc-analyze)',
2781
+ },
2782
+ }, draft),
2783
+ h('div', { className: 'lc-row' },
2784
+ h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: acceptDraft },
2785
+ busy === 'save' ? '正在存…' : '收下,写进这一章'),
2786
+ h(Btn, { size: 'sm', ghost: true, icon: 'trash', disabled: busy !== '', onClick: () => setDraft(null) }, '不要这段'),
2787
+ ),
2788
+ ),
2789
+ ),
2790
+
2791
+ // ── 正文 ──
2792
+ h('div', { className: 'lc-sec' },
2793
+ h('div', { className: 'lc-row' },
2794
+ h('div', { className: 'lc-grow' }, h('span', { style: { fontWeight: 650 } }, '正文')),
2795
+ h('span', { className: 'lc-meta' }, `${body.length} 字`),
2796
+ ),
2797
+ h('textarea', {
2798
+ className: 'lc-textarea',
2799
+ value: body,
2800
+ spellCheck: false,
2801
+ placeholder: '这一章还空着。让 AI 写一段,或者自己动手。',
2802
+ style: { minHeight: 260, marginTop: 6, lineHeight: 1.85 },
2803
+ onChange: (e) => { setBody(e.target.value); setBodyDirty(true) },
2804
+ }),
2805
+ h('div', { className: 'lc-row' },
2806
+ h(Btn, { size: 'sm', tone: 'primary', icon: 'check', disabled: busy !== '', onClick: saveBody }, busy === 'save' ? '正在存…' : '存好这一章'),
2807
+ h(Btn, { size: 'sm', icon: 'doctor', disabled: busy !== '' || body.trim() === '', onClick: doReview },
2808
+ busy === 'review' ? '正在挑毛病…' : '挑挑毛病'),
2809
+ h('div', { className: 'lc-grow' }),
2810
+ h(Btn, { size: 'sm', ghost: true, icon: 'plus', onClick: async () => {
2811
+ const next = chapterIndex + 1
2812
+ await goChapter(next)
2813
+ } }, `写第 ${chapterIndex + 1} 章`),
2814
+ ),
2815
+ review === null
2816
+ ? null
2817
+ : h('div', { className: 'lc-stack', style: { marginTop: 8 } },
2818
+ h(Note, { tone: 'warn', icon: 'warning' }, '它挑出来的问题(不是夸奖,也不用全听)'),
2819
+ h('div', { style: { whiteSpace: 'pre-wrap', lineHeight: 1.8, fontSize: 14 } }, review),
2820
+ ),
2821
+ ),
2822
+
2823
+ err === null ? null : h(Note, { tone: 'err', icon: 'warning' }, err),
2824
+ )
2825
+ }
2826
+
2827
+ function TavernView({ chat, onChat, onBack, flash }) {
1955
2828
  const [draft, setDraft] = useState('')
1956
2829
  const [busy, setBusy] = useState(false)
1957
2830
  const [err, setErr] = useState(null)
@@ -1959,6 +2832,16 @@ function TavernView({ chat, onChat, onBack }) {
1959
2832
  // 「到这儿为止」那段备忘。它存在会话文件里,跟着 chat 一起下发。
1960
2833
  const [notes, setNotes] = useState(Array.isArray(chat?.notes) ? chat.notes : [])
1961
2834
  const scrollRef = useRef(null)
2835
+ // 「把这一场变成小说的一章」。这几个 state 特意放在所有 hook 之后:
2836
+ // 上面那几个槽位有测试按号码摆状态,插在中间会让它们全体错位。
2837
+ const [toOpen, setToOpen] = useState(false)
2838
+ const [toBooks, setToBooks] = useState([])
2839
+ const [toBook, setToBook] = useState('')
2840
+ const [toBusy, setToBusy] = useState(false)
2841
+ const [toNote, setToNote] = useState('')
2842
+ // 「挑几句写进卡里」。同样排在最后。
2843
+ const [exBusy, setExBusy] = useState(false)
2844
+ const [exNote, setExNote] = useState('')
1962
2845
  const turns = Array.isArray(chat?.turns) ? chat.turns : []
1963
2846
  const rawName = String(chat?.card_name ?? '').trim()
1964
2847
  const name = rawName === '' ? 'TA' : rawName
@@ -2008,6 +2891,52 @@ function TavernView({ chat, onChat, onBack }) {
2008
2891
  }
2009
2892
  }
2010
2893
 
2894
+ // 打开「变成小说的一章」时先问一次有哪些书。以前没有这条路,
2895
+ // 酒馆里演得好的一场只能靠人手动复制粘贴进小说。
2896
+ /**
2897
+ * 把这一场里最能代表 TA 的几句话写回那张卡的「对话示例」。
2898
+ *
2899
+ * 「性格:沉默寡言」这种话模型只能猜;「他说话只答半句,句尾带个『呗』」
2900
+ * 是能直接照做的。演着演着发现 TA 说话的样子比卡上写的准,就该固化下来。
2901
+ */
2902
+ const toExample = async () => {
2903
+ if (exBusy || chat?.id === undefined) return
2904
+ setExBusy(true); setExNote('')
2905
+ try {
2906
+ const r = await call('tavern.toExample', { id: chat.id })
2907
+ if (r?.ok !== true) { setExNote(r?.error ?? '这次没挑出来'); return }
2908
+ const picked = Array.isArray(r.picked) ? r.picked : []
2909
+ setExNote(`写进卡里了,这次挑的是:${picked.map((x) => `「${x}」`).join('、')}`)
2910
+ flash?.('卡上的「对话示例」又厚了一点')
2911
+ } catch (e) {
2912
+ setExNote(humanizeError(e))
2913
+ } finally {
2914
+ setExBusy(false)
2915
+ }
2916
+ }
2917
+
2918
+ const openToChapter = async () => {
2919
+ setToOpen(true); setToBusy(true); setErr(null)
2920
+ try {
2921
+ const r = await call('novel.books', {})
2922
+ if (r?.ok !== true) { setErr(r?.error ?? '看不到书架'); return }
2923
+ const list = Array.isArray(r.books) ? r.books : []
2924
+ setToBooks(list)
2925
+ setToBook(toBook !== '' ? toBook : String(list[0]?.id ?? ''))
2926
+ } catch (e) { setErr(humanizeError(e)) } finally { setToBusy(false) }
2927
+ }
2928
+
2929
+ const toChapter = async () => {
2930
+ if (toBook === '') { setErr('还没有书。先回「写小说」那扇门建一本。'); return }
2931
+ setToBusy(true); setErr(null)
2932
+ try {
2933
+ const r = await call('tavern.toChapter', { id: chat?.id, book: toBook, note: toNote })
2934
+ if (r?.ok !== true) { setErr(r?.error ?? '没改成'); return }
2935
+ setToOpen(false); setToNote('')
2936
+ if (typeof flash === 'function') flash(`写成了第 ${r.chapter ?? '?'} 章`)
2937
+ } catch (e) { setErr(humanizeError(e)) } finally { setToBusy(false) }
2938
+ }
2939
+
2011
2940
  const remove = async () => {
2012
2941
  if (chat?.id === undefined) return
2013
2942
  setBusy(true)
@@ -2105,8 +3034,36 @@ function TavernView({ chat, onChat, onBack }) {
2105
3034
  onClick: summarize,
2106
3035
  title: '把前面发生过的事记成几句,之后每轮都带上',
2107
3036
  }, '收个尾'),
3037
+ h(Btn, { size: 'sm', icon: 'book', disabled: busy || turns.length === 0, onClick: openToChapter, title: '把这一场改写成某本小说的一章' }, '变成小说的一章'),
3038
+ h(Btn, { size: 'sm', icon: 'chat', disabled: busy || exBusy || turns.length === 0, onClick: toExample, title: '挑几句 TA 说过的话,写进那张卡的「对话示例」' }, exBusy ? '正在挑…' : '挑几句进卡里'),
2108
3039
  h(Btn, { size: 'sm', ghost: true, icon: 'trash', disabled: busy, onClick: () => setConfirmDelete(true) }, '这一场不要了'),
2109
3040
  ),
3041
+ toOpen === true
3042
+ ? h('div', { className: 'lc-sec lc-stack' },
3043
+ h('div', { className: 'lc-fhint' }, '挑一本书,这一场会改写成它的下一章'),
3044
+ toBusy && toBooks.length === 0
3045
+ ? h(Spinner)
3046
+ : toBooks.length === 0
3047
+ ? h('div', { className: 'lc-meta' }, '还没有书。先去「写小说」那扇门建一本,再回来。')
3048
+ : h('div', { className: 'lc-stack' },
3049
+ h('div', { className: 'lc-chips' }, toBooks.map((b) => h(Btn, {
3050
+ key: b.id,
3051
+ size: 'sm',
3052
+ tone: toBook === b.id ? 'primary' : undefined,
3053
+ onClick: () => setToBook(b.id),
3054
+ }, String(b.title ?? b.id)))),
3055
+ h('input', {
3056
+ className: 'lc-input', value: toNote, spellCheck: false,
3057
+ placeholder: '这一章要往哪走(可以不写)',
3058
+ onChange: (e) => setToNote(e.target.value),
3059
+ }),
3060
+ h('div', { className: 'lc-row' },
3061
+ h(Btn, { tone: 'primary', disabled: toBusy || toBook === '', onClick: toChapter }, toBusy ? '写着…' : '写成这一章'),
3062
+ h(Btn, { ghost: true, disabled: toBusy, onClick: () => setToOpen(false) }, '先不变'),
3063
+ ),
3064
+ ),
3065
+ )
3066
+ : null,
2110
3067
  confirmDelete === true
2111
3068
  ? h('div', { className: 'lc-row' },
2112
3069
  h('span', { className: 'lc-meta', className: 'lc-meta' }, '这一场的记录会删掉,删了就找不回来了。'),
@@ -2114,6 +3071,7 @@ function TavernView({ chat, onChat, onBack }) {
2114
3071
  h(Btn, { size: 'sm', ghost: true, onClick: () => setConfirmDelete(false), disabled: busy }, '先不删'),
2115
3072
  )
2116
3073
  : null,
3074
+ exNote === '' ? null : h(Note, { tone: 'ok', icon: 'check' }, exNote),
2117
3075
  h('div', { className: 'lc-meta' }, '每说一句都会存进「chats」里,退出再进来还能接着说。'),
2118
3076
  )
2119
3077
  }
@@ -3435,6 +4393,9 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
3435
4393
  // 结束页以前一律说「你说的都记下来了」;可要是手一滑点了结束,
3436
4394
  // 正在写的那句其实没存进去——那句话就得改口。
3437
4395
  const [dropped, setDropped] = useState(false)
4396
+ // 这一场访谈写出去的材料(interview.record 每次回传一份)。结束页要带上它们,
4397
+ // 不然分析页一条都不勾,而「一条都不勾」= 全读——别人的材料会被读进这张卡。
4398
+ const [materialPaths, setMaterialPaths] = useState([])
3438
4399
  const scrollRef = useRef(null)
3439
4400
 
3440
4401
  // 宿主给什么就用什么;一个都没有(还没重启、旧版宿主)就用本地这份。
@@ -3575,6 +4536,12 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
3575
4536
  setErr(rec?.error ?? '这一句没记上。检查一下文件夹还在不在,再点一次。')
3576
4537
  return
3577
4538
  }
4539
+ // interview.record 会把你这一场的问答另存成一份材料。攒着它的路径,
4540
+ // 结束页「整理进卡里」必须带上——分析页一条不勾等于「全读」,
4541
+ // 别人说的那些也会被读进这张卡。
4542
+ if (typeof rec.material === 'string' && rec.material !== '') {
4543
+ setMaterialPaths((prev) => (prev.includes(rec.material) ? prev : [...prev, rec.material]))
4544
+ }
3578
4545
  if (status === 'answered') {
3579
4546
  setLog((prev) => [...prev, { role: 'bot', text: q.ask, options: q.options }, { role: 'me', text: answer.trim() }])
3580
4547
  } else {
@@ -3585,6 +4552,12 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
3585
4552
  const rest = queue.slice(1)
3586
4553
  if (rest.length === 0) {
3587
4554
  const p = await call('interview.plan', { about: aboutId, relation, limit: 3, kind: fiction ? 'fictional' : 'real' })
4555
+ // 没问着问题不等于问完了。以前这里不看结果:AI 没连上、模型抽风、
4556
+ // 文件夹读不出来,界面都会直接跳到结束页,还写「现在写满了 undefined%」。
4557
+ if (p?.ok !== true) {
4558
+ setErr(`${p?.error ?? '想不出下一题'}。刚才那一句已经记下了,这里是没接上——过一会儿点「再问一题」接着来。`)
4559
+ return
4560
+ }
3588
4561
  setPlan(p)
3589
4562
  setQueue(p.next ?? [])
3590
4563
  setCurrent((p.next ?? [])[0] ?? null)
@@ -3742,7 +4715,7 @@ function InterviewView({ state, listing, preset, onRecorded, flash, onDone, onAn
3742
4715
  plan === null || (plan.empty ?? []).length === 0
3743
4716
  ? null
3744
4717
  : h('div', { className: 'lc-meta' }, `还空着:${(plan.empty ?? []).slice(0, 8).join('、')}`),
3745
- h(Btn, { tone: 'primary', big: true, block: true, onClick: () => onAnalyze({ id: aboutId, name: preset?.name ?? name }) }, '把刚才说的整理进卡里'),
4718
+ h(Btn, { tone: 'primary', big: true, block: true, onClick: () => onAnalyze({ id: aboutId, name: preset?.name ?? name, materials: materialPaths }) }, '把刚才说的整理进卡里'),
3746
4719
  h(Btn, { block: true, onClick: () => { onDone() } }, '先这样,回头再说'),
3747
4720
  h('div', { className: 'lc-meta', style: { textAlign: 'center' } }, '你说的每句话都存成了材料。整理时会标出原文出处,编不出来的。'),
3748
4721
  )
@@ -3805,9 +4778,16 @@ function ImportView({ busy, onImport, onAnalyze, listing }) {
3805
4778
  const [drag, setDrag] = useState(false)
3806
4779
  const [tab, setTab] = useState('card')
3807
4780
  const inputRef = useRef(null)
4781
+ const [skipNote, setSkipNote] = useState('')
3808
4782
 
3809
4783
  const handleFiles = (fileList) => {
3810
- const files = Array.from(fileList ?? []).filter((f) => /\.(png|json|charx|zip)$/i.test(f.name))
4784
+ const all = Array.from(fileList ?? [])
4785
+ const files = all.filter((f) => /\.(png|json|charx|zip)$/i.test(f.name))
4786
+ // 以前过滤完直接就 return:拖进来的是一张 jpg 头像或一份 md 设定,
4787
+ // 界面上什么都不发生,用户只会以为这块坏了。
4788
+ setSkipNote(files.length > 0 || all.length === 0
4789
+ ? ''
4790
+ : `这些认不出来:${all.map((f) => f.name).join('、')}。只认角色卡文件(.png / .json / .charx / .zip)。想贴一段文字,就切到上面「一段文字」。`)
3811
4791
  if (files.length === 0) return
3812
4792
  onImport(files)
3813
4793
  }
@@ -3843,6 +4823,7 @@ function ImportView({ busy, onImport, onAnalyze, listing }) {
3843
4823
  style: { display: 'none' }, onChange: (e) => handleFiles(e.target.files),
3844
4824
  }),
3845
4825
  ),
4826
+ skipNote === '' ? null : h(Note, { tone: 'warn', icon: 'warning' }, skipNote),
3846
4827
  busy === 'import' ? h('div', { className: 'lc-row' }, h(Spinner), h('span', { className: 'lc-meta' }, '正在读…')) : null,
3847
4828
  h(Note, { tone: undefined, icon: 'check' }, '导入进来的卡会原样保留所有内容,包括这个软件不认识的字段。你在这里改过的部分才会变,导出时一起带走。'),
3848
4829
  ),
@@ -3890,7 +4871,7 @@ function TextImport({ onAnalyze }) {
3890
4871
  h('div', { className: 'lc-meta', style: { lineHeight: 1.7 } },
3891
4872
  '把一大段话贴进来——小说片段、人物小传、你随口讲的关于某个人的事都行。',
3892
4873
  h('br'),
3893
- '存完点「让 AI 读一读」,它会把里面的信息整理成一张卡。**只认原文里写过的**,编的会被丢掉。'),
4874
+ '存完点「让 AI 读一读」,它会把里面的信息整理成一张卡。只认原文里写过的,编的会被丢掉。'),
3894
4875
  h(Field, { label: '这是要建成什么人' },
3895
4876
  h('div', { className: 'lc-row' },
3896
4877
  h(Btn, { size: 'sm', tone: kind === 'real' ? 'interview' : undefined, onClick: () => setKind('real') }, '现实里的人'),
@@ -4083,7 +5064,7 @@ function peopleKey(p) {
4083
5064
 
4084
5065
  // ── 让 AI 读材料 ──────────────────────────────────────────────────
4085
5066
 
4086
- function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMaterials, onBack, onNeedMaterial }) {
5067
+ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMaterials, onBack, onNeedMaterial, onAppliedCard }) {
4087
5068
  const materials = listing?.materials ?? []
4088
5069
  const cards = listing?.cards ?? []
4089
5070
  // 从别的页带过来的人,可能只有一个名字:材料刚存下、卡还没建。
@@ -4092,7 +5073,13 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
4092
5073
  const presetId = String(presetAbout?.id ?? '').trim()
4093
5074
  const presetName = String(presetAbout?.name ?? '').trim()
4094
5075
  const [about, setAbout] = useState(presetId !== '' ? presetId : presetName)
4095
- const [chosen, setChosen] = useState(presetMaterials === undefined || presetMaterials === null ? [] : [presetMaterials])
5076
+ // 带过来的材料可能是一份,也可能是好几份(访谈一路上记下来的)。
5077
+ // 不认数组的话会变成「[一份, 另一份]」被当成一个路径,一条都勾不上,
5078
+ // 而一条都不勾等于全读。
5079
+ const [chosen, setChosen] = useState(() => {
5080
+ if (Array.isArray(presetMaterials)) return presetMaterials.filter((x) => typeof x === 'string' && x !== '')
5081
+ return presetMaterials === undefined || presetMaterials === null ? [] : [presetMaterials]
5082
+ })
4096
5083
  const [busy, setBusy] = useState(false)
4097
5084
  const [err, setErr] = useState(null)
4098
5085
  const [result, setResult] = useState(null)
@@ -4126,12 +5113,28 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
4126
5113
  // 卸载时补存一次,别让刚想出来的那句话白写。
4127
5114
  const conceptDraftRef = useRef(null)
4128
5115
  conceptDraftRef.current = conceptDraft
5116
+ // 下面这两个特意放在所有 hook 之后:上面那些槽位是有测试按号码摆状态的,
5117
+ // 中间插一个新 state 会让它们全体错位(conceptDraft 就是被这么挪过一次)。
5118
+ const [oneClick, setOneClick] = useState('')
5119
+ // 这一趟读的是虚构角色还是真人:虚构的才给「直接建成卡」,
5120
+ // 真人的卡要人自己填关系那些,不能一键生成。
5121
+ const isFiction = String(presetAbout?.kind ?? '').trim() === 'fictional'
4129
5122
  useEffect(() => () => {
4130
5123
  const pending = conceptDraftRef.current
4131
5124
  if (pending === null || pending === undefined) return
4132
5125
  call('concept.set', { concept: pending }).catch(() => {})
4133
5126
  }, [])
4134
5127
 
5128
+ // 这一趟到底等了多久,明明白白数给人看。
5129
+ // 只说「正在读…」的话,超过半分钟人就开始怀疑卡死,去点第二次;
5130
+ // 两个请求叠在一起,后台更慢,看起来就更像卡死。
5131
+ const [waited, setWaited] = useState(0)
5132
+ useEffect(() => {
5133
+ if (busy !== true) { setWaited(0); return undefined }
5134
+ const timer = setInterval(() => setWaited((n) => n + 1), 1000)
5135
+ return () => clearInterval(timer)
5136
+ }, [busy])
5137
+
4135
5138
  const flushConcept = async (value) => {
4136
5139
  const next = value ?? conceptDraft ?? concept
4137
5140
  setConceptDraft(null)
@@ -4171,6 +5174,11 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
4171
5174
  })
4172
5175
  }
4173
5176
 
5177
+ // 选了哪几份就按哪几份算字数:「一条都不勾 = 全读」。
5178
+ const readChars = (chosen.length > 0 ? materials.filter((m) => chosen.includes(m.path)) : materials)
5179
+ .reduce((sum, m) => sum + (Number(m.chars) || Number(m.size) || 0), 0)
5180
+ const longRead = readChars > 20000
5181
+
4174
5182
  const run = async () => {
4175
5183
  // 「这一趟等了多久」从这里算。以前写成 startedAt 却没有声明,后台一连不上
4176
5184
  // 就在 catch 里抛 ReferenceError,setErr 永远走不到——用户只看到按钮弹回来,
@@ -4312,14 +5320,45 @@ function AnalyzeView({ listing, state, flash, onProposal, presetAbout, presetMat
4312
5320
  h(Btn, { tone: 'analyze', big: true, onClick: run, disabled: busy || about === '' || materials.length === 0, icon: 'spark' }, busy ? '正在读…' : '开始读'),
4313
5321
  busy ? h(Spinner) : null,
4314
5322
  ),
5323
+ // 这一趟是真等:模型先在心里想一遍再写(实测小材料也要四十秒上下,
5324
+ // 长材料一分多钟)。不给时间预期的话,转圈超过半分钟人就会以为卡死了、
5325
+ // 去点第二次,两个请求叠在一起更慢。
5326
+ busy
5327
+ ? h('div', { className: 'lc-meta' }, longRead
5328
+ ? `材料有点长(约 ${Math.round(readChars / 1000)} 千字),这一趟可能一两分钟。已经等了 ${waited} 秒,别关这一页,等它说完。`
5329
+ : `它在先想一遍再写,一般要半分钟到一分钟。已经等了 ${waited} 秒,别关这一页,等它说完。`)
5330
+ : null,
4315
5331
 
4316
5332
  result === null ? null : h('div', null,
4317
5333
  h(Note, { tone: 'ok', icon: 'check' },
4318
5334
  h('div', null, `读出了 ${result.accepted} 条。`),
4319
5335
  result.rejected > 0 ? h('div', { className: 'lc-meta', style: { marginTop: 3 } }, `另有 ${result.rejected} 条在材料里找不到原句,已经丢掉——AI 编的东西进不来。`) : null,
4320
5336
  ),
5337
+ // 写小说和酒馆里,用户要的是「读完就有一张能用的角色卡」,
5338
+ // 不是再去访谈一遍——虚构角色本来就没有「他是我的什么人」这种问题,
5339
+ // 那些细节它自己会从材料里编。所以这里直接给一个一键建卡的入口。
5340
+ isFiction && result.json !== undefined
5341
+ ? h('div', { style: { marginTop: 8 } },
5342
+ h(Btn, { tone: 'primary', big: true, block: true, icon: 'check', disabled: oneClick !== '', onClick: async () => {
5343
+ setOneClick('on')
5344
+ try {
5345
+ // 提案里默认一条都不勾(勾选是人的动作),所以这里要先把
5346
+ // 所有条目都勾上再采纳,否则只会得到「还没有勾选任何一条」。
5347
+ const got = await call('proposal.get', { json: result.json })
5348
+ if (got?.ok !== true) { setErr(got?.error ?? '这份建议读不出来'); return }
5349
+ const every = (got.items ?? []).map((item) => item.index).filter((n) => typeof n === 'number')
5350
+ if (every.length === 0) { setErr('这一份里没有能写进卡里的条目。'); return }
5351
+ const r = await call('proposal.apply', { json: result.json, checked: every })
5352
+ if (r?.ok !== true) { setErr(r?.error ?? '没能建成卡'); return }
5353
+ flash(`建好了,写进去 ${r.applied ?? every.length} 条`)
5354
+ if (typeof onAppliedCard === 'function' && r.path !== undefined) onAppliedCard(r.path)
5355
+ } catch (e) { setErr(humanizeError(e)) } finally { setOneClick('') }
5356
+ } }, oneClick === 'on' ? '正在建…' : '直接建成角色卡'),
5357
+ h('div', { className: 'lc-meta', style: { marginTop: 4 } }, '不用再访谈一遍。材料里读到什么就写什么,建完还能在卡上改。'),
5358
+ )
5359
+ : null,
4321
5360
  h('div', { style: { marginTop: 8 } },
4322
- h(Btn, { tone: 'primary', big: true, block: true, icon: 'check', onClick: async () => {
5361
+ h(Btn, { tone: isFiction ? undefined : 'primary', big: true, block: true, icon: 'check', onClick: async () => {
4323
5362
  try {
4324
5363
  const p = await call('proposal.get', { json: result.json })
4325
5364
  // 读不出来当「没有新东西」会让界面绿字说「这一份里没有还没写进去的了」,
@@ -4479,6 +5518,7 @@ function JobsView({ onBack, flash }) {
4479
5518
  // 这一页开头要读好几样东西(求职设置、以前读过的、投递表、故事)。
4480
5519
  // 读失败时以前是静默吞掉——界面上就是「一片空」,跟「你还没填」长得一样。
4481
5520
  const [loadFail, setLoadFail] = useState(false)
5521
+ const [savingStory, setSavingStory] = useState(false)
4482
5522
 
4483
5523
  useEffect(() => {
4484
5524
  let alive = true
@@ -4776,14 +5816,28 @@ function JobsView({ onBack, flash }) {
4776
5816
  h('input', { className: 'lc-input', value: newStoryTitle, onChange: (e) => setNewStoryTitle(e.target.value), placeholder: '一次线上事故', spellCheck: false }),
4777
5817
  ),
4778
5818
  h('textarea', { className: 'lc-textarea', value: newStoryBody, onChange: (e) => setNewStoryBody(e.target.value), placeholder: '凌晨两点被告警叫起来…', spellCheck: false }),
4779
- h(Btn, { size: 'sm', icon: 'plus', disabled: newStoryTitle.trim() === '' || newStoryBody.trim() === '', onClick: async () => {
4780
- const r = await call('job.story.add', { story: { title: newStoryTitle.trim(), body: newStoryBody.trim() } })
4781
- if (r.ok !== true) { setErr(r.error ?? '没存上'); return }
4782
- setNewStoryTitle(''); setNewStoryBody('')
4783
- const again = await call('job.stories', {})
4784
- if (again.ok === true) setStories(again.stories ?? [])
4785
- flash('记下来了')
4786
- } }, '存下这段'),
5819
+ h(Btn, {
5820
+ size: 'sm', icon: 'plus',
5821
+ disabled: savingStory || newStoryTitle.trim() === '' || newStoryBody.trim() === '',
5822
+ onClick: async () => {
5823
+ // 以前这里没有 try/catch:后台断了点它一声不响,用户只会以为按钮坏了;
5824
+ // 也没有忙等,网络慢连点两下会存出两条同名的记录。
5825
+ if (savingStory) return
5826
+ setSavingStory(true); setErr(null)
5827
+ try {
5828
+ const r = await call('job.story.add', { story: { title: newStoryTitle.trim(), body: newStoryBody.trim() } })
5829
+ if (r?.ok !== true) { setErr(r?.error ?? '没存上'); return }
5830
+ setNewStoryTitle(''); setNewStoryBody('')
5831
+ const again = await call('job.stories', {})
5832
+ if (again?.ok === true) setStories(again.stories ?? [])
5833
+ flash('记下来了')
5834
+ } catch (e) {
5835
+ setErr(humanizeError(e))
5836
+ } finally {
5837
+ setSavingStory(false)
5838
+ }
5839
+ },
5840
+ }, savingStory ? '正在存…' : '存下这段'),
4787
5841
  ),
4788
5842
  ),
4789
5843