@zaofan/dsh-qqbot 0.6.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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/README_EN.md +273 -0
  4. package/client/qqbot-settings.js +1463 -0
  5. package/cordis.patch.yml +36 -0
  6. package/dist/api/group-admin.d.ts +112 -0
  7. package/dist/api/group-admin.d.ts.map +1 -0
  8. package/dist/api/group-admin.js +179 -0
  9. package/dist/api/group-admin.js.map +1 -0
  10. package/dist/channel-tools.d.ts +14 -0
  11. package/dist/channel-tools.d.ts.map +1 -0
  12. package/dist/channel-tools.js +880 -0
  13. package/dist/channel-tools.js.map +1 -0
  14. package/dist/commands/help.d.ts +11 -0
  15. package/dist/commands/help.d.ts.map +1 -0
  16. package/dist/commands/help.js +21 -0
  17. package/dist/commands/help.js.map +1 -0
  18. package/dist/commands/index.d.ts +13 -0
  19. package/dist/commands/index.d.ts.map +1 -0
  20. package/dist/commands/index.js +29 -0
  21. package/dist/commands/index.js.map +1 -0
  22. package/dist/commands/misc.d.ts +14 -0
  23. package/dist/commands/misc.d.ts.map +1 -0
  24. package/dist/commands/misc.js +39 -0
  25. package/dist/commands/misc.js.map +1 -0
  26. package/dist/commands/model.d.ts +7 -0
  27. package/dist/commands/model.d.ts.map +1 -0
  28. package/dist/commands/model.js +58 -0
  29. package/dist/commands/model.js.map +1 -0
  30. package/dist/commands/session.d.ts +10 -0
  31. package/dist/commands/session.d.ts.map +1 -0
  32. package/dist/commands/session.js +26 -0
  33. package/dist/commands/session.js.map +1 -0
  34. package/dist/commands/status.d.ts +8 -0
  35. package/dist/commands/status.d.ts.map +1 -0
  36. package/dist/commands/status.js +24 -0
  37. package/dist/commands/status.js.map +1 -0
  38. package/dist/commands/types.d.ts +12 -0
  39. package/dist/commands/types.d.ts.map +1 -0
  40. package/dist/commands/types.js +2 -0
  41. package/dist/commands/types.js.map +1 -0
  42. package/dist/config.d.ts +205 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +203 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/features/chat-ledger.d.ts +53 -0
  47. package/dist/features/chat-ledger.d.ts.map +1 -0
  48. package/dist/features/chat-ledger.js +185 -0
  49. package/dist/features/chat-ledger.js.map +1 -0
  50. package/dist/features/face-tags.d.ts +17 -0
  51. package/dist/features/face-tags.d.ts.map +1 -0
  52. package/dist/features/face-tags.js +79 -0
  53. package/dist/features/face-tags.js.map +1 -0
  54. package/dist/features/group-join-request.d.ts +65 -0
  55. package/dist/features/group-join-request.d.ts.map +1 -0
  56. package/dist/features/group-join-request.js +190 -0
  57. package/dist/features/group-join-request.js.map +1 -0
  58. package/dist/features/history-store.d.ts +8 -0
  59. package/dist/features/history-store.d.ts.map +1 -0
  60. package/dist/features/history-store.js +24 -0
  61. package/dist/features/history-store.js.map +1 -0
  62. package/dist/features/qq-approval.d.ts +78 -0
  63. package/dist/features/qq-approval.d.ts.map +1 -0
  64. package/dist/features/qq-approval.js +165 -0
  65. package/dist/features/qq-approval.js.map +1 -0
  66. package/dist/features/schedule-store.d.ts +62 -0
  67. package/dist/features/schedule-store.d.ts.map +1 -0
  68. package/dist/features/schedule-store.js +253 -0
  69. package/dist/features/schedule-store.js.map +1 -0
  70. package/dist/features/scheduler.d.ts +9 -0
  71. package/dist/features/scheduler.d.ts.map +1 -0
  72. package/dist/features/scheduler.js +146 -0
  73. package/dist/features/scheduler.js.map +1 -0
  74. package/dist/features/sticker-gate.d.ts +90 -0
  75. package/dist/features/sticker-gate.d.ts.map +1 -0
  76. package/dist/features/sticker-gate.js +391 -0
  77. package/dist/features/sticker-gate.js.map +1 -0
  78. package/dist/features/sticker-store.d.ts +158 -0
  79. package/dist/features/sticker-store.d.ts.map +1 -0
  80. package/dist/features/sticker-store.js +676 -0
  81. package/dist/features/sticker-store.js.map +1 -0
  82. package/dist/features/sticker-tagger.d.ts +10 -0
  83. package/dist/features/sticker-tagger.d.ts.map +1 -0
  84. package/dist/features/sticker-tagger.js +139 -0
  85. package/dist/features/sticker-tagger.js.map +1 -0
  86. package/dist/gateway/bootstrap.d.ts +12 -0
  87. package/dist/gateway/bootstrap.d.ts.map +1 -0
  88. package/dist/gateway/bootstrap.js +346 -0
  89. package/dist/gateway/bootstrap.js.map +1 -0
  90. package/dist/gateway/debounce.d.ts +40 -0
  91. package/dist/gateway/debounce.d.ts.map +1 -0
  92. package/dist/gateway/debounce.js +262 -0
  93. package/dist/gateway/debounce.js.map +1 -0
  94. package/dist/gateway/index.d.ts +3 -0
  95. package/dist/gateway/index.d.ts.map +1 -0
  96. package/dist/gateway/index.js +3 -0
  97. package/dist/gateway/index.js.map +1 -0
  98. package/dist/gateway/middleware-setup.d.ts +12 -0
  99. package/dist/gateway/middleware-setup.d.ts.map +1 -0
  100. package/dist/gateway/middleware-setup.js +156 -0
  101. package/dist/gateway/middleware-setup.js.map +1 -0
  102. package/dist/index.d.ts +14 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +187 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/middleware/attachment.d.ts +11 -0
  107. package/dist/middleware/attachment.d.ts.map +1 -0
  108. package/dist/middleware/attachment.js +17 -0
  109. package/dist/middleware/attachment.js.map +1 -0
  110. package/dist/middleware/media-history.d.ts +30 -0
  111. package/dist/middleware/media-history.d.ts.map +1 -0
  112. package/dist/middleware/media-history.js +54 -0
  113. package/dist/middleware/media-history.js.map +1 -0
  114. package/dist/middleware/sticker-capture.d.ts +17 -0
  115. package/dist/middleware/sticker-capture.d.ts.map +1 -0
  116. package/dist/middleware/sticker-capture.js +97 -0
  117. package/dist/middleware/sticker-capture.js.map +1 -0
  118. package/dist/model/index.d.ts +10 -0
  119. package/dist/model/index.d.ts.map +1 -0
  120. package/dist/model/index.js +9 -0
  121. package/dist/model/index.js.map +1 -0
  122. package/dist/model/model-resolver.d.ts +88 -0
  123. package/dist/model/model-resolver.d.ts.map +1 -0
  124. package/dist/model/model-resolver.js +151 -0
  125. package/dist/model/model-resolver.js.map +1 -0
  126. package/dist/model/prefs-store.d.ts +24 -0
  127. package/dist/model/prefs-store.d.ts.map +1 -0
  128. package/dist/model/prefs-store.js +126 -0
  129. package/dist/model/prefs-store.js.map +1 -0
  130. package/dist/model/settings-reader.d.ts +19 -0
  131. package/dist/model/settings-reader.d.ts.map +1 -0
  132. package/dist/model/settings-reader.js +76 -0
  133. package/dist/model/settings-reader.js.map +1 -0
  134. package/dist/model/types.d.ts +15 -0
  135. package/dist/model/types.d.ts.map +1 -0
  136. package/dist/model/types.js +5 -0
  137. package/dist/model/types.js.map +1 -0
  138. package/dist/session/idle-evictor.d.ts +18 -0
  139. package/dist/session/idle-evictor.d.ts.map +1 -0
  140. package/dist/session/idle-evictor.js +31 -0
  141. package/dist/session/idle-evictor.js.map +1 -0
  142. package/dist/session/index.d.ts +9 -0
  143. package/dist/session/index.d.ts.map +1 -0
  144. package/dist/session/index.js +8 -0
  145. package/dist/session/index.js.map +1 -0
  146. package/dist/session/session-manager.d.ts +97 -0
  147. package/dist/session/session-manager.d.ts.map +1 -0
  148. package/dist/session/session-manager.js +624 -0
  149. package/dist/session/session-manager.js.map +1 -0
  150. package/dist/session/types.d.ts +130 -0
  151. package/dist/session/types.d.ts.map +1 -0
  152. package/dist/session/types.js +2 -0
  153. package/dist/session/types.js.map +1 -0
  154. package/dist/session/workspace-attach.d.ts +22 -0
  155. package/dist/session/workspace-attach.d.ts.map +1 -0
  156. package/dist/session/workspace-attach.js +21 -0
  157. package/dist/session/workspace-attach.js.map +1 -0
  158. package/dist/setup.d.ts +25 -0
  159. package/dist/setup.d.ts.map +1 -0
  160. package/dist/setup.js +198 -0
  161. package/dist/setup.js.map +1 -0
  162. package/dist/shared/index.d.ts +7 -0
  163. package/dist/shared/index.d.ts.map +1 -0
  164. package/dist/shared/index.js +7 -0
  165. package/dist/shared/index.js.map +1 -0
  166. package/dist/shared/scope.d.ts +14 -0
  167. package/dist/shared/scope.d.ts.map +1 -0
  168. package/dist/shared/scope.js +12 -0
  169. package/dist/shared/scope.js.map +1 -0
  170. package/dist/shared/send-helper.d.ts +11 -0
  171. package/dist/shared/send-helper.d.ts.map +1 -0
  172. package/dist/shared/send-helper.js +13 -0
  173. package/dist/shared/send-helper.js.map +1 -0
  174. package/dist/shared/utils.d.ts +28 -0
  175. package/dist/shared/utils.d.ts.map +1 -0
  176. package/dist/shared/utils.js +143 -0
  177. package/dist/shared/utils.js.map +1 -0
  178. package/dist/transport/attachment.d.ts +18 -0
  179. package/dist/transport/attachment.d.ts.map +1 -0
  180. package/dist/transport/attachment.js +106 -0
  181. package/dist/transport/attachment.js.map +1 -0
  182. package/dist/transport/chunker.d.ts +14 -0
  183. package/dist/transport/chunker.d.ts.map +1 -0
  184. package/dist/transport/chunker.js +78 -0
  185. package/dist/transport/chunker.js.map +1 -0
  186. package/dist/transport/events.d.ts +80 -0
  187. package/dist/transport/events.d.ts.map +1 -0
  188. package/dist/transport/events.js +62 -0
  189. package/dist/transport/events.js.map +1 -0
  190. package/dist/transport/inbound.d.ts +8 -0
  191. package/dist/transport/inbound.d.ts.map +1 -0
  192. package/dist/transport/inbound.js +292 -0
  193. package/dist/transport/inbound.js.map +1 -0
  194. package/dist/transport/index.d.ts +10 -0
  195. package/dist/transport/index.d.ts.map +1 -0
  196. package/dist/transport/index.js +10 -0
  197. package/dist/transport/index.js.map +1 -0
  198. package/dist/transport/inject-rules.d.ts +24 -0
  199. package/dist/transport/inject-rules.d.ts.map +1 -0
  200. package/dist/transport/inject-rules.js +72 -0
  201. package/dist/transport/inject-rules.js.map +1 -0
  202. package/dist/transport/outbound-buffer.d.ts +56 -0
  203. package/dist/transport/outbound-buffer.d.ts.map +1 -0
  204. package/dist/transport/outbound-buffer.js +131 -0
  205. package/dist/transport/outbound-buffer.js.map +1 -0
  206. package/dist/transport/outbound.d.ts +30 -0
  207. package/dist/transport/outbound.d.ts.map +1 -0
  208. package/dist/transport/outbound.js +139 -0
  209. package/dist/transport/outbound.js.map +1 -0
  210. package/dist/transport/rich-media.d.ts +41 -0
  211. package/dist/transport/rich-media.d.ts.map +1 -0
  212. package/dist/transport/rich-media.js +104 -0
  213. package/dist/transport/rich-media.js.map +1 -0
  214. package/dist/transport/streaming-writer.d.ts +47 -0
  215. package/dist/transport/streaming-writer.d.ts.map +1 -0
  216. package/dist/transport/streaming-writer.js +110 -0
  217. package/dist/transport/streaming-writer.js.map +1 -0
  218. package/dist/transport/tool-presenter.d.ts +38 -0
  219. package/dist/transport/tool-presenter.d.ts.map +1 -0
  220. package/dist/transport/tool-presenter.js +121 -0
  221. package/dist/transport/tool-presenter.js.map +1 -0
  222. package/dist/types.d.ts +45 -0
  223. package/dist/types.d.ts.map +1 -0
  224. package/dist/types.js +5 -0
  225. package/dist/types.js.map +1 -0
  226. package/package.json +106 -0
  227. package/settings-host.js +927 -0
