@hedeqiang/dsh-nl2sql 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -15,6 +15,32 @@ window.__ModuleLoader__.load({
15
15
  // ------------------------------------------------------------ locale
16
16
 
17
17
  const zh = {
18
+ 'mb.pick.people': "从和机器人说过话的人中选择",
19
+ 'mb.pick.chats': "从机器人所在的群中选择",
20
+ 'mb.pick.refresh': "刷新",
21
+ 'mb.pick.noPeople': "还没有人和机器人说过话。先让对方在群里 @机器人 或私聊发一句话(会提示未开通),这里就会出现,点一下即可添加。",
22
+ 'mb.pick.noChats': "还没有可选的群。把机器人拉进群并 @它一次,或确认应用已开通「获取群信息」权限。",
23
+ 'mb.pick.peopleHint': "点选即可自动填入账号 ID 和名称,也可以在下方手动填写。",
24
+ 'mb.pick.chatsHint': "点选群即可自动填入群 ID 和群名;群内所有成员按该群的权限使用。",
25
+ 'mb.pick.via': "来自渠道:{name}",
26
+ 'mb.pick.taken': "已是成员「{name}」",
27
+ 'mb.pick.refused': "曾因未开通被拒绝",
28
+ 'mb.pick.pending': "待开通",
29
+ 'mb.pick.added': "已添加",
30
+ 'mb.pending.title': "有 {n} 个人或群向机器人提问时因未开通被拒绝,点一下即可添加为成员:",
31
+ 'mb.pending.add': "添加",
32
+ 'dash.sourceOf': "数据源",
33
+ 'dash.sourceMissing': "该图表的数据源已删除,无法刷新",
34
+ 'dash.sourceMissingShort': "数据源已删除",
35
+ 'dash.scope': "显示范围",
36
+ 'dash.scopeAll': "全部数据源",
37
+ 'dash.scopeCurrent': "仅 {name}",
38
+ 'dash.sources': "包含数据源:{list}",
39
+ 'dash.scopeEmpty': "这个看板里没有「{name}」的图表",
40
+ 'dash.scopeEmptyHint': "另有 {n} 个其他数据源的图表被隐藏。",
41
+ 'im.attachFull': "超过 {n} 行的结果附带完整数据 Excel",
42
+ 'im.rowsLimit': "消息中每个结果最多显示 {n} 行,超出部分会注明;完整数据可在页面导出 Excel",
43
+ 'im.sentFile': "已附带完整数据 Excel",
18
44
  "settings.botDefaultRole": "未登记的用户",
19
45
  "settings.larkDefaultRole": "未登记的用户",
20
46
  "ch.savedTestFailedMsg": "已保存,但测试失败:{msg}",
@@ -1006,6 +1032,32 @@ window.__ModuleLoader__.load({
1006
1032
  'error.generic': '操作失败:{message}',
1007
1033
  }
1008
1034
  const en = {
1035
+ 'mb.pick.people': "Pick someone who talked to a bot",
1036
+ 'mb.pick.chats': "Pick a group the bot is in",
1037
+ 'mb.pick.refresh': "Refresh",
1038
+ 'mb.pick.noPeople': "Nobody has talked to a bot yet. Ask them to @-mention the bot or DM it once (they will be told they have no access); they then appear here for one-click adding.",
1039
+ 'mb.pick.noChats': "No groups to pick yet. Add the bot to a group and @-mention it once, or check the app can read chat info.",
1040
+ 'mb.pick.peopleHint': "Picking fills in the account id and name; you can also type them below.",
1041
+ 'mb.pick.chatsHint': "Picking fills in the chat id and name; everyone in the group uses its access.",
1042
+ 'mb.pick.via': "via channel: {name}",
1043
+ 'mb.pick.taken': "already member \"{name}\"",
1044
+ 'mb.pick.refused': "was refused (no access)",
1045
+ 'mb.pick.pending': "pending",
1046
+ 'mb.pick.added': "added",
1047
+ 'mb.pending.title': "{n} people or groups were refused by a bot because they are not members. Click to add:",
1048
+ 'mb.pending.add': "Add",
1049
+ 'dash.sourceOf': "Data source",
1050
+ 'dash.sourceMissing': "This chart's data source was deleted; it cannot refresh",
1051
+ 'dash.sourceMissingShort': "source deleted",
1052
+ 'dash.scope': "Show",
1053
+ 'dash.scopeAll': "All sources",
1054
+ 'dash.scopeCurrent': "Only {name}",
1055
+ 'dash.sources': "Sources: {list}",
1056
+ 'dash.scopeEmpty': "No charts from \"{name}\" on this dashboard",
1057
+ 'dash.scopeEmptyHint': "{n} charts from other sources are hidden.",
1058
+ 'im.attachFull': "Attach full data as Excel for results over {n} rows",
1059
+ 'im.rowsLimit': "Messages show up to {n} rows per result and say when rows are omitted; export Excel on the page for full data",
1060
+ 'im.sentFile': "full data attached as Excel",
1009
1061
  "settings.botDefaultRole": "Unregistered users",
1010
1062
  "settings.larkDefaultRole": "Unregistered users",
1011
1063
  "ch.savedTestFailedMsg": "Saved, but the test failed: {msg}",
@@ -1918,7 +1970,17 @@ window.__ModuleLoader__.load({
1918
1970
  .n2s-morebtn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;height:36px;margin-top:6px;border:.5px dashed var(--n2s-line-2);border-radius:var(--n2s-radius-sm);background:transparent;color:var(--n2s-muted);font:inherit;font-size:13px;cursor:pointer}
1919
1971
  .n2s-morebtn:hover{background:var(--n2s-hover);color:var(--dsw-alias-label-primary)}
1920
1972
  .n2s-btn.danger-text{color:var(--n2s-err)}
1973
+ /* ---- member identity picker ---- */
1974
+ .n2s-pickchips{display:flex;flex-wrap:wrap;gap:6px}
1975
+ .n2s-pickchip{display:inline-flex;align-items:center;gap:6px;max-width:100%;height:30px;padding:0 10px;border:.5px solid var(--n2s-line-2);border-radius:999px;background:var(--n2s-surface);color:inherit;font:inherit;font-size:12.5px;cursor:pointer}
1976
+ .n2s-pickchip:hover:not(:disabled){background:var(--n2s-hover)}
1977
+ .n2s-pickchip[aria-pressed=true]{border-color:var(--n2s-accent);background:var(--n2s-accent-soft,rgba(51,112,255,.08));color:var(--n2s-accent)}
1978
+ .n2s-pickchip:disabled{opacity:.55;cursor:default}
1979
+ .n2s-pickchip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
1980
+ .n2s-idpicker{gap:6px}
1921
1981
  /* ---- dashboard v2 ---- */
1982
+ .n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
1983
+ .n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
1922
1984
  .n2s-dash{grid-template-columns:repeat(6,minmax(0,1fr));align-items:start}
1923
1985
  .n2s-widget.w-third{grid-column:span 2}
1924
1986
  .n2s-widget.w-half{grid-column:span 3}
@@ -2096,6 +2158,10 @@ window.__ModuleLoader__.load({
2096
2158
  /** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
2097
2159
  let uiLang = 'zh'
2098
2160
  // LocaleSnapshot is { active, locales, revision } (dsh-client-locale); older hosts passed a string.
2161
+ /** Join a list for display in the UI language: 「a、b」 / "a, b". */
2162
+ const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
2163
+ /** Label + colon in the UI language: 「标签:」 / "Label: ". */
2164
+ const colon = (label) => (uiLang === 'zh' ? `${label}:` : `${label}: `)
2099
2165
  const setUiLang = (snapshot) => {
2100
2166
  const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
2101
2167
  uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
@@ -3280,7 +3346,7 @@ window.__ModuleLoader__.load({
3280
3346
  if (section === 'glossary') list = k.glossary.length ? k.glossary.map(g => item(g.id, h(React.Fragment, null, g.term, g.synonyms?.length ? h('span', { className: 'n2s-muted n2s-small' }, ` ${g.synonyms.join(' / ')}`) : null),
3281
3347
  h('div', null, h('div', null, g.definition), g.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 6 } }, g.sql)), 'glossary', g)) : empty(t('kb.glossaryEmpty'))
3282
3348
  if (section === 'metric') list = k.metrics.length ? k.metrics.map(m => item(m.id, h(React.Fragment, null, m.name, m.unit ? h('span', { className: 'n2s-badge', style: { marginLeft: 6 } }, m.unit) : null),
3283
- h('div', null, h('div', { className: 'n2s-mono n2s-small' }, `${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}`), m.timeColumn && h('div', { className: 'n2s-muted n2s-small' }, `${t('kb.timeColumn')}: ${m.timeColumn}${m.dimensions?.length ? ` · ${t('kb.dimensions')}: ${m.dimensions.join('、')}` : ''}${m.synonyms?.length ? ` · ${t('kb.synonymsLabel')}: ${m.synonyms.join('、')}` : ''}`), m.description && h('div', { className: 'n2s-muted n2s-small' }, m.description)), 'metric', m)) : empty(t('kb.metricsEmpty'))
3349
+ h('div', null, h('div', { className: 'n2s-mono n2s-small' }, `${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}`), m.timeColumn && h('div', { className: 'n2s-muted n2s-small' }, `${t('kb.timeColumn')}: ${m.timeColumn}${m.dimensions?.length ? ` · ${t('kb.dimensions')}: ${m.dimensions.join(uiLang === 'zh' ? '、' : ', ')}` : ''}${m.synonyms?.length ? ` · ${t('kb.synonymsLabel')}: ${m.synonyms.join(uiLang === 'zh' ? '、' : ', ')}` : ''}`), m.description && h('div', { className: 'n2s-muted n2s-small' }, m.description)), 'metric', m)) : empty(t('kb.metricsEmpty'))
3284
3350
  if (section === 'note') {
3285
3351
  const entries = Object.entries(k.notes).sort(([a], [b]) => a.localeCompare(b))
3286
3352
  list = h(React.Fragment, null,
@@ -3465,7 +3531,7 @@ window.__ModuleLoader__.load({
3465
3531
  h('span', { className: 'n2s-tile', 'aria-hidden': true }, svg(16, [h('circle', { key: 1, cx: 8, cy: 8, r: 5.5 }), h('path', { key: 2, d: 'M8 5v3.2l2 1.3' })])),
3466
3532
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
3467
3533
  h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, r.title), r.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), r.channelName)),
3468
- h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join('、') + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
3534
+ h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(uiLang === 'zh' ? '、' : ', ') + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
3469
3535
  h('div', { className: 'n2s-list2-side' },
3470
3536
  statusDot(r.status),
3471
3537
  h('span', { className: 'n2s-faint n2s-small' }, r.scheduledAt && r.status === 'active' ? `${t('rep.next')} ${new Date(r.scheduledAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}` : '')),
@@ -3643,7 +3709,7 @@ window.__ModuleLoader__.load({
3643
3709
 
3644
3710
  // ------------------------------------------- channels: catalog & shapes
3645
3711
  // The host may be older (flat Lark-only channels, no channelCatalog /
3646
- // botStatus RPCs) or newer (docs/CHANNELS.md). Everything below normalizes
3712
+ // botStatus RPCs) or newer (docs/channels.md). Everything below normalizes
3647
3713
  // both so the pages only deal with { platform, type, config, secretsSet,
3648
3714
  // capabilities, bot }.
3649
3715
 
@@ -3993,7 +4059,7 @@ window.__ModuleLoader__.load({
3993
4059
  /** Modal dialog sending a title, text and results to an IM channel. */
3994
4060
  function SendDialog({ t, initial, onClose }) {
3995
4061
  const channels = useChannels()
3996
- const [form, setForm] = useState(() => ({ channelId: '', title: initial.title ?? '', text: initial.text ?? '', includeSql: false, chartMode: '' }))
4062
+ const [form, setForm] = useState(() => ({ channelId: '', title: initial.title ?? '', text: initial.text ?? '', includeSql: false, chartMode: '', attachFull: true }))
3997
4063
  const [resultIds, setResultIds] = useState(initial.resultIds ?? [])
3998
4064
  const [loading, setLoading] = useState(Boolean(initial.message))
3999
4065
  useEffect(() => {
@@ -4021,9 +4087,9 @@ window.__ModuleLoader__.load({
4021
4087
  setState({ busy: true })
4022
4088
  try {
4023
4089
  const res = initial.dashboard
4024
- ? await call('sendDashboard', { channelId: form.channelId, title: form.title, ...(form.chartMode ? { chartMode: form.chartMode } : {}) })
4025
- : await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
4026
- setState({ ok: fmt(t, 'im.sent', { channel: res.channel, images: res.images }) })
4090
+ ? await call('sendDashboard', { channelId: form.channelId, title: form.title, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}) })
4091
+ : await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
4092
+ setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
4027
4093
  refreshChannels()
4028
4094
  setTimeout(onClose, 1200)
4029
4095
  } catch (e) {
@@ -4047,9 +4113,14 @@ window.__ModuleLoader__.load({
4047
4113
  h('span', { className: 'n2s-small' }, t('im.chartMode')),
4048
4114
  channel?.capabilities?.charts === 'none' && h('span', { className: 'n2s-faint n2s-small' }, t('ch.tablesOnly')),
4049
4115
  h('div', { className: 'n2s-seg', role: 'group', 'aria-label': t('im.chartMode') }, ['', ...chartModesFor(channel?.capabilities, channel?.type)].map(v => h('button', { key: v || 'def', type: 'button', 'aria-pressed': form.chartMode === v, onClick: () => setForm(f => ({ ...f, chartMode: v })) }, t(v ? `im.cm.${v}` : 'im.cm.default'))))),
4050
- !initial.dashboard && resultIds.length > 0 && h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.includeSql, onChange: e => setForm(f => ({ ...f, includeSql: e.target.checked })) }), t('im.includeSql'))),
4116
+ !initial.dashboard && resultIds.length > 0 && h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.includeSql, onChange: e => setForm(f => ({ ...f, includeSql: e.target.checked })) }), t('im.includeSql')),
4117
+ (resultIds.length > 0 || initial.dashboard) && h('div', { className: 'n2s-field' },
4118
+ channel?.capabilities?.files
4119
+ ? h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.attachFull, onChange: e => setForm(f => ({ ...f, attachFull: e.target.checked })) }), fmt(t, 'im.attachFull', { n: channel.capabilities.tableRows ?? 10 }))
4120
+ : h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'im.rowsLimit', { n: channel?.capabilities?.tableRows ?? 10 })))),
4051
4121
  state?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, state.error),
4052
4122
  state?.ok && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, state.ok),
4123
+ state?.warn && h('div', { className: 'n2s-notice warn', style: { marginTop: 8 } }, state.warn),
4053
4124
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
4054
4125
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
4055
4126
  h('button', { type: 'button', className: 'n2s-btn primary', disabled: !form.channelId || !form.title.trim() || state?.busy || loading, onClick: send }, state?.busy ? h('span', { className: 'n2s-spin' }) : null, t('im.send'))))), document.body)
@@ -4411,7 +4482,7 @@ window.__ModuleLoader__.load({
4411
4482
  h('td', { title: d.title }, d.title),
4412
4483
  h('td', { className: 'n2s-muted' }, tOpt(t, `im.src.${d.source}`) || d.source),
4413
4484
  h('td', { title: d.error ?? '' }, d.status === 'ok'
4414
- ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : ''].filter(Boolean).join(' · '))
4485
+ ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : '', d.files ? t('im.sentFile') : '', d.error ? d.error : ''].filter(Boolean).join(' · '))
4415
4486
  : h('span', { className: 'n2s-statusdot err' }, d.error ?? t('im.lastFailed')))))))))
4416
4487
  }
4417
4488
 
@@ -4465,7 +4536,7 @@ window.__ModuleLoader__.load({
4465
4536
  const HIDDEN_PIN = { hidden: true }
4466
4537
  const WIDGET_WIDTHS = ['third', 'half', 'full']
4467
4538
 
4468
- function DashboardWidget({ t, widget, onRemove, onChanged, onMove, tick, first, last, filters, filterCount, otherBoards = [], onTransfer, onLoaded, drag }) {
4539
+ function DashboardWidget({ t, widget, onRemove, onChanged, onMove, tick, first, last, filters, filterCount, otherBoards = [], onTransfer, onLoaded, drag, source, showSource }) {
4469
4540
  const [state, setState] = useState({ busy: true })
4470
4541
  const [local, setLocal] = useState(0)
4471
4542
  useEffect(() => {
@@ -4491,6 +4562,7 @@ window.__ModuleLoader__.load({
4491
4562
  onKeyDown: (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); if (!first) onMove(-1) } if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); if (!last) onMove(1) } },
4492
4563
  }, svg(14, [h('circle', { key: 1, cx: 6, cy: 4, r: .9, fill: 'currentColor' }), h('circle', { key: 2, cx: 10, cy: 4, r: .9, fill: 'currentColor' }), h('circle', { key: 3, cx: 6, cy: 8, r: .9, fill: 'currentColor' }), h('circle', { key: 4, cx: 10, cy: 8, r: .9, fill: 'currentColor' }), h('circle', { key: 5, cx: 6, cy: 12, r: .9, fill: 'currentColor' }), h('circle', { key: 6, cx: 10, cy: 12, r: .9, fill: 'currentColor' })])),
4493
4564
  h('span', { className: 'title', title: widget.title }, widget.title),
4565
+ showSource && h('span', { className: `n2s-badge n2s-srcbadge${source ? '' : ' warn'}`, title: source ? `${t('dash.sourceOf')}: ${source.name} · ${typeLabel(t, source.type)}` : t('dash.sourceMissing') }, Icon.db(11), source ? source.name : t('dash.sourceMissingShort')),
4494
4566
  state.busy ? h('span', { className: 'n2s-spin n2s-faint', 'aria-label': t('dash.loading') }) : ago && h('span', { className: 'n2s-faint n2s-small n2s-hide-narrow', title: state.result?.cached ? fmt(t, 'dash.cachedAt', { time: new Date(state.result.cachedAt).toLocaleTimeString() }) : t('dash.updatedAt') }, state.result?.cached ? `${ago} · ${t('dash.cached')}` : ago),
4495
4567
  state.result?.filters?.length > 0 && h('span', { className: 'n2s-badge blue', title: state.result.filters.map(f => f.description).join('\n') }, fmt(t, 'filter.appliedCount', { count: state.result.filters.length })),
4496
4568
  state.result && filterCount > 0 && !(state.result.filters?.length) && h('span', { className: 'n2s-badge', title: t('filter.notApplied') }, t('filter.notAppliedShort')),
@@ -4511,7 +4583,7 @@ window.__ModuleLoader__.load({
4511
4583
  state.result && h(ResultView, { result: widgetResult, t, compact: true, embedded: true, onPinned: HIDDEN_PIN }))
4512
4584
  }
4513
4585
 
4514
- function DashboardTab({ t }) {
4586
+ function DashboardTab({ t, ds, datasources = [] }) {
4515
4587
  const [boardId, setBoardId] = useState(null)
4516
4588
  const list = useAsync(() => call('listDashboard'), [boardId])
4517
4589
  const boards = useAsync(() => call('listDashboards'), [boardId])
@@ -4526,7 +4598,17 @@ window.__ModuleLoader__.load({
4526
4598
  const [order, setOrder] = useState(null)
4527
4599
  useEffect(() => { setOrder(null) }, [list.value])
4528
4600
  const serverWidgets = list.value ?? []
4529
- const widgets = order ? order.map(id => serverWidgets.find(w => w.id === id)).filter(Boolean) : serverWidgets
4601
+ const allWidgets = order ? order.map(id => serverWidgets.find(w => w.id === id)).filter(Boolean) : serverWidgets
4602
+ // Widgets keep the data source they were pinned from; a board may mix sources.
4603
+ // Scope: all sources, or only the one selected at the top right (remembered per browser).
4604
+ const [scope, setScopeState] = useState(() => { try { return localStorage.getItem('nl2sql:dashScope') === 'current' ? 'current' : 'all' } catch { return 'all' } })
4605
+ const setScope = (v) => { setScopeState(v); try { localStorage.setItem('nl2sql:dashScope', v) } catch {} }
4606
+ const sourceIds = [...new Set(allWidgets.map(w => w.datasourceId))]
4607
+ const mixed = sourceIds.length > 1
4608
+ const dsById = new Map(datasources.map(d => [d.id, d]))
4609
+ const widgets = scope === 'current' && ds ? allWidgets.filter(w => w.datasourceId === ds.id) : allWidgets
4610
+ const hiddenByScope = allWidgets.length - widgets.length
4611
+ const showSource = mixed || (ds && sourceIds.some(id => id !== ds.id))
4530
4612
  const saved = useAsync(() => call('getDashboardFilters'), [boardId])
4531
4613
  const [filters, setFilters] = useState(null)
4532
4614
  const [filtersFor, setFiltersFor] = useState(null)
@@ -4548,7 +4630,11 @@ window.__ModuleLoader__.load({
4548
4630
  // Drag and drop.
4549
4631
  const [dragId, setDragId] = useState(null)
4550
4632
  const [over, setOver] = useState(null) // { id, pos: 'before'|'after' }
4551
- const commitOrder = async (ids) => {
4633
+ const commitOrder = async (visibleIds) => {
4634
+ // Hidden (other-source) widgets keep their slots; visible ones take the new order.
4635
+ const queue = [...visibleIds]
4636
+ const visible = new Set(visibleIds)
4637
+ const ids = allWidgets.map(w => (visible.has(w.id) ? queue.shift() : w.id))
4552
4638
  setOrder(ids)
4553
4639
  try { await call('reorderWidgets', { dashboardId: boards.value?.current, ids }) } catch (e) { notifyError(e); setOrder(null) }
4554
4640
  list.reload()
@@ -4587,9 +4673,13 @@ window.__ModuleLoader__.load({
4587
4673
  }
4588
4674
  return h('div', null,
4589
4675
  h(DashboardSwitcher, { t, value: { id: boardId }, onChange: (id) => setBoardId(`${id}:${Date.now()}`) }),
4590
- widgets.length > 0 && h('div', { className: 'n2s-toolbar n2s-dashbar' },
4676
+ allWidgets.length > 0 && h('div', { className: 'n2s-toolbar n2s-dashbar' },
4591
4677
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
4592
- h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4678
+ h('div', { className: 'n2s-row', style: { gap: 10 } },
4679
+ h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4680
+ (mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
4681
+ ['all', 'current'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': scope === v, disabled: v === 'current' && !ds, onClick: () => setScope(v) }, v === 'all' ? t('dash.scopeAll') : fmt(t, 'dash.scopeCurrent', { name: ds?.name ?? '' }))))),
4682
+ showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort')).join(uiLang === 'zh' ? '、' : ', ') })),
4593
4683
  h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
4594
4684
  loadingN > 0 ? fmt(t, 'dash.loadingN', { n: loadingN }) : lastAt ? fmt(t, 'dash.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : '',
4595
4685
  cachedN > 0 && loadingN === 0 ? h('span', null, ' · ', h('button', { type: 'button', className: 'n2s-link', title: t('dash.cachedHint'), onClick: () => setTick(x => x + 1) }, fmt(t, 'dash.cachedN', { n: cachedN }))) : null,
@@ -4606,11 +4696,12 @@ window.__ModuleLoader__.load({
4606
4696
  widgets.length > 0 && filtersReady && h(FilterBar, { key: `f:${boardId ?? 'b'}`, t, filters, onChange: changeFilters }),
4607
4697
  sendingDash && h(SendDialog, { t, initial: { title: t('im.dashboardTitle'), dashboard: true }, onClose: () => setSendingDash(false) }),
4608
4698
  list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
4609
- !list.loading && widgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
4699
+ !list.loading && widgets.length === 0 && hiddenByScope > 0 && h('div', { className: 'n2s-empty' }, h('h3', null, fmt(t, 'dash.scopeEmpty', { name: ds?.name ?? '' })), h('div', null, fmt(t, 'dash.scopeEmptyHint', { n: hiddenByScope })), h('button', { type: 'button', className: 'n2s-btn', onClick: () => setScope('all') }, t('dash.scopeAll'))),
4700
+ !list.loading && allWidgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
4610
4701
  widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
4611
4702
  filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
4612
4703
  key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).length,
4613
- onChanged: () => list.reload(), onLoaded, drag: dragProps(w),
4704
+ onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
4614
4705
  onMove: (delta) => move(w, delta),
4615
4706
  onRemove: async () => { if (!await confirmDialog({ title: fmt(t, 'dash.removeConfirm', { title: w.title }), danger: true })) return; try { await call('unpinWidget', { id: w.id }); notify(t('dash.removed'), 'ok') } catch (e) { notifyError(e) } list.reload() },
4616
4707
  otherBoards, onTransfer: async (to, copy) => { try { await call('transferWidget', { id: w.id, toDashboardId: to, copy }); notify(copy ? t('dash.copied') : t('dash.moved'), 'ok') } catch (e) { notifyError(e) } list.reload(); boards.reload() },
@@ -4664,7 +4755,7 @@ window.__ModuleLoader__.load({
4664
4755
  h('span', { className: 'n2s-wf-bar' }, h('span', { className: `n2s-wf-fill ${it.delta > 0 ? 'up' : it.delta < 0 ? 'down' : ''}`, style: { width: `${Math.max(2, Math.abs(it.delta) / max * 100)}%` } })),
4665
4756
  h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
4666
4757
  h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
4667
- why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${t('drill.failedDims')}:${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join('、')}`))
4758
+ why.dimensions.filter(d => !d.attribution).length > 0 && h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('drill.failedDims'))}${why.dimensions.filter(d => !d.attribution).map(d => d.dimension).join(uiLang === 'zh' ? '、' : ', ')}`))
4668
4759
  }
4669
4760
 
4670
4761
  // ------------------------------------------------------------ alerts
@@ -4820,7 +4911,7 @@ window.__ModuleLoader__.load({
4820
4911
  h('div', { className: 'n2s-row' }, h('strong', null, fmt(t, 'sc.title', { count: c.changes.length, time: new Date(c.at).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) })), h('span', { className: 'n2s-grow' }),
4821
4912
  h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id }); list.reload() } }, t('sc.ack'))),
4822
4913
  h('div', { className: 'n2s-small', style: { marginTop: 4 } }, c.changes.slice(0, 8).map(x => `${kindLabel(x.kind)} ${x.table}${x.column ? `.${x.column}` : ''}${x.detail ? `(${x.detail})` : ''}`).join(';')),
4823
- c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${t('sc.impacts')}:`), c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}「${i.name}」`).join('、')))))
4914
+ c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}「${i.name}」`).join(uiLang === 'zh' ? '、' : ', ')))))
4824
4915
  }
4825
4916
 
4826
4917
  // -------------------------------------------------- knowledge io
@@ -4889,7 +4980,7 @@ window.__ModuleLoader__.load({
4889
4980
  if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
4890
4981
  if (s.kind === 'metric') return h('div', null,
4891
4982
  h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
4892
- (it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${it.dimensions.join('、')}`].filter(Boolean).join(' · ')))
4983
+ (it.timeColumn || it.dimensions?.length) && h('div', { className: 'n2s-muted n2s-small' }, [it.timeColumn && `${t('kb.timeColumn')}: ${it.timeColumn}`, it.dimensions?.length && `${t('kb.dimensions')}: ${it.dimensions.join(uiLang === 'zh' ? '、' : ', ')}`].filter(Boolean).join(' · ')))
4893
4984
  if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
4894
4985
  return h('div', null, h('div', { className: 'n2s-small' }, it.definition), it.sql && h('code', { className: 'n2s-md-code', style: { display: 'inline-block', marginTop: 4 } }, it.sql))
4895
4986
  }
@@ -4995,6 +5086,69 @@ window.__ModuleLoader__.load({
4995
5086
  h('button', { type: 'button', className: 'n2s-btn small ghost', style: { justifySelf: 'start' }, onClick: () => onChange([...rows, { platform: rows[rows.length - 1]?.platform ?? platforms[0], id: '', email: '' }]) }, '+ ', t('mb.ids.add')))
4996
5087
  }
4997
5088
 
5089
+ /**
5090
+ * One-click identity picks: people and groups that already talked to a bot
5091
+ * (recorded by the host, including refused ones), plus the groups a bot is
5092
+ * in (channels whose kind can list targets). No id lookup needed.
5093
+ */
5094
+ function IdentityPicker({ t, kind, catalog, onPick, chosen }) {
5095
+ const seen = useAsync(() => call('listSeenIdentities', { kind }).catch(() => []), [kind])
5096
+ const channels = useChannels()
5097
+ const listable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.listTargets)
5098
+ const [groups, setGroups] = useState(null)
5099
+ const [loadingGroups, setLoadingGroups] = useState(false)
5100
+ const loadGroups = async () => {
5101
+ setLoadingGroups(true)
5102
+ const out = []
5103
+ for (const c of listable) {
5104
+ try { for (const g of await call('listChannelTargets', { id: c.id })) out.push({ platform: c.platform, kind: 'chat', id: String(g.id), name: g.name, via: c.name }) } catch (e) { notifyError(e) }
5105
+ }
5106
+ setGroups(out); setLoadingGroups(false)
5107
+ }
5108
+ useEffect(() => { if (kind === 'chat' && listable.length && groups === null) loadGroups() }, [kind, listable.length])
5109
+ const isChosen = (x) => chosen.some(r => r.platform === x.platform && r.id === x.id)
5110
+ const items = [...(seen.value ?? [])]
5111
+ if (kind === 'chat') for (const g of groups ?? []) if (!items.some(x => x.platform === g.platform && x.id === g.id)) items.push(g)
5112
+ const label = (x) => x.name || x.chatName || x.email || x.id
5113
+ return h('div', { className: 'n2s-field full n2s-idpicker' },
5114
+ h('div', { className: 'n2s-row' },
5115
+ h('span', { className: 'n2s-flabel' }, kind === 'chat' ? t('mb.pick.chats') : t('mb.pick.people')),
5116
+ h('span', { className: 'n2s-grow' }),
5117
+ kind === 'chat' && listable.length > 0 && h('button', { type: 'button', className: 'n2s-link n2s-small', disabled: loadingGroups, onClick: loadGroups }, loadingGroups ? h('span', { className: 'n2s-spin' }) : Icon.refresh(12), t('mb.pick.refresh'))),
5118
+ seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
5119
+ items.length === 0
5120
+ ? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
5121
+ : h('div', { className: 'n2s-pickchips', role: 'list' }, items.slice(0, 30).map(x => {
5122
+ const taken = x.memberId && !isChosen(x)
5123
+ return h('button', {
5124
+ key: `${x.platform}:${x.id}`, type: 'button', role: 'listitem', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
5125
+ title: [`${platformName(t, x.platform, catalog)} · ${x.id}`, x.email, x.via ? fmt(t, 'mb.pick.via', { name: x.via }) : '', taken ? fmt(t, 'mb.pick.taken', { name: x.memberName }) : '', x.refused ? t('mb.pick.refused') : ''].filter(Boolean).join('\n'),
5126
+ onClick: () => onPick(x),
5127
+ },
5128
+ h(PlatformMark, { t, platform: x.platform, size: 16 }),
5129
+ h('span', { className: 'n2s-pickchip-name' }, label(x)),
5130
+ x.refused && !x.memberId && h('span', { className: 'n2s-badge warn' }, t('mb.pick.pending')),
5131
+ taken && h('span', { className: 'n2s-faint' }, t('mb.pick.added')),
5132
+ x.lastAt && h('span', { className: 'n2s-faint' }, shortTime(x.lastAt)))
5133
+ })),
5134
+ h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.chatsHint') : t('mb.pick.peopleHint')))
5135
+ }
5136
+
5137
+ /** People / groups refused by a bot because they are not members yet: add with one click. */
5138
+ function PendingMembers({ t, onAdd }) {
5139
+ const seen = useAsync(() => call('listSeenIdentities', {}).catch(() => []), [])
5140
+ const { catalog } = useCatalog(t)
5141
+ const pending = (seen.value ?? []).filter(x => x.refused && !x.memberId).slice(0, 6)
5142
+ if (!pending.length) return null
5143
+ return h('div', { className: 'n2s-notice warn n2s-pending', role: 'status', style: { marginBottom: 12 } },
5144
+ h('div', { style: { marginBottom: 6 } }, fmt(t, 'mb.pending.title', { n: pending.length })),
5145
+ h('div', { className: 'n2s-pickchips' }, pending.map(x => h('button', { key: `${x.platform}:${x.kind}:${x.id}`, type: 'button', className: 'n2s-pickchip', onClick: () => onAdd(x), title: `${platformName(t, x.platform, catalog)} · ${x.id}` },
5146
+ h(PlatformMark, { t, platform: x.platform, size: 16 }),
5147
+ h('span', { className: 'n2s-pickchip-name' }, x.name || x.chatName || x.id),
5148
+ h('span', { className: 'n2s-faint' }, x.kind === 'chat' ? t('mb.kind2.chat') : t('mb.kind2.person')),
5149
+ h('span', { className: 'n2s-link' }, t('mb.pending.add'))))))
5150
+ }
5151
+
4998
5152
  function IdentityChips({ t, u, catalog }) {
4999
5153
  const ids = identitiesOf(u)
5000
5154
  if (!ids.length) return h('span', { className: 'n2s-badge warn' }, t('mb.ids.none'))
@@ -5002,11 +5156,11 @@ window.__ModuleLoader__.load({
5002
5156
  h(PlatformMark, { t, platform: x.platform, size: 16 }), h('span', { className: 'n2s-mono' }, x.id || x.email))))
5003
5157
  }
5004
5158
 
5005
- function UserForm({ t, initial, datasources, onDone, onCancel }) {
5159
+ function UserForm({ t, initial, prefill, datasources, onDone, onCancel }) {
5006
5160
  const { catalog } = useCatalog(t)
5007
5161
  const [form, setForm] = useState(() => {
5008
- const base = { kind: 'person', name: '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
5009
- const ids = identitiesOf(initial)
5162
+ const base = { kind: prefill?.kind ?? 'person', name: prefill ? (prefill.name || prefill.chatName || '') : '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
5163
+ const ids = prefill ? [{ platform: prefill.platform, id: prefill.id, ...(prefill.kind === 'person' && prefill.email ? { email: prefill.email } : {}) }] : identitiesOf(initial)
5010
5164
  return { ...base, identities: ids.length ? ids : [{ platform: 'lark', id: '', email: '' }] }
5011
5165
  })
5012
5166
  const [error, setError] = useState(null)
@@ -5038,6 +5192,11 @@ window.__ModuleLoader__.load({
5038
5192
  h('div', { className: 'n2s-field full' }, h('span', { id: 'n2s-mb-kind' }, t('mb.kind')), h('div', { className: 'n2s-seg', role: 'group', 'aria-labelledby': 'n2s-mb-kind', style: { justifySelf: 'start' } }, ['person', 'chat'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.kind === v, disabled: Boolean(initial?.id), onClick: () => setForm(f => ({ ...f, kind: v })) }, t(`mb.kind2.${v}`))))),
5039
5193
  h('label', { className: 'n2s-field' }, h('span', null, t('im.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name') })),
5040
5194
  h('label', { className: 'n2s-field' }, h('span', null, t('mb.role')), h('select', { className: 'n2s-select', value: form.role, onChange: set('role') }, ['admin', 'analyst', 'viewer'].map(r => h('option', { key: r, value: r }, t(`mb.role.${r}`)))), h('span', { className: 'n2s-faint n2s-small' }, t(`mb.roleHint.${form.role}`))),
5195
+ !initial?.id && h(IdentityPicker, { t, kind: form.kind, catalog, chosen: form.identities, onPick: (x) => setForm(f => {
5196
+ const exists = f.identities.some(r => r.platform === x.platform && r.id === x.id)
5197
+ const rows = exists ? f.identities.filter(r => !(r.platform === x.platform && r.id === x.id)) : [...f.identities.filter(r => r.id || r.email), { platform: x.platform, id: x.id, ...(f.kind === 'person' && x.email ? { email: x.email } : {}) }]
5198
+ return { ...f, identities: rows.length ? rows : [{ platform: x.platform, id: '', email: '' }], name: f.name || (exists ? f.name : (x.name || x.chatName || '')) }
5199
+ }) }),
5041
5200
  h(IdentityEditor, { t, kind: form.kind, rows: form.identities, catalog, errors: idErrors, onChange: (rows) => { setForm(f => ({ ...f, identities: rows })); setIdErrors({}) } }),
5042
5201
  h('div', { className: 'n2s-field full' }, h('span', null, t('mb.scope')),
5043
5202
  h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.allDatasources, onChange: set('allDatasources') }), t('mb.allDs')),
@@ -5128,7 +5287,8 @@ window.__ModuleLoader__.load({
5128
5287
  return h('div', null,
5129
5288
  h(SectionHead, { title: t('tab.members'), desc: t('mb.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('mb.add'))),
5130
5289
  h(BotsOverview, { t }),
5131
- editing && h(UserForm, { key: editing.id ?? 'new', t, datasources, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); users.reload() } }),
5290
+ !editing && h(PendingMembers, { t, onAdd: (x) => setEditing({ prefill: x }) }),
5291
+ editing && h(UserForm, { key: editing.id ?? (editing.prefill ? `p:${editing.prefill.id}` : 'new'), t, datasources, prefill: editing.prefill, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); users.reload() } }),
5132
5292
  settings.value && h('div', { className: 'n2s-setnote', style: { marginBottom: 10 } },
5133
5293
  t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.botDefaultRole ?? settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
5134
5294
  h('button', { type: 'button', className: 'n2s-link', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'settings' }) }, t('set.changeInSettings'))),
@@ -5140,7 +5300,7 @@ window.__ModuleLoader__.load({
5140
5300
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5141
5301
  h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, u.name), h('span', { className: 'n2s-badge' }, t(`mb.role.${u.role}`)), u.disabled && h('span', { className: 'n2s-badge err' }, t('mb.disabled'))),
5142
5302
  h('div', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } }, h(IdentityChips, { t, u, catalog }), h('span', { className: 'n2s-muted n2s-small' }, u.datasourceIds === null ? t('mb.allDs') : (u.datasourceNames ?? []).join(uiLang === 'zh' ? '、' : ', ') || t('mb.noDs'))),
5143
- (u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${t('mb.rowRules')}:${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}`)),
5303
+ (u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}`)),
5144
5304
  h('div', { className: 'n2s-list2-side' }, h('span', { className: 'n2s-small' }, us ? fmt(t, 'mb.queries', { n: us.queries }) : t('mb.noUsage')), us?.lastAt && h('span', { className: 'n2s-faint n2s-small' }, new Date(us.lastAt).toLocaleDateString())),