@@ -0,0 +1,1463 @@
1
+ /* qqbot-settings — browser half (hand-written __ModuleLoader__ bundle, no build step).
2
+ * 在设置页新增「QQ 机器人 (im-qqbot)」整页: 读/写 im-qqbot settings ns,
3
+ * 数据经 host 半边同源路由 /api/qqbot-settings/{read,update}。
4
+ */
5
+ window.__ModuleLoader__.load({
6
+ id: '@zaofan/dsh-qqbot',
7
+ factory: (require) => {
8
+ var module = { exports: {} }
9
+ var exports = module.exports
10
+ var React = require('react')
11
+ var h = React.createElement
12
+ var useState = React.useState
13
+ var useEffect = React.useEffect
14
+
15
+ var READ = '/api/qqbot-settings/read'
16
+ var UPDATE = '/api/qqbot-settings/update'
17
+
18
+ var labelStyle = { display: 'block', margin: '6px 0', fontSize: 13 }
19
+ var inputStyle = { marginLeft: 8, padding: '2px 6px', border: '1px solid #8884', borderRadius: 4, background: 'transparent', color: 'inherit', width: 110 }
20
+ var wideStyle = { ...inputStyle, width: 260 }
21
+ var fullStyle = { ...inputStyle, width: '92%', display: 'block', margin: '4px 0 8px' }
22
+ var boxStyle = { border: '1px solid #e5e6eb', borderRadius: 8, padding: '10px 14px 12px', margin: '8px 0', background: '#fff' }
23
+ var sectionTitle = { fontWeight: 600, margin: '10px 0 4px', fontSize: 14, color: '#1f2329' }
24
+
25
+ // ── 迷你设计系统(2026-09-05 专家团定稿; 深色柔和适配宿主, 不刺眼) ──
26
+ // 宿主 dsh web 为深色主题 → 卡片用半透明深色、文字浅色、主色用低饱和紫(贴合 #9775fa 系)
27
+ var T = {
28
+ bg: 'transparent', card: '#ffffff', cardHi: '#fafafa', cardSolid: '#f4f5f8', border: '#e2e5ea', borderLight: '#eef0f3',
29
+ text: '#1f2329', text2: '#4e5560', text3: '#8a919c',
30
+ primary: '#7c6cf0', primary2: '#6b5ae6', primaryBg: '#7c6cf01a', primaryBorder: '#7c6cf04d',
31
+ danger: '#e5484d', dangerBg: '#e5484d14',
32
+ success: '#1f9e4f', successBg: '#1f9e4f14',
33
+ warn: '#d97706', warnBg: '#d9770614',
34
+ radius: 6, radiusLg: 10,
35
+ shadow: 'none',
36
+ fs: 13, fsSm: 12, fsTitle: 14,
37
+ }
38
+ var pageBg = { background: T.bg, borderRadius: T.radiusLg, padding: '12px 16px', minHeight: 300 }
39
+ var card = { background: T.card, border: '1px solid ' + T.border, borderRadius: T.radius }
40
+ function btnStyle(variant, size) {
41
+ var base = {
42
+ border: '1px solid ' + T.border, borderRadius: T.radius, cursor: 'pointer', font: 'inherit',
43
+ padding: size === 'sm' ? '2px 10px' : '4px 14px', fontSize: size === 'sm' ? 12 : 13, lineHeight: '20px', whiteSpace: 'nowrap',
44
+ transition: 'background .15s, border-color .15s, color .15s',
45
+ }
46
+ if (variant === 'primary') return { ...base, background: T.primaryBg, color: T.primary, borderColor: T.primaryBorder, fontWeight: 600 }
47
+ if (variant === 'danger') return { ...base, background: T.dangerBg, color: T.danger, borderColor: '#f7656044', fontWeight: 600 }
48
+ if (variant === 'ghost') return { ...base, background: 'transparent', color: T.text2 }
49
+ return { ...base, background: T.card, color: T.text }
50
+ }
51
+ function SButton(props) {
52
+ return h('button', { className: 'qqs-btn', style: btnStyle(props.variant, props.size), disabled: props.disabled, onClick: props.onClick }, props.children)
53
+ }
54
+ // 状态条: tone=info/danger/success/warn; 深色柔底 + 左圆标; action=可选右侧行动按钮
55
+ function StatusBar(props) {
56
+ var m = { info: [T.primaryBg, T.primary], danger: [T.dangerBg, T.danger], success: [T.successBg, T.success], warn: [T.warnBg, T.warn] }[props.tone || 'info'] || [T.primaryBg, T.primary]
57
+ var icon = props.tone === 'danger' ? '!' : props.tone === 'success' ? '✓' : props.tone === 'warn' ? '⚠' : 'i'
58
+ return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, background: m[0], border: '1px solid ' + m[1] + '44', borderRadius: T.radius, padding: '6px 12px', margin: '8px 0', fontSize: T.fsSm } },
59
+ h('span', { style: { flexShrink: 0, width: 18, height: 18, borderRadius: '50%', background: m[1], color: '#fff', textAlign: 'center', lineHeight: '18px', fontSize: 11, fontWeight: 700 } }, icon),
60
+ h('span', { style: { flex: 1, color: m[1] } }, props.children),
61
+ props.action ? h(SButton, { variant: props.tone === 'danger' ? 'danger' : 'primary', size: 'sm', onClick: props.action.onClick }, props.action.label) : null)
62
+ }
63
+ // 空状态
64
+ function Empty(props) {
65
+ return h('div', { style: { padding: '36px 0', textAlign: 'center', color: T.text3 } },
66
+ h('div', { style: { fontSize: 34, marginBottom: 8, opacity: .5 } }, props.icon || '🕳'),
67
+ h('div', { style: { fontSize: T.fsSm } }, props.text || '暂无数据'))
68
+ }
69
+ // 简易表格: cols=[{key,title,width?,render?}], rows=[]; 空态走 Empty (深色适配)
70
+ function DataTable(props) {
71
+ var cols = props.cols || []
72
+ var rows = props.rows || []
73
+ if (rows.length === 0) return h(Empty, { text: props.emptyText || '暂无数据' })
74
+ return h('div', { style: { border: '1px solid #dde1e7', borderRadius: T.radius, overflow: 'hidden', background: T.card } },
75
+ h('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: T.fsSm } },
76
+ h('thead', null, h('tr', { style: { background: T.cardHi } },
77
+ cols.map(function (c) { return h('th', { key: c.key, style: { padding: '8px 10px', textAlign: 'left', fontWeight: 600, color: T.text2, borderBottom: '1px solid #dde1e7', width: c.width || undefined } }, c.title) }))),
78
+ h('tbody', null, rows.map(function (r, i) {
79
+ return h('tr', { key: i, style: { background: i % 2 ? T.card : T.cardHi } },
80
+ cols.map(function (c) {
81
+ var v = r[c.key]
82
+ var cell = c.render ? c.render(v, r) : (v === null || v === undefined ? '' : String(v))
83
+ return h('td', { key: c.key, style: { padding: '8px 10px', borderBottom: '1px solid #eef0f3', color: T.text } }, cell)
84
+ }))
85
+ }))))
86
+ }
87
+ // 横排 TabBar: items=[{key,label,badge?}]
88
+ function TabBar(props) {
89
+ return h('div', { style: { display: 'flex', gap: 2, borderBottom: '1px solid ' + T.border, marginBottom: 4 } },
90
+ (props.items || []).map(function (it) {
91
+ var on = it.key === props.value
92
+ return h('div', { key: it.key, onClick: function () { props.onChange && props.onChange(it.key) }, style: { cursor: 'pointer', padding: '8px 14px', fontSize: T.fsSm, color: on ? T.primary : T.text2, fontWeight: on ? 600 : 400, borderBottom: on ? '2px solid ' + T.primary : '2px solid transparent', marginBottom: -1, display: 'flex', alignItems: 'center', gap: 6 } },
93
+ it.label,
94
+ it.badge > 0 ? h('span', { style: { background: T.danger, color: '#fff', borderRadius: 9, padding: '0 6px', fontSize: 11, lineHeight: '16px', fontWeight: 700 } }, it.badge) : null)
95
+ }))
96
+ }
97
+
98
+ // 可搜索选择器(选聊天对象用): 点击弹出候选浮层, 输入即过滤; 选中回调 onPick({scope,id,name})
99
+ function TargetPicker(props) {
100
+ var [open, setOpen] = useState(false)
101
+ var [q, setQ] = useState('')
102
+ var val = props.value || {}
103
+ function cands() {
104
+ var all = props.options || []
105
+ var t = q.trim().toLowerCase()
106
+ if (!t) return all
107
+ return all.filter(function (c) { return (c.name || '').toLowerCase().indexOf(t) >= 0 || String(c.id).toLowerCase().indexOf(t) >= 0 })
108
+ }
109
+ var gs = cands().filter(function (c) { return c.scope === 'group' })
110
+ var cs = cands().filter(function (c) { return c.scope === 'c2c' })
111
+ var label = val.name || (val.targetId ? String(val.targetId).slice(0, 8) : '')
112
+ var sec = { fontSize: 10, color: T.text3, padding: '3px 6px 1px', fontWeight: 600 }
113
+ var item = { cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: T.fsSm, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
114
+ function pick(c) { setOpen(false); setQ(''); props.onPick && props.onPick({ scope: c.scope, id: c.id, name: c.name || '' }) }
115
+ return h('div', { style: { position: 'relative', flex: 1, minWidth: 0 } },
116
+ h('div', { onClick: function () { setOpen(!open); setQ('') }, style: { cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5, padding: '2px 8px', minHeight: 22, border: '1px solid ' + T.border, borderRadius: T.radius, background: T.card, fontSize: T.fsSm, color: label ? T.text : T.text3, userSelect: 'none' } },
117
+ h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, label ? '『' + label + '』' : '(未选,点此搜索)'),
118
+ h('span', { style: { fontSize: 10, color: T.text3 } }, '▾')),
119
+ open ? h('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 60, minWidth: 250, marginTop: 2, background: T.card, border: '1px solid ' + T.border, borderRadius: T.radius, boxShadow: '0 6px 18px rgba(0,0,0,.12)', padding: 6 } },
120
+ h('input', { className: 'qqs-inp', style: { width: '100%', boxSizing: 'border-box', padding: '3px 8px', marginBottom: 4, fontSize: T.fsSm }, autoFocus: true, placeholder: '搜群名 / 昵称 / ID…', value: q, onChange: function (e) { setQ(e.target.value) } }),
121
+ gs.length + cs.length === 0 ? h('div', { style: { padding: '8px 6px', color: T.text3, fontSize: T.fsSm, textAlign: 'center' } }, '没有匹配的聊天') : null,
122
+ gs.length ? h('div', { style: sec }, '群') : null,
123
+ gs.map(function (c) { return h('div', { key: 'g:' + c.id, style: item, onMouseDown: function (ev) { ev.preventDefault(); pick(c) }, onMouseEnter: function (e) { e.currentTarget.style.background = T.cardHi }, onMouseLeave: function (e) { e.currentTarget.style.background = 'transparent' } }, '👥 ' + (c.name || c.id)) }),
124
+ cs.length ? h('div', { style: sec }, '私聊') : null,
125
+ cs.map(function (c) { return h('div', { key: 'c:' + c.id, style: item, onMouseDown: function (ev) { ev.preventDefault(); pick(c) }, onMouseEnter: function (e) { e.currentTarget.style.background = T.cardHi }, onMouseLeave: function (e) { e.currentTarget.style.background = 'transparent' } }, '💬 ' + (c.name || c.id)) })) : null)
126
+ }
127
+
128
+ // 群聊守则"恢复默认"用: 与主包 dsh-qqbot config.ts 的 DEFAULT_GROUP_PROMPT 保持一致。
129
+ // ⚠️ 若改主包默认文本, 必须同步改这里(唯一同步点)。
130
+ var DEFAULT_GROUP_PROMPT = [
131
+ '【表情包礼仪】你有一座本地表情包库(群友存的图,带标签)。想用表情包回应时先调 list_stickers 搜库,命中后再用 send_media 发出,别发没把握的图。',
132
+ '只在群里正热闹、情绪正浓时,才在回复末尾附 0~1 张:满屏哈哈/笑死(爆笑)、大家"确实/我也是"(共鸣)、有人抛梗你能接住(接梗)、有人当面夸你(被夸)、轻度吐槽可以接治愈/滑稽图。',
133
+ '绝不发:冷场没人接话、正事/技术问答/找资源、吵架互怼中、对方难过或聊严肃事、私聊。',
134
+ '出手前自检,缺一不发:①库里有 9 成贴切的图;②发出来群友会心一笑;③这轮没发过、今天这群没刷过图。',
135
+ '把图删掉话照样完整;一次最多一张;发离谱/冒犯/色气/羞辱人的图=人设崩塌,永远不许。',
136
+ ].join('\n')
137
+
138
+ function nv(v) { return typeof v === 'number' && isFinite(v) ? v : 0 }
139
+ function sv(v) { return typeof v === 'string' ? v : '' }
140
+
141
+ function BoolRow(props) {
142
+ return h('label', { style: labelStyle },
143
+ props.label,
144
+ h('input', { className: 'qqs-inp',
145
+ type: 'checkbox', style: inputStyle, checked: !!props.value,
146
+ onChange: (e) => props.onChange(e.target.checked),
147
+ }))
148
+ }
149
+ function NumRow(props) {
150
+ return h('label', { style: labelStyle },
151
+ props.label,
152
+ h('input', { className: 'qqs-inp',
153
+ type: 'number', style: inputStyle, value: nv(props.value),
154
+ onChange: (e) => props.onChange(Number(e.target.value)),
155
+ }))
156
+ }
157
+ function StrRow(props) {
158
+ return h('label', { style: labelStyle },
159
+ props.label,
160
+ h('input', { className: 'qqs-inp', style: props.wide ? wideStyle : inputStyle, value: sv(props.value), onChange: (e) => props.onChange(e.target.value) }))
161
+ }
162
+
163
+ function RuleEditor(props) {
164
+ var r = props.rule
165
+ function upd(patch) { props.onChange({ ...r, ...patch }) }
166
+ function condUpd(patch) { upd({ conditions: { ...r.conditions, ...patch } }) }
167
+ return h('div', { style: boxStyle },
168
+ h('label', { style: labelStyle },
169
+ h('input', { className: 'qqs-inp',
170
+ type: 'checkbox', checked: !!r.enabled,
171
+ onChange: (e) => upd({ enabled: e.target.checked }),
172
+ }), ' 启用这条提醒 ' + ((r.name || r.id) + (r.name && r.name !== r.id ? ' (' + r.id + ')' : ''))),
173
+ h('label', { style: labelStyle },
174
+ h('input', { className: 'qqs-inp',
175
+ type: 'checkbox', checked: !!r.conditions.hasImage,
176
+ onChange: (e) => condUpd({ hasImage: e.target.checked }),
177
+ }), ' 消息里带图片 ',
178
+ h('input', { className: 'qqs-inp',
179
+ type: 'checkbox', checked: !!r.conditions.hasLink,
180
+ onChange: (e) => condUpd({ hasLink: e.target.checked }),
181
+ }), ' 消息里带链接 '),
182
+ h('label', { style: labelStyle }, ' 按固定写法匹配文字(高手用,留空=不用): ',
183
+ h('input', { className: 'qqs-inp', style: wideStyle, value: sv(r.conditions.contentRegex), placeholder: '如 ^早安', onChange: (e) => condUpd({ contentRegex: e.target.value }) })),
184
+ h('label', { style: labelStyle }, ' 出现这些词就触发(逗号分隔): ',
185
+ h('input', { className: 'qqs-inp', style: wideStyle, value: (r.conditions.contentKeywords || []).join(','), onChange: (e) => condUpd({ contentKeywords: e.target.value.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) })),
186
+ h('label', { style: labelStyle }, ' 多个条件怎么算: ',
187
+ h('select', { style: inputStyle, value: r.conditions.matchScope || 'any', onChange: (e) => condUpd({ matchScope: e.target.value }) },
188
+ h('option', { value: 'any' }, '满足一个就行'),
189
+ h('option', { value: 'all' }, '全部都要满足'))),
190
+ h('div', { style: labelStyle }, '要提醒她的内容:'),
191
+ h('textarea', { className: 'qqs-area',
192
+ style: fullStyle, rows: 2, value: sv(r.prompt),
193
+ placeholder: '例:上方含链接,引用前先核实',
194
+ onChange: (e) => upd({ prompt: e.target.value }),
195
+ }),
196
+ h('button', { className: 'qqs-btn', style: { marginTop: 4 }, onClick: () => props.onRemove(r.id) }, '删除这条提醒'))
197
+ }
198
+
199
+ function SettingsPage(props) {
200
+ var close = props && props.close
201
+ // 多账号: 当前操作账号的 settings 命名空间(主=im-qqbot 不带参, 其它=实例 id)
202
+ var _acctNs = props && props.acct && props.acct.ns && props.acct.ns !== 'im-qqbot' ? String(props.acct.ns) : ''
203
+ var [cfg, setCfg] = useState(null)
204
+ var [rev, setRev] = useState(undefined)
205
+ var [msg, setMsg] = useState('')
206
+
207
+ function load() {
208
+ setMsg('加载中…')
209
+ fetch(READ + (_acctNs ? '?ns=' + encodeURIComponent(_acctNs) : '')).then(function (r) { return r.json() }).then(function (d) {
210
+ if (d && d.value) {
211
+ var v = d.value
212
+ var base = {
213
+ behavior: v.behavior || {},
214
+ sticker: v.sticker || {},
215
+ injectRules: Array.isArray(v.injectRules) ? v.injectRules : [],
216
+ schedule: v.schedule && Array.isArray(v.schedule.targets) ? v.schedule : { targets: [] },
217
+ // groupPrompt 必须读回来, 否则每次保存都会把它清空成 ''
218
+ // undefined(从未设置)→ 显示默认守则; ''(用户明确清空)→ 保持空(无守则)
219
+ groupPrompt: typeof v.groupPrompt === 'string' ? v.groupPrompt : DEFAULT_GROUP_PROMPT,
220
+ enableApprovals: v.enableApprovals === true,
221
+ approvalTimeoutMs: typeof v.approvalTimeoutMs === 'number' ? v.approvalTimeoutMs : 120000,
222
+ groupAdmin: { enabled: v.groupAdmin && v.groupAdmin.enabled === true, owners: Array.isArray(v.groupAdmin && v.groupAdmin.owners) ? v.groupAdmin.owners : [], manageGroup: v.groupAdmin && typeof v.groupAdmin.manageGroup === 'string' ? v.groupAdmin.manageGroup : '', watchJoinRequests: !!(v.groupAdmin && v.groupAdmin.watchJoinRequests), notifyInGroup: v.groupAdmin && v.groupAdmin.notifyInGroup !== false },
223
+ }
224
+ setCfg(base); setRev(d.revision); setMsg('')
225
+ } else { setMsg('读取失败: ' + JSON.stringify(d)) }
226
+ }).catch(function (e) { setMsg('读取异常: ' + e.message) })
227
+ }
228
+ useEffect(function () { load() }, [])
229
+
230
+ if (!cfg) return h('div', null, h('p', null, msg || '加载中…'))
231
+
232
+ var gates = cfg.sticker.gates || {}
233
+ var dbc = cfg.behavior.debounce || {} // 延迟聚合(后端未设置时缺省回落默认值)
234
+ function setBehavior(p) { setCfg(function (c) { return { ...c, behavior: { ...c.behavior, ...p } } }) }
235
+ function setGates(p) { setCfg(function (c) { return { ...c, sticker: { ...c.sticker, gates: { ...(c.sticker.gates || {}), ...p } } } }) }
236
+ function setSticker(p) { setCfg(function (c) { return { ...c, sticker: { ...(c.sticker || {}), ...p } } }) }
237
+ function setGroupAdmin(p) { setCfg(function (c) { return { ...c, groupAdmin: { ...(c.groupAdmin || { enabled: false, owners: [] }), ...p } } }) }
238
+ function setRules(list) { setCfg(function (c) { return { ...c, injectRules: list } }) }
239
+
240
+ function addRule() {
241
+ var list = (cfg.injectRules || []).slice()
242
+ list.push({
243
+ id: 'rule-' + Date.now().toString(36),
244
+ name: '',
245
+ enabled: true,
246
+ conditions: { hasImage: false, hasLink: false, contentRegex: '', contentKeywords: [], matchScope: 'any' },
247
+ prompt: '',
248
+ })
249
+ setRules(list)
250
+ }
251
+ function updRule(i, r) { var list = cfg.injectRules.slice(); list[i] = r; setRules(list) }
252
+ function rmRule(id) { setRules(cfg.injectRules.filter(function (r) { return r.id !== id })) }
253
+
254
+ // gpOverride: 传入字符串 = 用该守则保存(恢复默认用); undefined = 用当前编辑框内容
255
+ function doSave(gpOverride) {
256
+ setMsg('保存中…')
257
+ var patch = {
258
+ behavior: cfg.behavior,
259
+ sticker: {
260
+ gates: cfg.sticker.gates,
261
+ autoTagEnabled: cfg.sticker.autoTagEnabled,
262
+ visionCli: cfg.sticker.visionCli,
263
+ },
264
+ injectRules: cfg.injectRules,
265
+ // 定时唤醒(④)已并入「定时任务」页编辑; 这里原样带过不丢即可
266
+ schedule: cfg.schedule && Array.isArray(cfg.schedule.targets) ? cfg.schedule : { targets: [] },
267
+ groupPrompt: typeof gpOverride === 'string' ? gpOverride : (typeof cfg.groupPrompt === 'string' ? cfg.groupPrompt : ''),
268
+ enableApprovals: cfg.enableApprovals === true,
269
+ approvalTimeoutMs: typeof cfg.approvalTimeoutMs === 'number' ? cfg.approvalTimeoutMs : 120000,
270
+ groupAdmin: { enabled: cfg.groupAdmin && cfg.groupAdmin.enabled === true, owners: Array.isArray(cfg.groupAdmin && cfg.groupAdmin.owners) ? cfg.groupAdmin.owners : [], manageGroup: cfg.groupAdmin && typeof cfg.groupAdmin.manageGroup === 'string' ? cfg.groupAdmin.manageGroup : '', watchJoinRequests: !!(cfg.groupAdmin && cfg.groupAdmin.watchJoinRequests), notifyInGroup: cfg.groupAdmin && cfg.groupAdmin.notifyInGroup !== false },
271
+ }
272
+ fetch(UPDATE, {
273
+ method: 'POST', headers: { 'content-type': 'application/json' },
274
+ body: JSON.stringify({ patch: patch, expectedRevision: rev, ns: _acctNs || undefined }),
275
+ }).then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
276
+ if (d && d.value) {
277
+ var v2 = d.value
278
+ setCfg({
279
+ behavior: v2.behavior || {}, sticker: v2.sticker || {},
280
+ injectRules: Array.isArray(v2.injectRules) ? v2.injectRules : [],
281
+ schedule: v2.schedule && Array.isArray(v2.schedule.targets) ? v2.schedule : { targets: [] },
282
+ groupPrompt: typeof v2.groupPrompt === 'string' ? v2.groupPrompt : (typeof cfg.groupPrompt === 'string' ? cfg.groupPrompt : DEFAULT_GROUP_PROMPT),
283
+ enableApprovals: v2.enableApprovals === true,
284
+ approvalTimeoutMs: typeof v2.approvalTimeoutMs === 'number' ? v2.approvalTimeoutMs : 120000,
285
+ groupAdmin: { enabled: v2.groupAdmin && v2.groupAdmin.enabled === true, owners: Array.isArray(v2.groupAdmin && v2.groupAdmin.owners) ? v2.groupAdmin.owners : [], manageGroup: v2.groupAdmin && typeof v2.groupAdmin.manageGroup === 'string' ? v2.groupAdmin.manageGroup : '', watchJoinRequests: !!(v2.groupAdmin && v2.groupAdmin.watchJoinRequests), notifyInGroup: v2.groupAdmin && v2.groupAdmin.notifyInGroup !== false },
286
+ })
287
+ setRev(d.revision); setMsg('已保存 ✓(live 生效)')
288
+ }
289
+ else if (d && d.error) { setMsg('保存失败: ' + d.error) }
290
+ else { setMsg('保存失败(未知响应)') }
291
+ }).catch(function (e) { setMsg('保存异常: ' + e.message) })
292
+ }
293
+ function save() { doSave(undefined) }
294
+ // 恢复默认守则: 直接把"表情包礼仪"默认文本写回并保存(不需手动粘贴)
295
+ function restoreDefault() { doSave(DEFAULT_GROUP_PROMPT) }
296
+
297
+ var gh = gates.bannedGroups || []
298
+ return h('div', { style: { maxWidth: 640 } },
299
+ h('h2', null, 'QQ 机器人设置'),
300
+ h('p', { style: { fontSize: 12, color: '#888' } }, '改完点「保存」立刻生效(不用重启)。看不懂的项放着别动就行;想恢复"不限制"就把数字填 0、勾选取消、列表清空。'),
301
+
302
+ h('div', { style: sectionTitle }, '⓪ 群里她要一直记住的守则'),
303
+ h('p', { style: { fontSize: 12, color: '#888' } }, '每轮回复都会带上的一段提醒(所有 QQ 群都生效,不碰你的人格设定)。默认是"表情包礼仪";想让她不主动发图,把这里清空即可(留空=没守则,不会自动填回)。'),
304
+ h('textarea', { className: 'qqs-area',
305
+ style: { ...fullStyle, minHeight: 90 },
306
+ value: sv(cfg.groupPrompt),
307
+ onChange: function (e) { setCfg(function (c) { return { ...c, groupPrompt: e.target.value } }) },
308
+ }),
309
+ h('div', { style: { margin: '0 0 6px' } },
310
+ h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 }, onClick: restoreDefault }, '恢复默认守则(表情包礼仪)')),
311
+
312
+ h('div', { style: sectionTitle }, '① 多久回一次消息'),
313
+ h('p', { style: { fontSize: 12, color: '#888' } }, '控制她回复的速度。全填 0 = 来一条回一条(不限制)。'),
314
+ h('div', { style: boxStyle },
315
+ NumRow({ label: '群里没人 @ 她时,隔几秒才回一次(0=每条都回)', value: cfg.behavior.freeIntervalSec, onChange: function (v) { setBehavior({ freeIntervalSec: v }) } }),
316
+ NumRow({ label: '有人 @ 她时,两次回复至少隔几秒(0=随叫随到)', value: cfg.behavior.mentionIntervalSec, onChange: function (v) { setBehavior({ mentionIntervalSec: v }) } }),
317
+ NumRow({ label: '私聊里隔几秒回一次(0=不限制)', value: cfg.behavior.directIntervalSec, onChange: function (v) { setBehavior({ directIntervalSec: v }) } }),
318
+ h('div', { style: { fontSize: 12, color: '#666', margin: '10px 0 2px' } }, '延迟聚合(另一套机制,和上面冷却不冲突): 她收到消息先等一小会儿, 把连发的话攒一起综合回, 免得只回第一句。'),
319
+ BoolRow({ label: '开启延迟聚合(不勾=回到来一条回一条)', value: dbc.enabled !== false, onChange: function (v) { setBehavior({ debounce: { ...dbc, enabled: v } }) } }),
320
+ NumRow({ label: '对方停口几秒后她才开口(默认3;0=不停顿)', value: dbc.silenceSec != null ? dbc.silenceSec : 3, onChange: function (v) { setBehavior({ debounce: { ...dbc, silenceSec: v } }) } }),
321
+ NumRow({ label: '攒满几条立即开口,不等对方停(默认10)', value: dbc.maxMsgs != null ? dbc.maxMsgs : 10, onChange: function (v) { setBehavior({ debounce: { ...dbc, maxMsgs: v } }) } }),
322
+ BoolRow({ label: '有人 @ 她时也走延迟(不勾=@到秒回)', value: dbc.mentionDelayed !== false, onChange: function (v) { setBehavior({ debounce: { ...dbc, mentionDelayed: v } }) } })),
323
+
324
+ h('div', { style: sectionTitle }, '② 发表情包的限制(全默认不限制)'),
325
+ h('p', { style: { fontSize: 12, color: '#888' } }, '防止她聊天时表情包刷屏;下面两项是"她主动发图"时才用——群里很热闹才发,冷清就憋着。不想管就保持全 0,也别勾总开关。'),
326
+ h('div', { style: boxStyle },
327
+ BoolRow({ label: '开启表情包限制(不勾=完全不限制)', value: gates.enabled, onChange: function (v) { setGates({ enabled: v }) } }),
328
+ NumRow({ label: '她一次回复最多带几张图(0=不限)', value: gates.perTurnMax, onChange: function (v) { setGates({ perTurnMax: v }) } }),
329
+ NumRow({ label: '每隔"一小段时间"最多发几张(0=不限)', value: gates.maxPerWindow, onChange: function (v) { setGates({ maxPerWindow: v }) } }),
330
+ NumRow({ label: '上面说的"一小段时间"是多长(秒,如600=10分钟)', value: gates.perWindowSec, onChange: function (v) { setGates({ perWindowSec: v }) } }),
331
+ NumRow({ label: '每个群一天最多发几张表情包(0=不限)', value: gates.dailyBudgetPerGroup, onChange: function (v) { setGates({ dailyBudgetPerGroup: v }) } }),
332
+ NumRow({ label: '同一张图多久内不许重复发(小时,0=不查)', value: gates.dupTTLHours, onChange: function (v) { setGates({ dupTTLHours: v }) } }),
333
+ NumRow({ label: '看"最近"多长时间的聊天来判热闹(3600=最近1小时)', value: gates.activityWindowSec, onChange: function (v) { setGates({ activityWindowSec: v }) } }),
334
+ NumRow({ label: '最近这段时间群消息达到几条,她才肯主动发图(0=不管冷不冷都发)', value: gates.activityMinMsgs, onChange: function (v) { setGates({ activityMinMsgs: v }) } }),
335
+ StrRow({ label: '禁止发图的群(填群ID,逗号分隔;一般不用填)', value: gh.join(','), wide: true, onChange: function (v) { setGates({ bannedGroups: v.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) } })),
336
+ h('div', { style: boxStyle },
337
+ BoolRow({ label: '新图自动后台识图打标(会花视觉额度;不勾=全靠人工手动补)', value: !!cfg.sticker.autoTagEnabled, onChange: function (v) { setSticker({ autoTagEnabled: v }) } }),
338
+ StrRow({ label: '视觉引擎命令(高级;留空自动探测)', value: sv(cfg.sticker.visionCli), wide: true, onChange: function (v) { setSticker({ visionCli: v }) } })),
339
+
340
+ h('div', { style: sectionTitle }, '③ 自定义小提醒(高级)'),
341
+ h('p', { style: { fontSize: 12, color: '#888' } }, '当群友发的消息满足下面条件,就偷偷给机器人加一条要照做的提醒。比如:勾上"含链接",提醒写"引用前先核实"。'),
342
+ (cfg.injectRules || []).map(function (r, i) {
343
+ return h(RuleEditor, { key: r.id, rule: r, onChange: function (nr) { updRule(i, nr) }, onRemove: rmRule })
344
+ }),
345
+ h('button', { className: 'qqs-btn', onClick: addRule }, '+ 添加一条提醒'),
346
+
347
+ h('div', { style: sectionTitle }, '④ 定时唤醒'),
348
+ h('p', { style: { fontSize: 12, color: '#888' } }, '已合并到「定时任务」页(顶部 tab)一起编辑——到点主动开口的群/人分组,与她答应你的定时提醒,都在那边管理。'),
349
+
350
+ h('div', { style: sectionTitle }, '⑤ QQ 远程审批(在 QQ 里放行 dsh 权限申请)'),
351
+ h('p', { style: { fontSize: 12, color: '#888' } }, '机器人的工具要动"工作区外"的东西时,dsh 会申请权限。开启后审批请求直接发到你的 QQ(私聊/群聊看你从哪发起),回 /approve 验证码 放行、/deny 拒绝——只放行这一次,验证码一次性,只有你能批。'),
352
+ h('div', { style: boxStyle },
353
+ BoolRow({ label: '开启 QQ 远程审批(不勾=保持默认审批方式)', value: cfg.enableApprovals === true, onChange: function (v) { setCfg(function (c) { return { ...c, enableApprovals: v } }) } }),
354
+ NumRow({ label: '审批等待秒数(超时自动拒绝;默认120)', value: Math.round((cfg.approvalTimeoutMs || 120000) / 1000), onChange: function (v) { setCfg(function (c) { return { ...c, approvalTimeoutMs: v * 1000 } }) } })),
355
+
356
+ h('div', { style: { ...card, margin: '10px 0', padding: '12px 16px' } },
357
+ h('div', { style: { fontWeight: 700, fontSize: 13, marginBottom: 2 } }, '⚙ 群管理基础设置'),
358
+ h('p', { style: { fontSize: 12, color: T.text3, margin: '0 0 6px' } }, '开启后 QQ 会话里可用群管理工具; 踢人/成员列表等官方未开放能力会在下方 Tab 里提示等待公测。'),
359
+ BoolRow({ label: '开启 QQ 群管理(不勾=群管理工具不可用)', value: cfg.groupAdmin && cfg.groupAdmin.enabled === true, onChange: function (v) { setGroupAdmin({ enabled: v }) } }),
360
+ StrRow({ label: '允许操作的主人 openid(逗号分隔,可留空=不校验)', value: (cfg.groupAdmin && Array.isArray(cfg.groupAdmin.owners) ? cfg.groupAdmin.owners : []).join(','), wide: true, onChange: function (v) { setGroupAdmin({ owners: v.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) } }),
361
+ StrRow({ label: '对话内默认管理群 group_openid(web 对话时群工具用它; QQ 群会话自动用当前群)', value: (cfg.groupAdmin && typeof cfg.groupAdmin.manageGroup === 'string' ? cfg.groupAdmin.manageGroup : ''), wide: true, onChange: function (v) { setGroupAdmin({ manageGroup: v.trim() }) } }),
362
+ BoolRow({ label: '实时接收"入群申请"事件并自动提醒(勾选后需重启才生效)', value: !!(cfg.groupAdmin && cfg.groupAdmin.watchJoinRequests), onChange: function (v) { setGroupAdmin({ watchJoinRequests: v }) } }),
363
+ BoolRow({ label: '收到入群申请时在该群内发提醒消息', value: !(cfg.groupAdmin && cfg.groupAdmin.notifyInGroup === false), onChange: function (v) { setGroupAdmin({ notifyInGroup: v }) } })),
364
+ h(GroupAdminPanel, { key: 'ga-' + (_acctNs || 'main'), ns: _acctNs || undefined }),
365
+
366
+ h('div', { style: { margin: '12px 0' } },
367
+ h('button', { className: 'qqs-btn', style: { marginRight: 8 }, onClick: save }, '保存'),
368
+ h('button', { className: 'qqs-btn', onClick: load }, '放弃修改(重新读取)')),
369
+ msg ? h('p', { style: { fontSize: 13, color: '#2f9e44' } }, msg) : null,
370
+ close ? h('button', { className: 'qqs-btn', onClick: close, style: { float: 'right' } }, '完成') : null)
371
+ }
372
+
373
+ // ── ⑥ QQ 群管理(单开大卡片; P3+ 设计系统版: 横排 Tab + 状态条 + 表格 + 空状态 + 预留接口位) ──
374
+ // 能力就绪度: 官方未开放位保留(红条+行动), 不置灰糊弄; 业务名词做 Tab 骨架。
375
+ var GROUP_TABS = [
376
+ { key: 'join', label: '入群审批', open: true },
377
+ { key: 'mute', label: '禁言', open: true },
378
+ { key: 'members', label: '成员信息', open: false, gateHuman: '获取群成员列表: 官方尚未开放(内邀中), 等待公测后自动可用' },
379
+ { key: 'blacklist', label: '黑名单', open: false, gateHuman: '群黑名单: 官方接口尚未开放, 等待公测后自动可用' },
380
+ ]
381
+ function GroupAdminPanel(props) {
382
+ var nsq = props && props.ns ? '?ns=' + encodeURIComponent(props.ns) : ''
383
+ var [accts, setAccts] = useState(null)
384
+ var [gid, setGid] = useState('')
385
+ var [tab, setTab] = useState('join')
386
+ var [joins, setJoins] = useState(null)
387
+ var [mute, setMute] = useState(null)
388
+ var [msg, setMsg] = useState('')
389
+ var [busy, setBusy] = useState('')
390
+ var [bindGid, setBindGid] = useState('')
391
+ var [bindName, setBindName] = useState('')
392
+ var [members, setMembers] = useState(null)
393
+ var [muteSecs, setMuteSecs] = useState('60')
394
+ var [muteTarget, setMuteTarget] = useState('')
395
+
396
+ function loadMembers() {
397
+ if (!gid) return
398
+ fetch('/api/qqbot-settings/group/members_local' + nsq + (nsq ? '&' : '?') + 'gid=' + encodeURIComponent(gid)).then(function (r) { return r.json() }).then(function (d) {
399
+ setMembers(d && d.ok ? (d.members || []) : [])
400
+ }).catch(function () { setMembers([]) })
401
+ }
402
+
403
+ function loadAccounts() {
404
+ fetch('/api/qqbot-settings/group/accounts' + nsq).then(function (r) { return r.json() }).then(function (d) {
405
+ if (d && d.error) { setMsg('群面板: ' + d.error); setAccts([]); return }
406
+ var list = (d && Array.isArray(d.groups) ? d.groups : []).map(function (g) { return { gid: g.gid, name: g.name || '', from: g.from || '' } })
407
+ setAccts(list)
408
+ if (list.length && !gid) setGid(list[0].gid)
409
+ else if (list.length === 0) { setJoins(null); setMute(null) }
410
+ }).catch(function (e) { setMsg('群列表加载失败: ' + e.message) })
411
+ }
412
+ function refresh() {
413
+ if (!gid) return
414
+ if (tab === 'join') {
415
+ fetch('/api/qqbot-settings/group/join_requests' + nsq + (nsq ? '&' : '?') + 'gid=' + encodeURIComponent(gid)).then(function (r) { return r.json() }).then(function (d) {
416
+ if (d && d.ok) setJoins(d); else setMsg((d && d.err && d.err.human) || '申请列表加载失败')
417
+ }).catch(function (e) { setMsg('申请列表加载异常: ' + e.message) })
418
+ } else if (tab === 'mute') {
419
+ fetch('/api/qqbot-settings/group/mute_state' + nsq + (nsq ? '&' : '?') + 'gid=' + encodeURIComponent(gid)).then(function (r) { return r.json() }).then(function (d) {
420
+ if (d && d.ok) setMute(d); else setMsg((d && d.err && d.err.human) || '禁言状态加载失败')
421
+ }).catch(function (e) { setMsg('禁言状态加载异常: ' + e.message) })
422
+ }
423
+ }
424
+ useEffect(function () { loadAccounts() }, [])
425
+ useEffect(function () { refresh(); loadMembers() }, [gid, tab])
426
+
427
+ function doApprove(mid, op) {
428
+ var reason = op === 'decline' ? window.prompt('拒绝理由(可留空)') : '-'
429
+ if (op === 'decline' && reason === null) return
430
+ setBusy('approve-' + mid)
431
+ fetch('/api/qqbot-settings/group/approve', {
432
+ method: 'POST', headers: { 'content-type': 'application/json' },
433
+ body: JSON.stringify(Object.assign({ gid: gid, member_openid: mid, op: op, reason: reason || '' }, props.ns ? { ns: props.ns } : {})),
434
+ }).then(function (r) { return r.json() }).then(function (d) {
435
+ setBusy('')
436
+ setMsg((d && d.msg) || (d && d.err && d.err.human) || '审批失败')
437
+ refresh()
438
+ }).catch(function (e) { setBusy(''); setMsg('审批异常: ' + e.message) })
439
+ }
440
+ function doMute(mid, action, seconds) {
441
+ var secs = action === 'mute' ? Math.round(Number(seconds || 600)) : 0
442
+ setBusy('mute-' + mid)
443
+ fetch('/api/qqbot-settings/group/mute', {
444
+ method: 'POST', headers: { 'content-type': 'application/json' },
445
+ body: JSON.stringify(Object.assign({ gid: gid, member_openid: mid, action: action, seconds: secs }, props.ns ? { ns: props.ns } : {})),
446
+ }).then(function (r) { return r.json() }).then(function (d) {
447
+ setBusy('')
448
+ setMsg((d && d.msg) || (d && d.err && d.err.human) || '操作失败')
449
+ refresh()
450
+ }).catch(function (e) { setBusy(''); setMsg('禁言异常: ' + e.message) })
451
+ }
452
+ function doBind() {
453
+ var g = bindGid.trim()
454
+ if (!g) { setMsg('先粘贴 group_openid'); return }
455
+ fetch('/api/qqbot-settings/group/bind', {
456
+ method: 'POST', headers: { 'content-type': 'application/json' },
457
+ body: JSON.stringify(Object.assign({ gid: g, name: bindName.trim() }, props.ns ? { ns: props.ns } : {})),
458
+ }).then(function (r) { return r.json() }).then(function (d) {
459
+ if (d && d.ok) { setMsg('已登记群 ✓'); setBindGid(''); setBindName(''); loadAccounts() }
460
+ else setMsg((d && d.error) || '绑定失败')
461
+ }).catch(function (e) { setMsg('绑定异常: ' + e.message) })
462
+ }
463
+
464
+ var curTabMeta = null
465
+ GROUP_TABS.forEach(function (t) { if (t.key === tab) curTabMeta = t })
466
+ var groupSel = accts === null ? h('span', { style: { fontSize: T.fsSm, color: T.text3 } }, '群列表加载中…')
467
+ : accts.length === 0 ? h('span', { style: { fontSize: T.fsSm, color: T.warn } }, '暂无群(事件累积/下方登记后出现)')
468
+ : h('select', { className: 'qqs-inp', style: { padding: '3px 8px', maxWidth: 320, border: '1px solid ' + T.border, borderRadius: T.radius }, value: gid, onChange: function (e) { setGid(e.target.value); setMsg('') } },
469
+ accts.map(function (a) {
470
+ return h('option', { key: a.gid, value: a.gid }, (a.name ? a.name + ' · ' : '') + a.gid.slice(0, 12) + '…' + (a.from ? ' [' + a.from + ']' : ''))
471
+ }))
472
+
473
+ var joinCols = [
474
+ { key: 'username', title: '昵称', width: '18%', render: function (v, r) { return h('span', { style: { fontWeight: 600 } }, v || '(未知)') } },
475
+ { key: 'source', title: '来源', width: '10%', render: function (v, r) { return h('span', { style: { color: T.text2 } }, v || '-') } },
476
+ { key: 'verify', title: '验证消息', render: function (v, r) { return h('span', { style: { color: T.text2 } }, v || '-') } },
477
+ { key: 'risk', title: '风险', width: '18%', render: function (v, r) { return v ? h('span', { style: { color: T.danger } }, '⚠ ' + v) : h('span', { style: { color: T.text3 } }, '-') } },
478
+ { key: 'op', title: '操作', width: '20%', render: function (v, r) {
479
+ var busying = busy === 'approve-' + r.member_openid
480
+ return h('span', { style: { display: 'inline-flex', gap: 6 } },
481
+ h(SButton, { variant: 'primary', size: 'sm', disabled: !!busy, onClick: function () { doApprove(r.member_openid, 'approve') } }, busying ? '处理中…' : '通过'),
482
+ h(SButton, { variant: 'danger', size: 'sm', disabled: !!busy, onClick: function () { doApprove(r.member_openid, 'decline') } }, '拒绝'))
483
+ } },
484
+ ]
485
+ var joinRows = ((joins && joins.ok && joins.list) || []).map(function (j) {
486
+ return {
487
+ username: j.username || '(未知昵称)',
488
+ source: j.apply_source === 'invited' ? '被邀请' : '主动申请',
489
+ verify: (j.verify_info && j.verify_info.verify_message) || (j.verify_info && j.verify_info.method) || '',
490
+ risk: j.risk_tips || '',
491
+ member_openid: j.member_openid,
492
+ }
493
+ })
494
+
495
+ var muteMode = (mute && mute.data && mute.data.global_rule && mute.data.global_rule.mode) || 'none'
496
+ var muteCols = [
497
+ { key: 'username', title: '成员', width: '30%', render: function (v, r) { return h('span', { style: { fontWeight: 600 } }, v || String(r.member_openid).slice(0, 12)) } },
498
+ { key: 'expire', title: '禁言至', render: function (v) { return h('span', { style: { color: T.text2 } }, v || '?') } },
499
+ { key: 'op', title: '操作', width: '18%', render: function (v, r) {
500
+ var busying = busy === 'mute-' + r.member_openid
501
+ return h(SButton, { variant: 'ghost', size: 'sm', disabled: !!busy, onClick: function () { doMute(r.member_openid, 'unmute') } }, busying ? '处理中…' : '解除')
502
+ } },
503
+ ]
504
+ var muteRows = (((mute && mute.data && mute.data.members) || [])).map(function (m) { return { username: m.username || '', member_openid: m.member_openid, expire: m.mute_expire_at || '' } })
505
+
506
+ var body = null
507
+ if (curTabMeta && curTabMeta.open === false) {
508
+ body = h('div', null,
509
+ h(StatusBar, { tone: 'danger', action: { label: '去开放平台申请', onClick: function () { window.open('https://q.qq.com', '_blank') } } }, curTabMeta.gateHuman || '该能力官方尚未开放'),
510
+ h('div', { style: { border: '1px dashed ' + T.border, borderRadius: T.radius, padding: '24px', textAlign: 'center', color: T.text3, fontSize: T.fsSm } },
511
+ '此功能位已预留 —— 官方开放接口后此处自动点亮, 无需等待插件更新'))
512
+ } else if (!gid) {
513
+ body = h(Empty, { icon: '👥', text: '先选择或登记一个群, 再查看/操作' })
514
+ } else if (tab === 'join') {
515
+ body = joins === null ? h(Empty, { icon: '⏳', text: '加载中…' })
516
+ : h('div', null,
517
+ h(StatusBar, { tone: 'info' }, '数据来自 QQ 官方接口; 机器人需为群管理员。通过/拒绝将直接生效并写入审计日志。'),
518
+ h(DataTable, { cols: joinCols, rows: joinRows, emptyText: '当前没有待审批的入群申请' }))
519
+ } else if (tab === 'mute') {
520
+ // 禁言页: ①选人禁言(本地成员清单) ②正在禁言中的成员(官方状态)
521
+ var pickList = members === null ? h(Empty, { icon: '⏳', text: '加载成员中…' })
522
+ : members.length === 0 ? h('div', { style: { border: '1px dashed ' + T.border, borderRadius: T.radius, padding: '12px', textAlign: 'center', color: T.text3, fontSize: T.fsSm } },
523
+ '本地暂无成员记录 —— 让群友在群里说句话, 机器人就能记住他们, 之后这里可直接选人禁言(官方成员列表未开放)')
524
+ : h('div', { style: { maxHeight: 260, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
525
+ members.map(function (m) {
526
+ var busying = busy === 'mute-' + m.mid
527
+ return h('div', { key: m.mid, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderBottom: '1px solid ' + T.borderLight, fontSize: T.fsSm } },
528
+ h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, (m.name || '(未知名)') + ' · ' + m.mid.slice(0, 10) + '…'),
529
+ h('input', { className: 'qqs-inp', style: { width: 70, padding: '1px 6px', border: '1px solid ' + T.border, borderRadius: T.radius }, type: 'number', min: 1, defaultValue: muteSecs, onChange: function (e) { setMuteSecs(e.target.value) } }),
530
+ h('span', { style: { color: T.text3 } }, '分钟'),
531
+ h(SButton, { variant: 'danger', size: 'sm', disabled: !!busy, onClick: function () { doMute(m.mid, 'mute', Number(muteSecs || 60) * 60) } }, busying ? '处理中…' : '禁言'))
532
+ }))
533
+ body = mute === null ? h(Empty, { icon: '⏳', text: '加载中…' })
534
+ : h('div', null,
535
+ h('div', { style: { fontWeight: 700, fontSize: 13, margin: '10px 0 4px' } }, '① 选择成员禁言'),
536
+ pickList,
537
+ h('div', { style: { fontWeight: 700, fontSize: 13, margin: '14px 0 4px' } }, '② 正在禁言中(官方状态)'),
538
+ h(StatusBar, { tone: 'info' }, '全员禁言: ' + (muteMode === 'always' ? '常开' : muteMode === 'schedule' ? '定时' : '关闭') + ' —— 只能操作普通成员, 群主/管理员不可禁。'),
539
+ h(DataTable, { cols: muteCols, rows: muteRows, emptyText: '没有正在禁言的成员' }))
540
+ } else if (tab === 'members') {
541
+ // 成员信息: 红条(官方未开放)+ 本地兜底名单(见到的发言者)
542
+ var memRows = (members || []).map(function (m) { return { name: m.name || '(未知名)', mid: m.mid, seen: new Date(m.lastSeen).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } })
543
+ var memCols = [
544
+ { key: 'name', title: '昵称', width: '24%' },
545
+ { key: 'mid', title: '成员 ID', render: function (v) { return h('span', { style: { color: T.text2, wordBreak: 'break-all' } }, v) } },
546
+ { key: 'seen', title: '最近发言', width: '22%', render: function (v) { return h('span', { style: { color: T.text3 } }, v) } },
547
+ ]
548
+ body = h('div', null,
549
+ h(StatusBar, { tone: 'danger', action: { label: '去开放平台申请', onClick: function () { window.open('https://q.qq.com', '_blank') } } }, '官方"群成员列表"接口尚未开放 —— 下方为本地兜底名单(机器人见过的发言者), 仅用于帮你认人。'),
550
+ h('div', { style: { fontWeight: 700, fontSize: 13, margin: '10px 0 4px' } }, '本地成员清单(机器人见过的)'),
551
+ h(DataTable, { cols: memCols, rows: memRows, emptyText: '暂无记录 —— 群友发言后自动出现' }))
552
+ }
553
+
554
+ return h('div', { style: { ...card, padding: '4px 0 12px', margin: '12px 0', overflow: 'hidden' } },
555
+ h('div', { style: { padding: '12px 16px', borderBottom: '1px solid ' + T.border, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
556
+ h('span', { style: { fontSize: T.fsTitle, fontWeight: 700 } }, '🛡 群管理'),
557
+ h('span', { style: { fontSize: T.fsSm, color: T.text3 } }, '直连 QQ 官方, 面板操作 = 主人直发'),
558
+ h('span', { style: { flex: 1 } }),
559
+ h('span', { style: { fontSize: T.fsSm, color: T.text2 } }, '目标群'),
560
+ groupSel,
561
+ h(SButton, { variant: 'ghost', size: 'sm', onClick: refresh }, '刷新')),
562
+ h('div', { style: { padding: '0 16px' } },
563
+ h(TabBar, { items: GROUP_TABS.map(function (t) { return { key: t.key, label: t.label + (t.open ? '' : '(待开放)'), badge: t.key === 'join' ? joinRows.length : 0 } }), value: tab, onChange: function (k) { setTab(k); setMsg('') } })),
564
+ h('div', { style: { padding: '0 16px' } }, body),
565
+ h('div', { style: { margin: '12px 16px 0', borderTop: '1px solid ' + T.borderLight, paddingTop: 10, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
566
+ h('span', { style: { fontSize: T.fsSm, color: T.text2 } }, '登记群(官方无"我的群列表", 手动粘贴):'),
567
+ h('input', { className: 'qqs-inp', style: { width: 200, padding: '3px 8px', border: '1px solid ' + T.border, borderRadius: T.radius }, placeholder: 'group_openid', value: bindGid, onChange: function (e) { setBindGid(e.target.value) } }),
568
+ h('input', { className: 'qqs-inp', style: { width: 100, padding: '3px 8px', border: '1px solid ' + T.border, borderRadius: T.radius }, placeholder: '备注(可选)', value: bindName, onChange: function (e) { setBindName(e.target.value) } }),
569
+ h(SButton, { variant: 'primary', size: 'sm', onClick: doBind }, '绑定'),
570
+ msg ? h('span', { style: { fontSize: T.fsSm, color: T.success } }, msg) : null))
571
+ }
572
+
573
+ var API_LIST = '/api/qqbot-settings/stickers'
574
+ var API_IMG = '/api/qqbot-settings/sticker-img?id='
575
+ var API_BATCH = '/api/qqbot-settings/stickers/batch'
576
+ var API_IMPORT = '/api/qqbot-settings/stickers/import'
577
+ var LAYER_LABEL = { candidate: '待整理', library: '收藏', trash: '回收站' }
578
+
579
+ function Lightbox(props) {
580
+ var it = props.item || {}
581
+ var lbDD = props.storeDir || ''
582
+ var [tagsText, setTagsText] = useState((it.tags || []).join(','))
583
+ var [desc, setDesc] = useState(it.desc || '')
584
+ var [saved, setSaved] = useState('')
585
+ function doBatch(op, extra) {
586
+ fetch(API_BATCH, {
587
+ method: 'POST', headers: { 'content-type': 'application/json' },
588
+ body: JSON.stringify(Object.assign({ op: op, ids: [it.id], dataDir: lbDD || undefined }, extra || {})),
589
+ }).then(function (r) { return r.json() }).then(function () { setSaved(op === 'tag' ? '已保存 ✓' : (op === 'promote' ? '已移入收藏 ✓' : '已送回收站 ✓')); props.onChanged && props.onChanged() }).catch(function (e) { setSaved('失败:' + e.message) })
590
+ }
591
+ return h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: function () { props.onClose() } },
592
+ h('div', { className: 'qqs-modal', style: { background: '#22252e', borderRadius: 12, padding: 14, maxWidth: 560, width: '92%', maxHeight: '88vh', overflow: 'auto' }, onClick: function (e) { e.stopPropagation() } },
593
+ h('img', { src: API_IMG + encodeURIComponent(it.id) + (lbDD ? '&dataDir=' + encodeURIComponent(lbDD) : ''), style: { maxWidth: '100%', maxHeight: '46vh', borderRadius: 8, display: 'block', margin: '0 auto', objectFit: 'contain' } }),
594
+ h('p', { style: { fontSize: 12, color: '#999' } }, '图号 ' + it.id.slice(0, 8) + ' · ' + (LAYER_LABEL[it.layer] || it.layer) + ' · 用过 ' + (it.useCount || 0) + ' 次'),
595
+ h('div', { style: { fontSize: 13, margin: '4px 0' } }, '标签(逗号或空格分开都行,自动去重):'),
596
+ h('input', { className: 'qqs-inp', style: { width: '100%', padding: '4px 8px' }, value: tagsText, onChange: function (e) { setTagsText(e.target.value) } }),
597
+ h('div', { style: { fontSize: 13, margin: '6px 0 2px' } }, '描述(一句话说明画面和适合场合):'),
598
+ h('textarea', { className: 'qqs-area', style: { width: '100%', minHeight: 54 }, value: desc, onChange: function (e) { setDesc(e.target.value) } }),
599
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', margin: '6px 0', fontSize: 12, color: '#aaa' } },
600
+ h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', direction: 'rtl', textAlign: 'left' } }, it.path || ''),
601
+ h('button', { className: 'qqs-btn', style: { padding: '2px 8px', fontSize: 12 }, onClick: function () { try { navigator.clipboard.writeText(it.path || '').then(function () { setSaved('原路径已复制 ✓') }).catch(function () { setSaved('复制失败') }) } catch (e2) { setSaved('复制失败:' + e2.message) } } }, '复制原路径')),
602
+ h('div', { style: { marginTop: 8, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
603
+ h('button', { className: 'qqs-btn', onClick: function () { doBatch('tag', { tags: tagsText.split(/[,,\s]+/).map(function (s) { return s.trim() }).filter(Boolean), desc: desc }) } }, '保存'),
604
+ it.layer !== 'library' ? h('button', { className: 'qqs-btn', onClick: function () { doBatch('promote') } }, '移入收藏') : null,
605
+ it.layer !== 'trash' ? h('button', { className: 'qqs-btn', onClick: function () { doBatch('trash') } }, '送回收站') : null,
606
+ it.layer === 'trash' ? h('button', { className: 'qqs-btn', onClick: function () { doBatch('restore') } }, '恢复') : null,
607
+ saved ? h('span', { style: { fontSize: 12, color: '#2f9e44' } }, saved) : null,
608
+ h('button', { className: 'qqs-btn', onClick: function () { props.onClose() } }, '关闭'))))
609
+ }
610
+
611
+ function ImportPanel(props) {
612
+ var ipDD = props.storeDir || ''
613
+ var [text, setText] = useState('')
614
+ var [fileMsg, setFileMsg] = useState('')
615
+ var [result, setResult] = useState('')
616
+ var fileInput = React.useRef(null)
617
+ function doImport(extra) {
618
+ setResult('导入中…')
619
+ fetch(API_IMPORT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(Object.assign({}, extra, ipDD ? { dataDir: ipDD } : {})) })
620
+ .then(function (r) { return r.json() }).then(function (d) {
621
+ var rs = (d && d.results) || []
622
+ var ok = 0, dup = 0, err = 0, errList = []
623
+ rs.forEach(function (x) { if (x.status === 'new') ok += 1; else if (x.status === 'dup') dup += 1; else { err += 1; if (errList.length < 5) errList.push(x.ref) } })
624
+ setResult('完成: 新增 ' + ok + ' 张, 已有跳过 ' + dup + ' 张' + (err ? ', 失败 ' + err + ' 张(' + errList.join('; ') + ')' : ''))
625
+ }).catch(function (e) { setResult('失败:' + e.message) })
626
+ }
627
+ function pickFiles() {
628
+ var files = fileInput.current && fileInput.current.files ? Array.from(fileInput.current.files).slice(0, 20) : []
629
+ if (!files.length) return
630
+ setFileMsg('读取中 ' + files.length + ' 个文件…')
631
+ var reads = files.map(function (f) {
632
+ return new Promise(function (res) {
633
+ var rd = new FileReader()
634
+ rd.onload = function () {
635
+ var b64 = String(rd.result || '').split(',')[1] || ''
636
+ res({ name: f.name, dataBase64: b64 })
637
+ }
638
+ rd.onerror = function () { res(null) }
639
+ rd.readAsDataURL(f)
640
+ })
641
+ })
642
+ Promise.all(reads).then(function (list) {
643
+ var filesArr = list.filter(Boolean)
644
+ setFileMsg('')
645
+ doImport({ files: filesArr })
646
+ })
647
+ }
648
+ return h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: function () { props.onClose() } },
649
+ h('div', { className: 'qqs-modal', style: { background: '#22252e', borderRadius: 12, padding: 14, maxWidth: 520, width: '92%' }, onClick: function (e) { e.stopPropagation() } },
650
+ h('h3', { style: { marginTop: 0 } }, '导入表情包'),
651
+ h('div', null,
652
+ h('input', { className: 'qqs-inp', ref: fileInput, type: 'file', accept: 'image/*', multiple: true }),
653
+ h('button', { className: 'qqs-btn', style: { marginLeft: 8 }, onClick: pickFiles }, '上传所选图片')),
654
+ h('p', { style: { fontSize: 12, color: '#999' } }, '或粘贴每行一个:图片URL 或 电脑上的图片路径'),
655
+ h('textarea', { className: 'qqs-area', style: { width: '100%', minHeight: 70 }, value: text, placeholder: 'https://… 或 D:\\…\\xx.jpg', onChange: function (e) { setText(e.target.value) } }),
656
+ h('div', { style: { margin: '6px 0' } },
657
+ h('button', { className: 'qqs-btn', onClick: function () {
658
+ var lines = text.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
659
+ var urls = lines.filter(function (x) { return /^https?:\/\//i.test(x) })
660
+ var paths = lines.filter(function (x) { return !/^https?:\/\//i.test(x) })
661
+ doImport({ urls: urls, paths: paths })
662
+ } }, '导入粘贴的内容')),
663
+ fileMsg ? h('p', { style: { fontSize: 12 } }, fileMsg) : null,
664
+ result ? h('p', { style: { fontSize: 13, color: '#2f9e44' } }, result) : null,
665
+ h('div', { style: { textAlign: 'right', marginTop: 8 } }, h('button', { className: 'qqs-btn', onClick: function () { props.onClose() } }, '关闭'))))
666
+ }
667
+
668
+ function GalleryPage(props) {
669
+ // 多账号: 图库按当前账号 dataDir(各号各库)
670
+ var DD = props && props.acct && props.acct.dataDir ? String(props.acct.dataDir) : ''
671
+ var [items, setItems] = useState([])
672
+ var [layer, setLayer] = useState('')
673
+ var [untagged, setUntagged] = useState(false)
674
+ var [q, setQ] = useState('')
675
+ var [sel, setSel] = useState([])
676
+ var [box, setBox] = useState(null)
677
+ var [imp, setImp] = useState(false)
678
+ var gridRef = React.useRef(null)
679
+ var suppressRef = React.useRef(false)
680
+ var [mq, setMq] = useState(null)
681
+ var selDownRef = React.useRef(false)
682
+
683
+ function onGridDown(e) {
684
+ if (e.button !== 0) return
685
+ if (selDownRef.current) return
686
+ selDownRef.current = true
687
+ try { if (e.target && e.target.closest && e.target.closest('input,textarea,button,select,label,a,[data-sel-card]')) { selDownRef.current = false; return } } catch (e2) {}
688
+ var sx = e.clientX, sy = e.clientY, moved = false
689
+ function move(ev) {
690
+ var dx = ev.clientX - sx, dy = ev.clientY - sy
691
+ if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) moved = true
692
+ if (moved) setMq({ x0: Math.min(sx, ev.clientX), y0: Math.min(sy, ev.clientY), x1: Math.max(sx, ev.clientX), y1: Math.max(sy, ev.clientY) })
693
+ }
694
+ function up(ev) {
695
+ window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); setMq(null); selDownRef.current = false
696
+ if (moved && gridRef.current) {
697
+ suppressRef.current = true
698
+ var r = { x0: Math.min(sx, ev.clientX), y0: Math.min(sy, ev.clientY), x1: Math.max(sx, ev.clientX), y1: Math.max(sy, ev.clientY) }
699
+ var hits = []
700
+ Array.prototype.forEach.call(gridRef.current.children, function (el, i) {
701
+ var b = el.getBoundingClientRect()
702
+ var cx = b.left + b.width / 2, cy = b.top + b.height / 2
703
+ if (cx >= r.x0 && cx <= r.x1 && cy >= r.y0 && cy <= r.y1) { var it = items[i]; if (it) hits.push(it.id) }
704
+ })
705
+ if (hits.length) setSel(function (s) { return ev.shiftKey ? Array.from(new Set(s.concat(hits))) : hits })
706
+ setTimeout(function () { suppressRef.current = false }, 80)
707
+ }
708
+ }
709
+ window.addEventListener('mousemove', move); window.addEventListener('mouseup', up)
710
+ }
711
+ var [msg, setMsg] = useState('')
712
+
713
+ function load(quiet) {
714
+ // 回收站层必须带 includeTrash=1, 否则 queryAll 默认滤掉 trash(既有坑: 回收站一直显示空)
715
+ var url = API_LIST + '?layer=' + encodeURIComponent(layer) + (layer === 'trash' ? '&includeTrash=1' : '') + (untagged ? '&untagged=1' : '') + '&q=' + encodeURIComponent(q) + (DD ? '&dataDir=' + encodeURIComponent(DD) : '')
716
+ fetch(url).then(function (r) { return r.json() }).then(function (d) {
717
+ if (d && Array.isArray(d.items)) {
718
+ setItems(d.items)
719
+ if (!quiet) setSel([])
720
+ }
721
+ }).catch(function (e) { if (!quiet) setMsg('加载失败:' + e.message) })
722
+ }
723
+ useEffect(function () { load(true) }, [layer, untagged, q])
724
+ // 自动刷新: 后台机器人持续收藏/改动时, 页面每 6 秒同步一次(不打断勾选)
725
+ useEffect(function () {
726
+ var t = setInterval(function () { load(true) }, 6000)
727
+ return function () { clearInterval(t) }
728
+ }, [layer, untagged, q])
729
+
730
+ function toggle(id) { setSel(function (s) { return s.includes(id) ? s.filter(function (x) { return x !== id }) : s.concat(id) }) }
731
+ function batch(op, extra) {
732
+ if (!sel.length) return
733
+ fetch(API_BATCH, {
734
+ method: 'POST', headers: { 'content-type': 'application/json' },
735
+ body: JSON.stringify(Object.assign({ op: op, ids: sel, dataDir: DD || undefined }, extra || {})),
736
+ }).then(function (r) { return r.json() }).then(function (d) {
737
+ setMsg((d && d.total !== undefined ? '已处理 ' + d.ok + '/' + d.total : '完成') + ' ✓')
738
+ load()
739
+ }).catch(function (e) { setMsg('操作失败:' + e.message) })
740
+ }
741
+
742
+ var pills = [['', '全部'], ['candidate', '待整理'], ['library', '收藏'], ['trash', '回收站']]
743
+ return h('div', { className: 'qqs-panel', onMouseDown: onGridDown, style: { maxWidth: 1080, width: '100%', userSelect: 'none' } },
744
+ h('h2', null, '表情包图库'),
745
+ h('div', { className: 'qqs-toolbar', style: {} },
746
+ pills.map(function (p) {
747
+ return h('button', { className: 'qqs-btn', key: p[0], style: { padding: '3px 10px', fontWeight: layer === p[0] ? 700 : 400 }, onClick: function () { setLayer(p[0]) } }, p[1])
748
+ }),
749
+ h('input', { className: 'qqs-inp', placeholder: '搜标签/描述…', value: q, style: { flex: 1, minWidth: 140 }, onChange: function (e) { setQ(e.target.value) } }),
750
+ h('label', { style: { fontSize: 13, whiteSpace: 'nowrap' } }, h('input', { className: 'qqs-inp', type: 'checkbox', className: 'qqs-cb', checked: untagged, onChange: function (e) { setUntagged(e.target.checked) } }), ' 只看没打标的'),
751
+ h('button', { className: 'qqs-btn', onClick: function () { setImp(true) } }, '导入'),
752
+ h('button', { className: 'qqs-btn', onClick: load }, '刷新')),
753
+ h('p', { style: { fontSize: 12, color: '#888' } }, '共 ' + items.length + ' 张。点图看大图并编辑标签/描述;勾选后底部批量操作。'),
754
+ items.length === 0 ? h('p', { style: { color: '#888', padding: 20, textAlign: 'center' } }, '这里还没有图' + (untagged ? '(没有未打标的图了 🎉)' : ',点右上角「导入」或等群里发图自动收藏')) : null,
755
+ h('div', { ref: gridRef, onMouseDown: onGridDown, style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(104px,1fr))', gap: 14, padding: 14, userSelect: 'none', cursor: 'crosshair', minHeight: 120 } },
756
+ items.map(function (it) {
757
+ var on = sel.includes(it.id)
758
+ return h('div', { key: it.id, 'data-sel-card': true, className: 'qqs-card', style: { position: 'relative', border: '1px solid ' + (on ? '#2f9e44' : '#8883'), borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }, onClick: function () { if (suppressRef.current) return; setBox(it) } },
759
+ h('img', { src: API_IMG + encodeURIComponent(it.id) + (DD ? '&dataDir=' + encodeURIComponent(DD) : ''), style: { width: '100%', height: 104, objectFit: 'cover', display: 'block' } }),
760
+ h('input', { className: 'qqs-inp', type: 'checkbox', className: 'qqs-cb', checked: on, style: { position: 'absolute', left: 4, top: 4 }, onClick: function (e) { e.stopPropagation(); toggle(it.id) } }),
761
+ it.tags && it.tags.length ? h('span', { style: { position: 'absolute', left: 4, bottom: 2, fontSize: 10, color: '#fff', background: '#0009', borderRadius: 4, padding: '0 4px', maxWidth: '80%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' } }, it.tags[0]) : null,
762
+ it.layer === 'library' ? h('span', { style: { position: 'absolute', right: 4, bottom: 2, fontSize: 10, color: '#ffd43b' } }, '★') : null,
763
+ it.layer === 'trash' ? h('span', { style: { position: 'absolute', right: 4, bottom: 2, fontSize: 10, color: '#ffa94d' } }, '回收站') : null)
764
+ })),
765
+ h('div', { style: { height: 120, cursor: 'crosshair', userSelect: 'none' } }),
766
+ sel.length ? h('div', { style: { position: 'sticky', bottom: 8, marginTop: 10, background: 'rgba(255,255,255,.86)', border: '1px solid #00000030', borderRadius: 12, padding: '8px 12px', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', boxShadow: '0 6px 22px rgba(0,0,0,.18)', backdropFilter: 'blur(4px)' } },
767
+ h('span', { style: { fontWeight: 800, color: '#171a21' } }, '已选 ' + sel.length + ' 张'),
768
+ layer !== 'trash' ? h('button', { onClick: function () { batch('promote') }, style: { font: 'inherit', cursor: 'pointer', padding: '4px 12px', borderRadius: 9, border: '1px solid #2f9e4499', background: 'rgba(47,158,68,.12)', color: '#1d7a31', fontWeight: 700, fontSize: 13 } }, '移入收藏 ★') : null,
769
+ layer !== 'trash' ? h('button', { onClick: function () { batch('trash') }, style: { font: 'inherit', cursor: 'pointer', padding: '4px 12px', borderRadius: 9, border: '1px solid #e8590c99', background: 'rgba(232,89,12,.12)', color: '#c2450a', fontWeight: 700, fontSize: 13 } }, '送回收站') : null,
770
+ layer === 'trash' ? h('button', { onClick: function () { batch('restore') }, style: { font: 'inherit', cursor: 'pointer', padding: '4px 12px', borderRadius: 9, border: '1px solid #2f9e4499', background: 'rgba(47,158,68,.12)', color: '#1d7a31', fontWeight: 700, fontSize: 13 } }, '恢复') : null,
771
+ h('button', { onClick: function () { setSel([]) }, style: { font: 'inherit', cursor: 'pointer', padding: '4px 12px', borderRadius: 9, border: '1px solid #00000030', background: 'rgba(255,255,255,.7)', color: '#444', fontWeight: 600, fontSize: 13 } }, '取消')) : null,
772
+ mq ? h('div', { style: { position: 'fixed', left: mq.x0, top: mq.y0, width: Math.max(0, mq.x1 - mq.x0), height: Math.max(0, mq.y1 - mq.y0), border: '1px dashed #5b9dff', background: '#5b9dff22', pointerEvents: 'none', zIndex: 500 } }) : null,
773
+ msg ? h('p', { style: { fontSize: 13, color: '#2f9e44' } }, msg) : null,
774
+ box ? h(Lightbox, { item: box, storeDir: DD || undefined, onClose: function () { setBox(null); load() }, onChanged: load }) : null,
775
+ imp ? h(ImportPanel, { storeDir: DD || undefined, onClose: function () { setImp(false); load() } }) : null)
776
+ }
777
+
778
+ // ── 定时任务页(管理 AI 在 QQ 会话里建的定时任务: 一次性/每天) ──
779
+ var API_TIMERS = '/api/qqbot-settings/timers'
780
+ function fmtWhen(job) {
781
+ if (job.kind === 'daily') return '每天 ' + (job.atTime || '??:??')
782
+ if (job.kind === 'once' && job.dueAt) {
783
+ var d = new Date(job.dueAt)
784
+ var p2 = function (n) { return n < 10 ? '0' + n : '' + n }
785
+ return '一次性 · ' + d.getFullYear() + '/' + p2(d.getMonth() + 1) + '/' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes())
786
+ }
787
+ return job.kind === 'once' ? '一次性' : '每天'
788
+ }
789
+ // ── 定时唤醒编辑(原设置页④区, 并入定时任务页): config.schedule.targets 分组 ──
790
+ // 保存走 im-qqbot settings ns 全量 patch(与设置页同构, 防覆盖掉其它字段), live 热更新。
791
+ // 聊天对象数据源(2026-09-05 统一): 群只列"活的"(host 已用官方 info 剔除注销群, 显示真群名);
792
+ // 私聊用台账昵称, 缺名时用本地成员表(同 openid 在群里出现过的昵称)补 —— 不再露裸 id。
793
+ function fetchChatTargets(acct) {
794
+ var ns = acct && acct.ns && acct.ns !== 'im-qqbot' ? String(acct.ns) : ''
795
+ var dd = acct && acct.dataDir ? String(acct.dataDir) : ''
796
+ var qNs = ns ? '?ns=' + encodeURIComponent(ns) : ''
797
+ var qDD = dd ? '?dataDir=' + encodeURIComponent(dd) : ''
798
+ var qM = ns ? ('?ns=' + encodeURIComponent(ns)) : '' // members_local 不带 gid = 全部成员
799
+ return Promise.all([
800
+ fetch('/api/qqbot-settings/group/accounts' + qNs).then(function (r) { return r.json() }).catch(function () { return {} }),
801
+ fetch('/api/qqbot-settings/known-chats' + qDD).then(function (r) { return r.json() }).catch(function () { return {} }),
802
+ fetch('/api/qqbot-settings/group/members_local' + qM).then(function (r) { return r.json() }).catch(function () { return {} }),
803
+ ]).then(function (rs) {
804
+ var groups = ((rs[0] && rs[0].groups) || []).map(function (g) {
805
+ return { scope: 'group', id: g.gid, name: g.name || '', lastSeen: g.lastAt || 0, count: 0 }
806
+ })
807
+ var known = ((rs[1] && rs[1].chats) || [])
808
+ // 成员表: mid -> 最近昵称(给私聊/无台账名时补名)
809
+ var nameByMid = {}
810
+ ;((rs[2] && rs[2].members) || []).forEach(function (m) {
811
+ if (m.mid && m.name) {
812
+ var old = nameByMid[m.mid]
813
+ if (!old || m.lastSeen > old.ts) nameByMid[m.mid] = { name: m.name, ts: m.lastSeen || 0 }
814
+ }
815
+ })
816
+ var c2c = []
817
+ var seen = {}
818
+ known.forEach(function (c) {
819
+ if (c.scope !== 'c2c' || !c.id || seen[c.id]) return
820
+ seen[c.id] = 1
821
+ var nm = c.name || (nameByMid[c.id] && nameByMid[c.id].name) || ''
822
+ c2c.push({ scope: 'c2c', id: c.id, name: nm, lastSeen: c.lastSeen || 0, count: c.count || 0 })
823
+ })
824
+ // 台账里没有但成员表有名字的 openid 也补上(私聊候选)
825
+ Object.keys(nameByMid).forEach(function (mid) {
826
+ if (!seen[mid]) { seen[mid] = 1; c2c.push({ scope: 'c2c', id: mid, name: nameByMid[mid].name, lastSeen: nameByMid[mid].ts, count: 0 }) }
827
+ })
828
+ c2c.sort(function (a, b) { return (b.lastSeen || 0) - (a.lastSeen || 0) })
829
+ return { group: groups, c2c: c2c }
830
+ })
831
+ }
832
+ function WakeEditor(props) {
833
+ // 多账号: 定时唤醒按当前账号 ns 读写; 表格=拍平行视图(行=对象×时刻), 存储仍是 targets 分组
834
+ var _wkNs = props && props.acct && props.acct.ns && props.acct.ns !== 'im-qqbot' ? String(props.acct.ns) : ''
835
+ var [cfg, setCfg] = useState(null)
836
+ var [rev, setRev] = useState(undefined)
837
+ var [knownChats, setKnownChats] = useState(null)
838
+ var [msg, setMsg] = useState('')
839
+ // 视图态: 筛选 tab + 对象搜索词(纯视图过滤)
840
+ var [filter, setFilter] = useState('all') // all|group|c2c
841
+ var [search, setSearch] = useState('')
842
+
843
+ function load() {
844
+ fetch(READ + (_wkNs ? '?ns=' + encodeURIComponent(_wkNs) : '')).then(function (r) { return r.json() }).then(function (d) {
845
+ if (d && d.value) { setCfg(d.value); setRev(d.revision); setMsg('') }
846
+ else { setMsg('读取失败: ' + JSON.stringify(d)) }
847
+ }).catch(function (e) { setMsg('读取异常: ' + e.message) })
848
+ }
849
+ useEffect(function () { load() }, [])
850
+ useEffect(function () {
851
+ fetchChatTargets(props.acct).then(function (t) {
852
+ var list = (t.group || []).concat(t.c2c || [])
853
+ setKnownChats(list)
854
+ }).catch(function () {})
855
+ }, [])
856
+ function targets() { return (cfg && cfg.schedule && Array.isArray(cfg.schedule.targets)) ? cfg.schedule.targets : [] }
857
+ function setTargets(list) {
858
+ setCfg(function (c) {
859
+ var base = c || {}
860
+ var s = (base.schedule && typeof base.schedule === 'object') ? base.schedule : {}
861
+ return { ...base, schedule: Object.assign({}, s, { targets: list }) }
862
+ })
863
+ }
864
+
865
+ // ── 拍平行行模型: [{tgIndex, tg, task}] ──
866
+ function rows() {
867
+ var out = []
868
+ targets().forEach(function (tg, ti) {
869
+ ;(tg.tasks || []).forEach(function (tt) {
870
+ out.push({ ti: ti, tg: tg, task: tt })
871
+ })
872
+ })
873
+ return out
874
+ }
875
+ function chatLabelOf(scope, id) {
876
+ var hit = null
877
+ ;(knownChats || []).forEach(function (c) { if (c.scope === scope && c.id === id) hit = c })
878
+ if (hit && hit.name) return hit.name
879
+ return id ? (id.length > 14 ? id.slice(0, 6) + '…' + id.slice(-4) : id) : ''
880
+ }
881
+ // 行操作 → 直接改 targets 分组(保持存储模型)
882
+ function patchTask(ti, taskId, patch) {
883
+ var list = targets().slice()
884
+ var tg = { ...list[ti], tasks: (list[ti].tasks || []).map(function (t) { return t.id === taskId ? { ...t, ...patch } : t }) }
885
+ list[ti] = tg
886
+ setTargets(list)
887
+ }
888
+ // 行内改对象: 若该行所在组还挂着其它行(旧数据同组多时刻), 先拆成独立组再改, 保证只影响本行
889
+ function pickObject(r, c) {
890
+ var list = targets().slice()
891
+ var tg = list[r.ti]
892
+ if (!tg) return
893
+ var tasks = (tg.tasks || []).slice()
894
+ var patch = { scope: c.scope, targetId: c.id, name: c.name || '' }
895
+ if (tasks.length <= 1) {
896
+ list[r.ti] = { ...tg, ...patch }
897
+ } else {
898
+ var keep = tasks.filter(function (t) { return t.id !== r.task.id })
899
+ var lone = tasks.filter(function (t) { return t.id === r.task.id })
900
+ var ntg = { ...tg, ...patch, id: 'tg-' + Date.now().toString(36), tasks: lone }
901
+ list.splice(r.ti, 1, { ...tg, tasks: keep }, ntg)
902
+ list = list.filter(function (g) { return (g.tasks || []).length > 0 })
903
+ }
904
+ setTargets(list)
905
+ }
906
+ function rmRow(ti, taskId) {
907
+ var list = targets().slice()
908
+ var tg = { ...list[ti], tasks: (list[ti].tasks || []).filter(function (t) { return t.id !== taskId }) }
909
+ if (tg.tasks.length === 0) list = list.filter(function (_, i) { return i !== ti }) // 空目标组一并删
910
+ else list[ti] = tg
911
+ setTargets(list)
912
+ }
913
+ function addRow() {
914
+ var list = targets().slice()
915
+ // 每行 = 独立目标组(对象列改起来互不牵连); 默认挂"全部"视图, scope 跟随当前筛选类型
916
+ var nt = { id: 't-' + Date.now().toString(36), time: '09:00', enabled: true, prompt: '' }
917
+ var scope = filter === 'c2c' ? 'c2c' : filter === 'group' ? 'group' : 'group'
918
+ var ntg = { id: 'tg-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6), name: '', scope: scope, targetId: '', tasks: [nt] }
919
+ list.push(ntg)
920
+ setTargets(list)
921
+ }
922
+ // 保存: 整表(行→分组已实时同步)全量 patch
923
+ function save() {
924
+ setMsg('保存中…')
925
+ var v = cfg || {}
926
+ var patch = {
927
+ behavior: v.behavior || {},
928
+ sticker: {
929
+ gates: (v.sticker && v.sticker.gates) || {},
930
+ autoTagEnabled: !!(v.sticker && v.sticker.autoTagEnabled),
931
+ visionCli: (v.sticker && typeof v.sticker.visionCli === 'string') ? v.sticker.visionCli : '',
932
+ },
933
+ injectRules: Array.isArray(v.injectRules) ? v.injectRules : [],
934
+ schedule: { targets: targets() },
935
+ groupPrompt: typeof v.groupPrompt === 'string' ? v.groupPrompt : DEFAULT_GROUP_PROMPT,
936
+ }
937
+ fetch(UPDATE, {
938
+ method: 'POST', headers: { 'content-type': 'application/json' },
939
+ body: JSON.stringify({ patch: patch, expectedRevision: rev, ns: _wkNs || undefined }),
940
+ }).then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
941
+ if (d && d.value) { setCfg(d.value); setRev(d.revision); setMsg('已保存 ✓(live 生效)') }
942
+ else if (d && d.error) { setMsg('保存失败: ' + d.error) }
943
+ else { setMsg('保存失败(未知响应)') }
944
+ }).catch(function (e) { setMsg('保存异常: ' + e.message) })
945
+ }
946
+ if (!cfg) return h('p', null, msg || '加载中…')
947
+
948
+ // 筛选行
949
+ var allRows = rows()
950
+ var shown = allRows.filter(function (r) {
951
+ if (filter !== 'all' && r.tg.scope !== filter) return false
952
+ if (search) {
953
+ var q = search.toLowerCase()
954
+ var nm = (chatLabelOf(r.tg.scope, r.tg.targetId) || '').toLowerCase()
955
+ if (nm.indexOf(q) < 0 && String(r.task.prompt || '').toLowerCase().indexOf(q) < 0 && String(r.task.time).indexOf(q) < 0) return false
956
+ }
957
+ return true
958
+ })
959
+
960
+ // 渲染: 表格
961
+ return h('div', null,
962
+ h('p', { style: { fontSize: T.fsSm, color: T.warn, margin: '4px 0 6px' } }, '⚠️ QQ 平台: 主动发私聊需对方 48h 内跟机器人说过话, 超时会拒收(自动降级); 群一般没问题。想验证: 时间设成 1~2 分钟后保存等着看。'),
963
+ // 工具条: 筛选 + 搜索 + 添加行
964
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', margin: '6px 0' } },
965
+ h(TabBar, {
966
+ items: [
967
+ { key: 'all', label: '全部(' + allRows.length + ')' },
968
+ { key: 'group', label: '群' },
969
+ { key: 'c2c', label: '私聊' },
970
+ ], value: filter, onChange: function (k) { setFilter(k); setMsg('') },
971
+ }),
972
+ h('input', { className: 'qqs-inp', style: { flex: 1, minWidth: 120, padding: '3px 8px', border: '1px solid ' + T.border, borderRadius: T.radius }, placeholder: '搜对象 / 描述 / 时间…', value: search, onChange: function (e) { setSearch(e.target.value) } }),
973
+ h(SButton, { variant: 'primary', size: 'sm', onClick: addRow }, '+ 添加一行')),
974
+ // 表格头
975
+ h('div', { style: { display: 'flex', alignItems: 'center', background: T.cardHi, border: '1px solid #dde1e7', borderRadius: T.radius + ' ' + T.radius + ' 0 0', fontWeight: 700, fontSize: T.fsSm, color: T.text2 } },
976
+ h('div', { style: { width: 90, padding: '8px 10px' } }, '时间'),
977
+ h('div', { style: { flex: '1 1 26%', minWidth: 140, padding: '8px 10px' } }, '对象'),
978
+ h('div', { style: { flex: '2 1 38%', minWidth: 160, padding: '8px 10px' } }, '任务描述'),
979
+ h('div', { style: { width: 80, padding: '8px 10px', textAlign: 'center' } }, '每天'),
980
+ h('div', { style: { width: 56, padding: '8px 10px', textAlign: 'center' } }, '')),
981
+ // 行
982
+ shown.length === 0 ? h('div', { style: { border: '1px solid ' + T.border, borderTop: 'none', borderRadius: '0 0 ' + T.radius + ' ' + T.radius, padding: 22, textAlign: 'center', color: T.text3, fontSize: T.fsSm } }, '还没有任务行 —— 点「+ 添加一行」开始配置')
983
+ : shown.map(function (r, idx) {
984
+ var tg = r.tg, tt = r.task
985
+ var nm = chatLabelOf(tg.scope, tg.targetId)
986
+ return h('div', { key: tg.id + ':' + tt.id, style: { display: 'flex', alignItems: 'center', background: idx % 2 ? T.cardHi : '#ffffff', borderBottom: '1px solid #dde1e7', borderLeft: '1px solid #dde1e7', borderRight: '1px solid #dde1e7', fontSize: T.fsSm } },
987
+ // 时间
988
+ h('div', { style: { width: 90, padding: '6px 8px' } },
989
+ h('input', { className: 'qqs-inp', style: { width: 62, padding: '2px 6px', border: '1px solid ' + T.border, borderRadius: T.radius }, value: sv(tt.time), placeholder: '09:00', onChange: function (e) { patchTask(r.ti, tt.id, { time: e.target.value }) } })),
990
+ // 对象: 类型小标 + 可搜索选择器(点击弹出, 输入过滤; 每行独立, 互不牵连)
991
+ h('div', { style: { flex: '1 1 26%', minWidth: 150, padding: '6px 8px', display: 'flex', gap: 4, alignItems: 'center' } },
992
+ h('span', { style: { flexShrink: 0, fontSize: 10, padding: '0 5px', borderRadius: 4, color: '#fff', background: tg.scope === 'c2c' ? T.primary : T.primary2 } }, tg.scope === 'c2c' ? '私' : '群'),
993
+ h(TargetPicker, { value: { scope: tg.scope, targetId: tg.targetId, name: nm }, options: knownChats || [], onPick: function (c) { pickObject(r, c) } })),
994
+ // 描述
995
+ h('div', { style: { flex: '2 1 38%', minWidth: 160, padding: '6px 8px' } },
996
+ h('input', { className: 'qqs-inp', style: { width: '100%', padding: '2px 6px', border: '1px solid ' + T.border, borderRadius: T.radius, fontSize: T.fsSm }, value: sv(tt.prompt), placeholder: '例: 早上好, 提醒大家吃早饭(留空=自由发挥)', onChange: function (e) { patchTask(r.ti, tt.id, { prompt: e.target.value }) } })),
997
+ // 每天 toggle = 启停
998
+ h('div', { style: { width: 80, padding: '6px 8px', textAlign: 'center' } },
999
+ h('input', { type: 'checkbox', checked: !!tt.enabled, onChange: function (e) { patchTask(r.ti, tt.id, { enabled: e.target.checked }) } })),
1000
+ // 删除
1001
+ h('div', { style: { width: 56, padding: '6px 8px', textAlign: 'center' } },
1002
+ h('button', { className: 'qqs-btn', style: { padding: '1px 8px', fontSize: 11, color: T.danger, background: 'transparent', border: 'none', cursor: 'pointer' }, onClick: function () { if (window.confirm('删除这一行?')) rmRow(r.ti, tt.id) } }, '✕')),
1003
+ )
1004
+ }),
1005
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', margin: '10px 0 2px' } },
1006
+ h(SButton, { variant: 'primary', size: 'sm', onClick: save }, '保存定时唤醒'),
1007
+ h(SButton, { variant: 'ghost', size: 'sm', onClick: load }, '放弃修改'),
1008
+ msg ? h('span', { style: { fontSize: T.fsSm, color: T.success } }, msg) : null))
1009
+ }
1010
+
1011
+ function TimersPage(props) {
1012
+ // 多账号: 定时任务按当前账号 schedDir(={cwd}/.qqbot)
1013
+ var _tmSD = props && props.acct && props.acct.schedDir ? String(props.acct.schedDir) : ''
1014
+ var _tmQ = _tmSD ? '?schedDir=' + encodeURIComponent(_tmSD) : ''
1015
+ var [jobs, setJobs] = useState(null)
1016
+ var [msg, setMsg] = useState('')
1017
+ var [busyId, setBusyId] = useState('')
1018
+ // 名称解析(2026-09-05): 群=活群真名(accounts 已剔除注销群), 私聊=台账昵称+成员表补名
1019
+ var [chatTg, setChatTg] = useState({ group: [], c2c: [] })
1020
+ function load() {
1021
+ fetch(API_TIMERS + _tmQ).then(function (r) { return r.json() }).then(function (d) {
1022
+ if (d && Array.isArray(d.jobs)) setJobs(d.jobs.slice().reverse())
1023
+ else setMsg('读取失败: ' + JSON.stringify(d))
1024
+ }).catch(function (e) { setMsg('读取异常: ' + e.message) })
1025
+ }
1026
+ useEffect(function () {
1027
+ load()
1028
+ fetchChatTargets(props.acct).then(function (t) { setChatTg(t) }).catch(function () {})
1029
+ }, [])
1030
+ function nameOf(scope, id) {
1031
+ var nm = ''
1032
+ ;(chatTg[scope] || []).forEach(function (x) { if (x.id === id) nm = x.name || nm })
1033
+ return nm
1034
+ }
1035
+ function isGroupAlive(gid) {
1036
+ return (chatTg.group || []).some(function (g) { return g.id === gid })
1037
+ }
1038
+ function act(job, body, okMsg) {
1039
+ if (busyId) return
1040
+ setBusyId(job.id)
1041
+ fetch(API_TIMERS, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(Object.assign({ id: job.id, schedDir: _tmSD || undefined }, body || {})) })
1042
+ .then(function (r) { return r.json() })
1043
+ .then(function (d) {
1044
+ if (d && d.error) setMsg('失败: ' + d.error)
1045
+ else { setMsg(okMsg + ' ✓'); load() }
1046
+ })
1047
+ .catch(function (e) { setMsg('操作失败: ' + e.message) })
1048
+ .then(function () { setBusyId('') })
1049
+ }
1050
+ function who(job) {
1051
+ var scope = job.scope === 'c2c' ? 'c2c' : 'group'
1052
+ var nm = nameOf(scope, job.peerId)
1053
+ if (!nm) return (scope === 'c2c' ? '私聊' : '群聊') + ' · ' + String(job.peerId || '').slice(0, 8)
1054
+ return (scope === 'c2c' ? '私聊 · ' : '群聊 · ') + nm
1055
+ }
1056
+ function chip(kind) {
1057
+ return h('span', { style: { fontSize: 11, padding: '1px 8px', borderRadius: 999, color: '#fff', background: kind === 'daily' ? '#2f9e44' : '#4d7cfe', whiteSpace: 'nowrap' } },
1058
+ kind === 'daily' ? '每天' : '一次性')
1059
+ }
1060
+ // 左右分栏: 左=定时唤醒(编辑), 右=已安排的定时提醒(开关/删除)——有效利用横向空间
1061
+ var leftCol = h('div', { style: { flex: '1 1 44%', minWidth: 280, maxWidth: 560 } },
1062
+ h('div', { style: { ...card, padding: '10px 14px' } },
1063
+ h('div', { style: { fontWeight: 700, fontSize: 13 } }, '① 定时唤醒(每天到点,让她主动去群/私聊开口)'),
1064
+ h('p', { style: { fontSize: T.fsSm, color: T.text3 } }, '每个群/人一组,下面可加多个时刻。不用记号码——点「选聊天对象」挑见过的就行(群显示真名/人显示昵称)。改完点「保存」。'),
1065
+ h(WakeEditor, { acct: props.acct })))
1066
+ var rightCol = h('div', { style: { flex: '1 1 44%', minWidth: 280 } },
1067
+ h('div', { style: { ...card, padding: '10px 14px' } },
1068
+ h('div', { style: { fontWeight: 700, fontSize: 13 } }, '② 她答应你的定时提醒(在 QQ 里说一声就能建)'),
1069
+ h('p', { style: { fontSize: T.fsSm, color: T.text3 } }, '这些是她在 QQ 里应你要求安排的提醒。这里只管看/开关/删除;一次性到点自动消失。'),
1070
+ jobs === null ? h('p', { style: { fontSize: T.fsSm, color: T.text3 }, padding: 10 }, '加载中…') : null,
1071
+ jobs !== null && jobs.length === 0 ? h('p', { style: { color: '#888', padding: 14, textAlign: 'center', fontSize: T.fsSm } }, '还没有这类提醒。去 QQ 里让她安排一个,就会出现在这里。') : null,
1072
+ (jobs || []).map(function (j) {
1073
+ var disabled = busyId === j.id
1074
+ var deadGroup = j.scope === 'group' && !isGroupAlive(j.peerId)
1075
+ return h('div', { key: j.id, style: { border: '1px solid ' + (deadGroup ? T.danger + '88' : (j.enabled ? T.border : '#ffa94d66')), borderRadius: T.radius, padding: '8px 10px', margin: '6px 0', background: '#fff', opacity: j.enabled ? 1 : 0.72 } },
1076
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
1077
+ chip(j.kind),
1078
+ h('span', { style: { fontWeight: 600, fontSize: T.fsSm } }, fmtWhen(j)),
1079
+ h('span', { style: { fontSize: T.fsSm, color: deadGroup ? T.danger : T.text2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 150 } }, deadGroup ? (who(j) + '(群已失效)') : who(j)),
1080
+ h('span', { style: { flex: 1 } }),
1081
+ h('label', { style: { fontSize: T.fsSm, whiteSpace: 'nowrap', color: j.enabled ? T.primary : '#ffa94d' } },
1082
+ h('input', { className: 'qqs-cb', type: 'checkbox', checked: !!j.enabled, disabled: disabled, onChange: function (e) { act(j, { enabled: e.target.checked }, e.target.checked ? '已启用' : '已停用') } }),
1083
+ ' ' + (j.enabled ? '启用中' : '已停用')),
1084
+ h('button', { className: 'qqs-btn', disabled: disabled, style: { padding: '2px 8px', fontSize: T.fsSm }, onClick: function () { if (window.confirm('删除这条定时任务?')) act(j, {}, '已删除') } }, '删除')),
1085
+ j.prompt ? h('div', { style: { fontSize: T.fsSm, color: '#cfd3dc', background: '#ffffff0a', border: '1px solid #ffffff14', borderRadius: T.radius, padding: '4px 8px', marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, j.prompt) : null)
1086
+ }),
1087
+ msg ? h('p', { style: { fontSize: T.fsSm, color: T.success } }, msg) : null))
1088
+ return h('div', { style: { width: '100%' } },
1089
+ h('div', { style: { display: 'flex', gap: 14, alignItems: 'flex-start', flexWrap: 'wrap' } }, leftCol, rightCol),
1090
+ props && props.close ? h('button', { className: 'qqs-btn', onClick: props.close, style: { float: 'right', marginTop: 6 } }, '完成') : null)
1091
+ }
1092
+
1093
+ // ── 单入口壳: 设置 / 表情包图库 / 定时任务 三个胶囊 tab 页内切换 ──
1094
+ var TAB_KEY = 'qqs-home-tab'
1095
+ var ACCT_KEY = 'qqs-acct'
1096
+ function QqbotHome(props) {
1097
+ var [tab, setTab] = useState(null)
1098
+ var [accts, setAccts] = useState(null)
1099
+ var [acctId, setAcctId] = useState('')
1100
+ var [badges, setBadges] = useState({})
1101
+ // 当前操作账号(缺省=第一个启用的实例, 兼容单账号)
1102
+ function currentAcct() {
1103
+ var list = accts || []
1104
+ if (acctId) { var p = null; list.forEach(function (a) { if (a.id === acctId) p = a }); if (p) return p }
1105
+ var first = null
1106
+ list.forEach(function (a) { if (!first && !a.disabled) first = a })
1107
+ return first || list[0] || null
1108
+ }
1109
+ useEffect(function () {
1110
+ var saved = ''
1111
+ try { saved = localStorage.getItem(TAB_KEY) || '' } catch (e) {}
1112
+ setTab(saved === 'gallery' || saved === 'timers' || saved === 'accounts' ? saved : 'settings')
1113
+ fetch('/api/qqbot-settings/accounts').then(function (r) { return r.json() }).then(function (d) {
1114
+ if (d && Array.isArray(d.instances)) {
1115
+ setAccts(d.instances)
1116
+ var want = ''
1117
+ try { want = localStorage.getItem(ACCT_KEY) || '' } catch (e) {}
1118
+ var found = null
1119
+ d.instances.forEach(function (a) { if (a.id === want) found = a.id })
1120
+ setAcctId(found || '')
1121
+ }
1122
+ }).catch(function () {})
1123
+ }, [])
1124
+ // 角标按当前账号
1125
+ useEffect(function () {
1126
+ var ac = currentAcct()
1127
+ var alive = true
1128
+ var ddq = ac && ac.dataDir ? '&dataDir=' + encodeURIComponent(ac.dataDir) : ''
1129
+ var sdq = ac && ac.schedDir ? '?schedDir=' + encodeURIComponent(ac.schedDir) : ''
1130
+ Promise.all([
1131
+ fetch('/api/qqbot-settings/stickers?untagged=1&layer=candidate' + ddq).then(function (r) { return r.json() }).catch(function () { return {} }),
1132
+ fetch(API_TIMERS + sdq).then(function (r) { return r.json() }).catch(function () { return {} }),
1133
+ ]).then(function (rs) {
1134
+ if (!alive) return
1135
+ var unt = rs[0] && typeof rs[0].total === 'number' ? rs[0].total : 0
1136
+ var on = 0
1137
+ var list = (rs[1] && rs[1].jobs) || []
1138
+ list.forEach(function (j) { if (j.enabled) on += 1 })
1139
+ setBadges({ untagged: unt, timers: on })
1140
+ })
1141
+ return function () { alive = false }
1142
+ }, [accts, acctId])
1143
+ if (!tab) return h('div', null, '…')
1144
+ function choose(t) { setTab(t); try { localStorage.setItem(TAB_KEY, t) } catch (e) {} }
1145
+ function chooseAcct(id) { setAcctId(id); try { localStorage.setItem(ACCT_KEY, id) } catch (e) {} }
1146
+ function badge(n) {
1147
+ return n > 0 ? h('span', { style: { background: '#ff6b6b', color: '#fff', borderRadius: 9, padding: '0 6px', fontSize: 11, lineHeight: '16px', display: 'inline-block', fontWeight: 700, flexShrink: 0 } }, n) : null
1148
+ }
1149
+ var ac = currentAcct()
1150
+ // 传给按账号视图的上下文: 设置按 ns, 图库按 dataDir, 定时按 schedDir
1151
+ var acctx = ac ? { id: ac.id, ns: ac.ns, dataDir: ac.dataDir, schedDir: ac.schedDir } : null
1152
+ // key=账号: 切号即强制子页整页重挂(清掉旧账号 state, 全部重新拉取)
1153
+ var subKey = ac ? ac.id : 'main'
1154
+ var sub = tab === 'gallery' ? h(GalleryPage, { key: subKey, close: props && props.close, acct: acctx })
1155
+ : tab === 'timers' ? h(TimersPage, { key: subKey, close: props && props.close, acct: acctx })
1156
+ : tab === 'accounts' ? h(AccountsPage, { close: props && props.close })
1157
+ : h(SettingsPage, { key: subKey, close: props && props.close, acct: acctx })
1158
+ var acctLine = (accts && accts.length > 1) || (accts && accts.length === 1)
1159
+ return h('div', { style: { width: '100%' } },
1160
+ // ── 一级: 账号选择条(身份带) ──
1161
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', margin: '2px 0 10px', padding: '6px 10px', border: '1px solid #b197fc55', borderRadius: 12, background: 'linear-gradient(90deg,#b197fc22,#b197fc0a)' } },
1162
+ h('span', { style: { fontSize: 13, fontWeight: 800, color: '#171a21', whiteSpace: 'nowrap' } }, '🤖 正在查看:'),
1163
+ accts && accts.length > 0
1164
+ ? h('select', { className: 'qqs-inp', style: { padding: '4px 8px', fontWeight: 700 }, value: ac ? ac.id : '', onChange: function (e) { chooseAcct(e.target.value) } },
1165
+ accts.map(function (a) {
1166
+ return h('option', { key: a.id, value: a.id }, a.id + (a.appId ? ' (AppID ' + a.appId + ')' : '') + (a.disabled ? ' [停用]' : ''))
1167
+ }))
1168
+ : h('span', { style: { fontSize: 13, color: '#888' } }, '无账号… 去「账号与预设」添加'),
1169
+ ac ? h('span', { style: { fontSize: 12, color: '#6d28d9', whiteSpace: 'nowrap' } }, ac.dataDir ? '数据目录: ' + ac.dataDir : '') : null,
1170
+ h('span', { style: { flex: 1 } }),
1171
+ h('span', { style: { fontSize: 12, color: '#8a97ad' } }, '设置/图库/定时 都跟随上面选的机器人')),
1172
+ h('div', { style: { position: 'sticky', top: 0, zIndex: 30, background: 'transparent', padding: '10px 4px 12px', display: 'flex', gap: 10, flexWrap: 'nowrap', alignItems: 'stretch', marginBottom: 12 } },
1173
+ [['settings', '设置', '#ff5d5d'], ['gallery', '表情包图库', '#ffd97a'], ['timers', '定时任务', '#5b9dff'], ['accounts', '账号与预设', '#b197fc']].map(function (t) {
1174
+ var on = tab === t[0]
1175
+ var n = t[0] === 'gallery' ? badges.untagged : t[0] === 'timers' ? badges.timers : 0
1176
+ var col = t[2]
1177
+ return h('button', { key: t[0], onClick: function () { choose(t[0]) },
1178
+ style: {
1179
+ flex: 1, minWidth: 0, cursor: 'pointer', font: 'inherit',
1180
+ color: '#171a21', fontSize: 14, textAlign: 'center',
1181
+ whiteSpace: 'nowrap', padding: '9px 8px', borderRadius: 14,
1182
+ border: '1px solid ' + (on ? col : col + 'a6'),
1183
+ background: 'linear-gradient(165deg,' + col + (on ? '66' : '40') + ',' + col + (on ? '1f' : '14') + ')',
1184
+ boxShadow: on
1185
+ ? '0 0 18px ' + col + 'cc, inset 0 0 16px ' + col + '66'
1186
+ : '0 0 10px ' + col + '73, inset 0 0 12px ' + col + '47',
1187
+ fontWeight: on ? 800 : 600,
1188
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
1189
+ transition: 'all .15s', outline: 'none',
1190
+ } },
1191
+ t[1], badge(n))
1192
+ })),
1193
+ sub)
1194
+ }
1195
+
1196
+ // ── 账号与预设(第4 tab): 多机器人账号(appId/secret/预设/cwd) + agent 预设复制管理 ──
1197
+ var API_ACCOUNTS = '/api/qqbot-settings/accounts'
1198
+ var API_PRESETS = '/api/qqbot-settings/presets'
1199
+ var API_BIND = '/api/qqbot-settings/bind'
1200
+ var API_WORKSPACES = '/api/qqbot-settings/workspaces'
1201
+ // appSecret 掩码(借鉴 dsh-qqbot-panel): host 只回显掩码, 留空/掩码保存=保留原值
1202
+ var SECRET_MASK = '********'
1203
+ var PRESET_HINT = '想改人设:点预设卡片「打开文件夹」,编辑里面 agent.cordis.yml —— persona 插件 config.text 那段就是人设正文(改完保存并重启 dsh 生效);preset.yml 的 name 是预设显示名。做新人格:选一个预设点「复制」,填「预设 id」(=文件夹名,字母/数字开头,可含 - _) 和「人设名」(中文随意),保存后自动生成新预设文件夹。'
1204
+ function inputRow(label, node) {
1205
+ return h('label', { style: { display: 'block', margin: '5px 0', fontSize: 13 } }, label, node)
1206
+ }
1207
+ function AccountsPage(props) {
1208
+ var [insts, setInsts] = useState(null)
1209
+ var [presets, setPresets] = useState([])
1210
+ var [root, setRoot] = useState('')
1211
+ var [workspaces, setWorkspaces] = useState([])
1212
+ var [msg, setMsg] = useState('')
1213
+ var [busy, setBusy] = useState('')
1214
+ var [bind, setBind] = useState(null) // {id,url,status,error,iframeUrl}
1215
+ var [copy, setCopy] = useState(null) // 复制弹层: {sourceId}
1216
+ var [copyId, setCopyId] = useState('')
1217
+ var [copyName, setCopyName] = useState('')
1218
+ var pollRef = React.useRef(null)
1219
+ function load(quiet) {
1220
+ Promise.all([
1221
+ fetch(API_ACCOUNTS).then(function (r) { return r.json() }).catch(function () { return {} }),
1222
+ fetch(API_PRESETS).then(function (r) { return r.json() }).catch(function () { return {} }),
1223
+ fetch(API_WORKSPACES).then(function (r) { return r.json() }).catch(function () { return {} }),
1224
+ ]).then(function (rs) {
1225
+ var a = rs[0] || {}
1226
+ if (Array.isArray(a.instances)) setInsts(a.instances)
1227
+ var p = rs[1] || {}
1228
+ if (Array.isArray(p.presets)) { setPresets(p.presets); if (p.root) setRoot(p.root) }
1229
+ var w = rs[2] || {}
1230
+ if (Array.isArray(w.workspaces)) setWorkspaces(w.workspaces)
1231
+ if (!quiet) setMsg('')
1232
+ })
1233
+ }
1234
+ useEffect(function () { load(true) }, [])
1235
+ // 自动填充: 账号没选 preset 时自动带上候选中的第一个(浏览器 select 默认显示 ≠ state 值,
1236
+ // 不真正写入 state 的话保存会丢 preset——主人实测: 不手动切换选择就保存不了)。
1237
+ // 与渲染处同规则算候选(host 没给 hasChannelTools 字段时视全部为候选, 不锁死)。
1238
+ // 候选数量不限: 有候选就把空缺账号填上第一个, 用户仍可手动改选。
1239
+ useEffect(function () {
1240
+ if (!Array.isArray(presets) || presets.length === 0) return
1241
+ if (!Array.isArray(insts) || insts.length === 0) return
1242
+ var know = presets.every(function (p) { return p.hasChannelTools === true || p.hasChannelTools === false })
1243
+ var only = know ? presets.filter(function (p) { return p.hasChannelTools }) : presets
1244
+ if (only.length === 0) return
1245
+ var changed = false
1246
+ var next = insts.map(function (x) {
1247
+ if (x.preset) return x
1248
+ changed = true
1249
+ return Object.assign({}, x, { preset: only[0].id })
1250
+ })
1251
+ if (changed) setInsts(next)
1252
+ }, [insts, presets])
1253
+ useEffect(function () { return function () { if (pollRef.current) clearInterval(pollRef.current) } }, [])
1254
+ function upd(i, patch) { setInsts(function (list) { var n = list.slice(); n[i] = Object.assign({}, n[i], patch); return n }) }
1255
+ function addBlank() {
1256
+ var base = 'im-qqbot'
1257
+ var used = new Set((insts || []).map(function (x) { return x.id }))
1258
+ var n = 2
1259
+ while (used.has(base + '-' + n)) n += 1
1260
+ setInsts(function (list) { return (list || []).concat([{ id: base + '-' + n, appId: '', appSecret: '', preset: '', cwd: '', disabled: false, _new: true }]) })
1261
+ }
1262
+ function addBound(cred) {
1263
+ var base = 'im-qqbot'
1264
+ var used = new Set((insts || []).map(function (x) { return x.id }))
1265
+ var n = 2
1266
+ while (used.has(base + '-' + n)) n += 1
1267
+ setInsts(function (list) { return (list || []).concat([{ id: base + '-' + n, appId: cred.appId, appSecret: cred.appSecret, preset: '', cwd: '', disabled: false, _bound: true }]) })
1268
+ }
1269
+ function save() {
1270
+ if (busy) return
1271
+ setBusy('save'); setMsg('保存中…')
1272
+ var list = (insts || []).filter(function (x) { return x.id && (x.appId || x.appSecret || x.hasSecret || !x._new || x._bound) })
1273
+ // 空账号(全空且没绑定)不保存; 全新手动号若两凭据都空则提示(hasSecret=已保存的账号不算空)
1274
+ var blanks = list.filter(function (x) { return !x.appSecret && !x.hasSecret && !x._bound })
1275
+ if (blanks.length && !window.confirm('有 ' + blanks.length + ' 个账号没填 appSecret(不填的会被保存为只有骨架的实例, 可能启动就弹扫码)。继续?')) { setBusy(''); return }
1276
+ // 保存兜底: preset 仍为空的账号自动填第一个候选(防 effect 未触发/用户未手动选导致保存丢 preset)
1277
+ var knowNow = presets.length > 0 && presets.every(function (p) { return p.hasChannelTools === true || p.hasChannelTools === false })
1278
+ var candNow = knowNow ? presets.filter(function (p) { return p.hasChannelTools }) : presets
1279
+ if (candNow.length > 0) {
1280
+ var autoId = candNow[0].id
1281
+ list = list.map(function (x) { return x.preset ? x : Object.assign({}, x, { preset: autoId }) })
1282
+ }
1283
+ fetch(API_ACCOUNTS + '/save', {
1284
+ method: 'POST', headers: { 'content-type': 'application/json' },
1285
+ body: JSON.stringify({ instances: list.map(function (x) { return { id: x.id, appId: x.appId, appSecret: x.appSecret, preset: x.preset, cwd: x.cwd, disabled: !!x.disabled } }) }),
1286
+ }).then(function (r) { return r.json() }).then(function (d) {
1287
+ setBusy('')
1288
+ if (d && d.error) setMsg('保存失败: ' + d.error)
1289
+ else setMsg('已保存到 cordis.patch.yml ✓ —— 重启 dsh(web) 后生效')
1290
+ load(true)
1291
+ }).catch(function (e) { setBusy(''); setMsg('保存异常: ' + e.message) })
1292
+ }
1293
+ function removeInst(i) {
1294
+ var list = (insts || []).slice()
1295
+ var one = list[i]
1296
+ if (!window.confirm('删除账号实例 ' + one.id + '? (它的配置会从 cordis.patch.yml 移除)')) return
1297
+ list.splice(i, 1)
1298
+ setInsts(list)
1299
+ }
1300
+ // ── 扫码绑定 ──
1301
+ function startBind() {
1302
+ setBind({ id: '', url: '', status: 'start', error: '' })
1303
+ fetch(API_BIND + '/start', { method: 'POST' }).then(function (r) { return r.json() }).then(function (d) {
1304
+ if (d && d.error) { setBind({ id: '', url: '', status: 'error', error: d.error }); return }
1305
+ var b = { id: d.id, url: d.url, status: 'wait', error: '', iframeUrl: d.url }
1306
+ setBind(b)
1307
+ if (pollRef.current) clearInterval(pollRef.current)
1308
+ pollRef.current = setInterval(function () {
1309
+ fetch(API_BIND + '/poll?id=' + encodeURIComponent(b.id)).then(function (r) { return r.json() }).then(function (p) {
1310
+ if (p.status === 'done') {
1311
+ clearInterval(pollRef.current); pollRef.current = null
1312
+ addBound({ appId: p.appId, appSecret: p.appSecret })
1313
+ setMsg('🎉 扫码绑定成功! 平台下发的 AppID/Secret 已填好, 起个名字保存即可')
1314
+ setBind(null)
1315
+ } else if (p.status === 'error') {
1316
+ clearInterval(pollRef.current); pollRef.current = null
1317
+ setBind({ id: b.id, url: p.url || b.url, status: 'error', error: p.error || '绑定失败', iframeUrl: p.url || b.url })
1318
+ } else {
1319
+ setBind(function (old) { return old && old.id === b.id ? Object.assign({}, old, { url: p.url || old.url, iframeUrl: (p.url && p.url !== old.url) ? p.url : old.iframeUrl }) : old })
1320
+ }
1321
+ }).catch(function () {})
1322
+ }, 2500)
1323
+ }).catch(function (e) { setBind({ id: '', url: '', status: 'error', error: e.message }) })
1324
+ }
1325
+ function cancelBind() {
1326
+ if (bind && bind.id) { try { fetch(API_BIND + '/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: bind.id }) }) } catch (e) {} }
1327
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
1328
+ setBind(null)
1329
+ }
1330
+ // ── 预设复制 ──
1331
+ function doCopy() {
1332
+ if (!copy) return
1333
+ setBusy('copy')
1334
+ var url = copy.fromStandard ? API_PRESETS + '/new' : API_PRESETS + '/copy'
1335
+ var payload = copy.fromStandard
1336
+ ? { id: copyId.trim(), name: copyName.trim() }
1337
+ : { sourceId: copy.sourceId, newId: copyId.trim(), newName: copyName.trim() }
1338
+ fetch(url, {
1339
+ method: 'POST', headers: { 'content-type': 'application/json' },
1340
+ body: JSON.stringify(payload),
1341
+ }).then(function (r) { return r.json() }).then(function (d) {
1342
+ setBusy('')
1343
+ if (d && d.error) { setMsg('复制失败: ' + d.error); return }
1344
+ setMsg('✅ 新预设已生成: ' + d.newId + (copy.fromStandard ? ' (复制自标准模式, 已带 QQ 工具)' : '') + '。去「打开文件夹」改人设吧。')
1345
+ setCopy(null); setCopyId(''); setCopyName('')
1346
+ load(true)
1347
+ }).catch(function (e) { setBusy(''); setMsg('复制异常: ' + e.message) })
1348
+ }
1349
+ function openPreset(id) {
1350
+ fetch(API_PRESETS + '/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: id }) })
1351
+ .then(function (r) { return r.json() }).then(function (d) { if (d && d.ok) setMsg('已在本机打开文件夹'); else if (d && d.error) setMsg(d.error) })
1352
+ .catch(function (e) { setMsg('打开失败: ' + e.message) })
1353
+ }
1354
+ var st = { width: '100%', padding: '5px 8px', boxSizing: 'border-box' }
1355
+ return h('div', { style: { maxWidth: 860 } },
1356
+ h('h2', null, '账号与预设'),
1357
+ h('p', { style: { fontSize: 12, color: '#888' } }, '一个 QQ 机器人 = 一个账号实例(AppID/AppSecret)。想同时开几个机器人就加几个实例,每个可以配不同的 agent 预设(人格)与工作目录(各人各数据,不串)。⚠️ appSecret 会明文存在本机 cordis.patch.yml(改前自动备份 .bak),别把配置文件传上网。'),
1358
+ h('div', { style: sectionTitle }, '① 机器人账号(改完点保存, 重启生效)'),
1359
+ (insts === null ? h('p', null, '加载中…') : null),
1360
+ (insts || []).map(function (it, i) {
1361
+ // 账号可选预设: 有 QQ 工具的优先。容错: host 若还没返回 hasChannelTools(旧版未重启), 则不锁死、照常全列, 保证能选能填;
1362
+ // 一旦 host 给全了该字段, 就只列带 QQ 工具(不带行的选了在 QQ 跑不起来)。
1363
+ var knowChannel = presets.length > 0 && presets.every(function (p) { return p.hasChannelTools === true || p.hasChannelTools === false })
1364
+ var usable = knowChannel ? presets.filter(function (p) { return p.hasChannelTools }) : presets.slice()
1365
+ var selInvalid = it.preset && !usable.some(function (p) { return p.id === it.preset })
1366
+ var opts = usable.map(function (p) { return { id: p.id, label: p.name + ' (' + p.id + ')' } })
1367
+ if (selInvalid) opts.unshift({ id: it.preset, label: '⚠️ ' + it.preset + '(不带QQ工具, 无法在QQ用)' })
1368
+ return h('div', { key: it.id, style: { border: '1px solid ' + (it.disabled ? '#ffa94d66' : '#00000026'), borderRadius: 10, padding: '8px 12px', margin: '8px 0', background: 'rgba(255,255,255,.5)' } },
1369
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1370
+ h('span', { style: { fontWeight: 800, color: '#171a21' } }, it._bound ? '📱 扫码绑定' : (it._new ? '🆕 新账号' : '🤖 账号')),
1371
+ h('input', { className: 'qqs-inp', style: Object.assign({ width: 170 }, st), value: it.id, disabled: !it._new && !it._bound, onChange: function (e) { upd(i, { id: e.target.value }) }, placeholder: '实例 id(如 im-qqbot-2)' }),
1372
+ h('label', { style: { fontSize: 13, whiteSpace: 'nowrap' } },
1373
+ h('input', { className: 'qqs-cb', type: 'checkbox', checked: !it.disabled, onChange: function (e) { upd(i, { disabled: !e.target.checked }) } }), ' 启用'),
1374
+ h('button', { className: 'qqs-btn', onClick: function () { removeInst(i) }, style: { marginLeft: 'auto' } }, '删除')),
1375
+ inputRow('AppID: ', h('input', { className: 'qqs-inp', style: Object.assign({ width: 220 }, st), value: it.appId || '', onChange: function (e) { upd(i, { appId: e.target.value }) }, placeholder: '如 1234567890' })),
1376
+ inputRow('AppSecret: ', h('input', { className: 'qqs-inp', type: 'password', style: Object.assign({ width: 320 }, st), value: (it.appSecret === SECRET_MASK || (!it.appSecret && it.hasSecret)) ? '' : (it.appSecret || ''), onChange: function (e) { upd(i, { appSecret: e.target.value }) }, placeholder: (it.hasSecret && !it._new) ? '已保存(留空/掩码=保留原值; 填新值=更换)' : '扫码绑定会自动填; 也可手动填(两个都要填全才不弹码)' })),
1377
+ inputRow('Agent 预设(人格): ', (function () { if (usable.length === 0 && !selInvalid) {
1378
+ return h('span', { className: 'qqs-inp', style: { display: 'inline-block', verticalAlign: 'middle', padding: '5px 10px', fontSize: 12, color: '#e8590c', background: '#fff5f0', borderRadius: 6 } }, '⚠️ 还没有可用的 Agent 预设——请在下方②复制一个(如 whale-girl / whitegirl), 复制会自动带 QQ 工具。')
1379
+ } return h('select', { className: 'qqs-inp', style: { width: 320, padding: '4px 8px' }, value: it.preset || '', onChange: function (e) { upd(i, { preset: e.target.value }) } }, opts.map(function (o) { return h('option', { key: o.id, value: o.id, style: selInvalid && o.id === it.preset ? { color: '#e03131' } : null }, o.label) })) })()),
1380
+ inputRow('工作目录(各账号数据放这, 留空=默认): ', h('input', { className: 'qqs-inp', style: Object.assign({ width: '90%' }, st), value: it.cwd || '', onChange: function (e) { upd(i, { cwd: e.target.value }) }, placeholder: '如 D:\\bots\\二号机' })),
1381
+ (function () {
1382
+ var ws = (workspaces || []).filter(function (w) { return w.path && w.count > 0 })
1383
+ if (ws.length === 0) return null
1384
+ return h('div', { style: { margin: '2px 0 4px', fontSize: 12, color: '#555' } },
1385
+ '📁 已有 dsh 会话的工作区(点选填入上方): ',
1386
+ ws.map(function (w) {
1387
+ return h('button', { key: w.dir, className: 'qqs-btn', style: { fontSize: 11, padding: '1px 8px', margin: '2px 4px 2px 0' }, onClick: function () { upd(i, { cwd: w.path }) } },
1388
+ (w.path.length > 46 ? w.path.slice(0, 43) + '…' : w.path) + ' (' + w.count + ')')
1389
+ }))
1390
+ })())
1391
+ }),
1392
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', margin: '8px 0' } },
1393
+ h('button', { className: 'qqs-btn', onClick: addBlank }, '+ 添加账号(手动填凭据)'),
1394
+ h('button', { className: 'qqs-btn', onClick: startBind, style: { borderColor: '#b197fc99', color: '#171a21' } }, '📱 扫码绑定新机器人(推荐)'),
1395
+ h('span', { style: { flex: 1 } }),
1396
+ h('button', { className: 'qqs-btn', onClick: save, disabled: busy === 'save' }, '保存账号配置'),
1397
+ h('button', { className: 'qqs-btn', onClick: function () { load() } }, '重新读取')),
1398
+ h('div', { style: { ...sectionTitle, marginTop: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1399
+ '② Agent 预设(人格库)',
1400
+ h('button', { className: 'qqs-btn', style: { fontSize: 12, padding: '2px 10px', borderColor: '#2b8a3e99', color: '#171a21' }, onClick: function () { setCopy({ fromStandard: true, sourceId: 'standard' }); setCopyId(''); setCopyName('') } }, '➕ 从标准模式新建(带QQ工具)')),
1401
+ h('p', { style: { fontSize: 12, color: '#888' } }, PRESET_HINT),
1402
+ (presets.filter(function (p) { return !p.hasChannelTools }).length > 0
1403
+ ? h('p', { style: { fontSize: 12, color: '#e8590c', background: '#fff5f0', borderRadius: 6, padding: '6px 10px' } }, '💡 选一个预设点「复制」生成的副本会自动带上 QQ 工具(send_media/发图等), 之后就能在①账号里给机器人选用了。复制源无所谓, 只要是能跑的人格。')
1404
+ : null),
1405
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(190px,1fr))', gap: 10, margin: '8px 0' } },
1406
+ presets.map(function (p) {
1407
+ return h('div', { key: p.id, style: { border: '1px solid #00000026', borderRadius: 10, padding: '10px 12px', background: 'rgba(255,255,255,.5)' } },
1408
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
1409
+ h('span', { style: { fontWeight: 800, color: '#171a21' } }, p.name),
1410
+ h('span', { style: { fontSize: 10, padding: '1px 6px', borderRadius: 8, color: '#fff', background: p.hasChannelTools ? '#2b8a3e' : '#e8590c' } }, p.hasChannelTools ? 'QQ可用' : '无QQ工具')),
1411
+ h('div', { style: { fontSize: 12, color: '#666', margin: '4px 0' } }, 'id=' + p.id),
1412
+ h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1413
+ h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 }, onClick: function () { setCopy({ sourceId: p.id }); setCopyId(''); setCopyName('') } }, '复制'),
1414
+ h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 }, onClick: function () { openPreset(p.id) } }, '📂 打开文件夹')))
1415
+ })),
1416
+ copy ? h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: function () { if (!busy) setCopy(null) } },
1417
+ h('div', { className: 'qqs-modal', style: { background: '#22252e', color: '#e8e8e8', borderRadius: 12, padding: 14, maxWidth: 460, width: '92%' }, onClick: function (e) { e.stopPropagation() } },
1418
+ h('h3', { style: { marginTop: 0 } }, copy.fromStandard ? '从标准模式新建预设' : '复制预设: ' + copy.sourceId),
1419
+ inputRow('预设 id(=新文件夹名, 字母/数字开头, 可含 - _): ', h('input', { className: 'qqs-inp', style: { width: '100%', boxSizing: 'border-box' }, value: copyId, onChange: function (e) { setCopyId(e.target.value) }, placeholder: '如 my-girl-2' })),
1420
+ inputRow('人设名(显示用, 中文随意): ', h('input', { className: 'qqs-inp', style: { width: '100%', boxSizing: 'border-box' }, value: copyName, onChange: function (e) { setCopyName(e.target.value) }, placeholder: '如 我的二号人格' })),
1421
+ h('div', { style: { textAlign: 'right', marginTop: 10 } },
1422
+ h('button', { className: 'qqs-btn', onClick: function () { if (!busy) setCopy(null) }, style: { marginRight: 8 } }, '取消'),
1423
+ h('button', { className: 'qqs-btn', onClick: doCopy, disabled: busy === 'copy' }, '生成新预设')))) : null,
1424
+ bind ? h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' } },
1425
+ h('div', { className: 'qqs-modal', style: { background: '#22252e', color: '#e8e8e8', borderRadius: 12, padding: 14, maxWidth: 620, width: '94%' }, onClick: function (e) { e.stopPropagation() } },
1426
+ h('h3', { style: { marginTop: 0 } }, bind.status === 'error' ? '绑定失败' : '扫码绑定 QQ 机器人'),
1427
+ bind.status === 'error'
1428
+ ? h('p', { style: { color: '#ffa94d' } }, '失败: ' + (bind.error || '未知'))
1429
+ : h('div', null,
1430
+ h('p', { style: { fontSize: 13, color: '#cfd3dc' } }, '这个授权页只认「手机 QQ」打开(终端启动时是拿手机扫屏幕上的码 = 同一个链接)。请照做:'),
1431
+ h('ol', { style: { fontSize: 13, color: '#cfd3dc', margin: '4px 0 8px', paddingLeft: 20 } },
1432
+ h('li', null, '点「复制链接」,把链接发到手机 QQ(如发给"文件传输助手")'),
1433
+ h('li', null, '在手机 QQ 里点开链接,按页面提示完成机器人绑定(没有第二个机器人,就在页面里选/建一个)'),
1434
+ h('li', null, '完成后本页自动收到并填好 AppID/AppSecret')),
1435
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', background: '#ffffff12', border: '1px solid #ffffff26', borderRadius: 8, padding: '6px 8px' } },
1436
+ h('span', { style: { flex: 1, fontSize: 11, color: '#9fb0c9', wordBreak: 'break-all', maxHeight: 60, overflow: 'auto' } }, bind.url),
1437
+ h('button', { className: 'qqs-btn', onClick: function () { try { navigator.clipboard.writeText(bind.url).then(function () { setBind(function (o) { return o ? Object.assign({}, o, { copied: true }) : o }) }).catch(function () {}) } catch (e) {} } }, bind.copied ? '已复制 ✓' : '复制链接')),
1438
+ h('p', { style: { fontSize: 11, color: '#8a97ad' } }, '提示: 二维码内容就是这个链接——手机 QQ 扫电脑屏的码 = 在手机 QQ 里点开它, 效果相同。等待中…(过期会自动刷新, 重新复制最新链接即可)')),
1439
+ h('div', { style: { textAlign: 'right', marginTop: 8 } },
1440
+ h('button', { className: 'qqs-btn', onClick: cancelBind }, '取消绑定')))) : null,
1441
+ msg ? h('p', { style: { fontSize: 13, color: '#1d7a31', fontWeight: 600 } }, msg) : null,
1442
+ props && props.close ? h('button', { className: 'qqs-btn', onClick: props.close, style: { float: 'right', marginTop: 6 } }, '完成') : null)
1443
+ }
1444
+
1445
+ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180deg,#ffffff,#f5f6f8);border:1px solid #d0d5dd;border-radius:9px;padding:5px 13px;cursor:pointer;transition:all .15s}.qqs-btn:hover{background:#ffffff;border-color:#7c6cf0;color:#7c6cf0}.qqs-btn:active{transform:translateY(1px)}.qqs-inp,.qqs-area{font:inherit;color:#1f2329;background:#ffffff;border:1px solid #d0d5dd;border-radius:9px;padding:5px 10px;outline:none;transition:border-color .15s}.qqs-inp::placeholder,.qqs-area::placeholder{color:#9aa1ab}.qqs-inp:focus,.qqs-area:focus{border-color:#7c6cf0;box-shadow:0 0 0 2px #7c6cf033}.qqs-cb{accent-color:#7c6cf0;width:15px;height:15px}.qqs-card{transition:box-shadow .15s,border-color .15s}.qqs-card:hover{box-shadow:0 2px 10px #0002}.qqs-panel{background:linear-gradient(180deg,#ffffff,#f7f8fa);border:1px solid #e2e5ea;border-radius:16px;padding:16px;box-shadow:0 10px 34px #0002}.qqs-toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;background:#f7f8fa;border:1px solid #e2e5ea;border-radius:12px;padding:8px 10px;margin-bottom:10px}.qqs-modal{background:#ffffff;color:#1f2329}"
1446
+ function ensureCss() { try { if (!document.getElementById('qqs-css')) { var st = document.createElement('style'); st.id = 'qqs-css'; st.textContent = QQS_CSS; document.head.appendChild(st) } } catch (e) {} }
1447
+
1448
+ function apply(ctx) {
1449
+ ensureCss()
1450
+ var slots = ctx.slots
1451
+ if (!slots) { console.warn('[qqbot-settings] slots unavailable'); return }
1452
+ slots.inject('settings.section', function () {
1453
+ return slots.register(
1454
+ { name: 'settings.section', id: 'qqbot', order: 32, label: 'QQ 机器人' },
1455
+ function (props) { return h(QqbotHome, { close: props && props.close }) })
1456
+ })
1457
+ }
1458
+
1459
+ exports.inject = ['slots']
1460
+ exports.apply = apply
1461
+ return module.exports
1462
+ }
1463
+ })