5145
5305
  h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
5146
5306
  { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
@@ -5441,7 +5601,7 @@ window.__ModuleLoader__.load({
5441
5601
  nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment ? `(${tb.comment})` : '' })) }) :
5442
5602
  nav.tab === 'knowledge' ? h(KnowledgeTab, { t, ds }) :
5443
5603
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
5444
- nav.tab === 'dashboard' ? h(DashboardTab, { t }) :
5604
+ nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
5445
5605
  h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: setNav, ds, datasources }))),
5446
5606
  h(FeedbackLayer, { t })))
5447
5607
  }
@@ -5708,7 +5868,7 @@ window.__ModuleLoader__.load({
5708
5868
  h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('set.section'), t('set.toAdd'), t('set.toUpdate'), t('set.note')].map((x, i) => h('th', { key: i }, x)))),
5709
5869
  h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
5710
5870
  h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
5711
- h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, [r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: r.needsSecret.join('、') }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''].filter(Boolean).join(';')))))),
5871
+ h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, [r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: r.needsSecret.join(uiLang === 'zh' ? '、' : ', ') }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''].filter(Boolean).join(';')))))),
5712
5872
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
5713
5873
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
5714
5874
  h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
@@ -5724,7 +5884,7 @@ window.__ModuleLoader__.load({
5724
5884
  row('dataDir', v?.dataDir, true),
5725
5885
  row('runtime', v ? `Node ${v.nodeVersion} · SQLite ${v.sqliteVersion}` : null),
5726
5886
  row('tests', v?.testsCommand, true),
5727
- row('docs', 'DEVELOPMENT.md'))))
5887
+ row('docs', 'github.com/hedeqiang/dsh-nl2sql'))))
5728
5888
  }
5729
5889
 
5730
5890
  // ------------------------------------------------------------ apply
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.11.1",
4
- "description": "Data Q&A (NL2SQL) for DeepSeek Harness: ask MySQL / PostgreSQL / Elasticsearch / CSV in plain language, charts, dashboards, alerts, IM push and bots (Lark/Feishu, Telegram, WeCom, DingTalk), access control.",
3
+ "version": "0.12.0",
4
+ "description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
@@ -29,10 +29,10 @@
29
29
  "locale/*.json",
30
30
  "icon.svg",
31
31
  "README.md",
32
- "README.en.md",
33
32
  "LICENSE",
34
33
  "CHANGELOG.md",
35
- "docs/CHANNELS.md"
34
+ "README.zh-CN.md",
35
+ "docs/channels.md"
36
36
  ],
37
37
  "dependencies": {
38
38
  "@larksuiteoapi/node-sdk": "1.76.0",
@@ -93,5 +93,9 @@
93
93
  },
94
94
  "publishConfig": {
95
95
  "access": "public"
96
+ },
97
+ "homepage": "https://github.com/hedeqiang/dsh-nl2sql#readme",
98
+ "bugs": {
99
+ "url": "https://github.com/hedeqiang/dsh-nl2sql/issues"
96
100
  }
97
101
  }
package/service/access.js CHANGED
@@ -99,7 +99,7 @@ export const methods = {
99
99
  /**
100
100
  * Members: people and group chats on IM platforms, each with a role
101
101
  * (admin | analyst | viewer), a data source scope and platform-scoped
102
- * identities [{ platform, id, email? }] (see docs/CHANNELS.md). The local
102
+ * identities [{ platform, id, email? }] (see docs/channels.md). The local
103
103
  * owner is implicit. Bot messages from unknown people fall back to
104
104
  * settings.botDefaultRole (none = refuse).
105
105
  */
@@ -212,6 +212,43 @@ export const methods = {
212
212
  }
213
213
  },
214
214
 
215
+ /**
216
+ * Remember an IM identity that talked to a bot (refused or not), so admins can
217
+ * add it as a member with one click instead of looking up ids. Bounded list,
218
+ * most recent first; keyed by (platform, kind, id).
219
+ */
220
+ noteSeenIdentity({ platform, kind, id, name, email, chatName, channelId, refused }) {
221
+ if (!platform || !id) return
222
+ this.state.seenIdentities ??= []
223
+ const list = this.state.seenIdentities
224
+ const key = `${platform}:${kind}:${id}`
225
+ const i = list.findIndex(x => `${x.platform}:${x.kind}:${x.id}` === key)
226
+ const prev = i >= 0 ? list.splice(i, 1)[0] : {}
227
+ list.unshift({ ...prev, platform, kind, id: String(id), ...(name ? { name: String(name).slice(0, 60) } : {}), ...(email ? { email: String(email).slice(0, 200) } : {}), ...(chatName ? { chatName: String(chatName).slice(0, 80) } : {}), ...(channelId ? { channelId } : {}), lastAt: Date.now(), count: (prev.count ?? 0) + 1, refused: Boolean(refused) })
228
+ if (list.length > 200) list.length = 200
229
+ },
230
+
231
+ /**
232
+ * People and group chats that talked to a bot recently, for the member form:
233
+ * [{ platform, kind:'person'|'chat', id, name?, email?, chatName?, lastAt, count, refused, memberId? }].
234
+ * `memberId` is set when the identity already belongs to a member.
235
+ */
236
+ listSeenIdentities({ kind, platform } = {}) {
237
+ const users = (this.state.users ?? []).map(u => normalizeUserRecord(u))
238
+ // Group chats seen in bot conversations count too (older data has no seen list).
239
+ const seen = [...(this.state.seenIdentities ?? [])]
240
+ for (const c of Object.values(this.state.botConversations ?? {})) {
241
+ if (c.chatType === 'group' && c.chatId && !seen.some(x => x.kind === 'chat' && x.platform === c.platform && x.id === String(c.chatId))) seen.push({ platform: c.platform ?? 'lark', kind: 'chat', id: String(c.chatId), chatName: c.chatName, name: c.chatName, lastAt: c.lastAt ?? c.createdAt, count: 1, refused: false })
242
+ }
243
+ return seen
244
+ .filter(x => (!kind || x.kind === kind) && (!platform || x.platform === platform))
245
+ .map(x => {
246
+ const owner = users.find(u => u.kind === x.kind && u.identities.some(i => i.platform === x.platform && i.id === x.id))
247
+ return { ...x, ...(owner ? { memberId: owner.id, memberName: owner.name } : {}) }
248
+ })
249
+ .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0))
250
+ },
251
+
215
252
  /** Back-compat alias of resolveActor for Lark. */
216
253
  resolveLarkActor({ openId, email, name, chatId, chatType }) {
217
254
  return this.resolveActor({ platform: 'lark', userId: openId, email, name, chatId, chatType })