@hedeqiang/dsh-nl2sql 0.12.0 → 0.12.2

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
@@ -3,6 +3,8 @@ window.__ModuleLoader__.load({
3
3
  factory(require) {
4
4
  const React = require('react')
5
5
  const ReactDOM = require('react-dom')
6
+ // createRoot for the global feedback layer; older loaders only share react-dom.
7
+ const ReactDOMClient = (() => { try { return require('react-dom/client') } catch { return null } })() ?? ReactDOM
6
8
  const h = React.createElement
7
9
  const { useState, useEffect, useMemo, useRef, useCallback, useSyncExternalStore } = React
8
10
 
@@ -1030,6 +1032,18 @@ window.__ModuleLoader__.load({
1030
1032
  'tool.running': '正在查询…',
1031
1033
  'tool.failed': '查询失败',
1032
1034
  'error.generic': '操作失败:{message}',
1035
+ 'dash.copyName': '{name} 副本',
1036
+ 'set.leaveTitle': '放弃未保存的设置?',
1037
+ 'set.leaveMsg': '设置中有未保存的修改,离开后这些修改会丢失。',
1038
+ 'set.leaveOk': '放弃并离开',
1039
+ 'fb.deleteConfirm': '删除这条反馈?',
1040
+ 'hist.source.telegrambot': 'Telegram 机器人',
1041
+ 'hist.source.wecombot': '企业微信机器人',
1042
+ 'hist.source.dingtalkbot': '钉钉机器人',
1043
+ 'hist.source.unknown': '未知来源',
1044
+ 'hist.source.genericBot': '{platform} 机器人',
1045
+ 'filter.valuesPh': '值,用逗号分隔',
1046
+ 'policy.tablesLabel': '可访问的表',
1033
1047
  }
1034
1048
  const en = {
1035
1049
  'mb.pick.people': "Pick someone who talked to a bot",
@@ -1426,7 +1440,7 @@ window.__ModuleLoader__.load({
1426
1440
  'im.src.ui': 'Page', 'im.src.agent': 'Agent', 'im.src.dashboard': 'Dashboard', 'im.src.test': 'Test', 'im.resultsN': '{count} results', 'im.imagesN': '{count} images',
1427
1441
  'im.share': 'Send to IM', 'im.shareAll': 'Send all to IM', 'im.shareDashboard': 'Send dashboard', 'im.dashboardTitle': 'Dashboard', 'im.sendTitle': "Send to IM channel",
1428
1442
  'im.noChannel': "No channels yet; add one under Admin → Channels.", 'im.channel': 'Send to', 'im.msgTitle': 'Title', 'im.text': 'Message (Markdown, optional)', 'im.textPh': 'e.g. GMV +12% day over day, mostly from the east region',
1429
- 'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.', 'im.webhookNoImage': 'Group bots cannot send images; charts go as tables.',
1443
+ 'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.',
1430
1444
  'im.includeSql': 'Include SQL', 'im.send': 'Send', 'im.sent': 'Sent to {channel} ({images} images)', 'rep.push': 'Push to', 'rep.pushNone': 'Do not push',
1431
1445
  'tab.reports': 'Reports', 'ds.type': 'Database type', 'ds.indexPattern': 'Indices (comma separated, wildcards allowed)', 'ds.userOptional': 'User (optional)',
1432
1446
  'ds.apiKey': 'API key (optional, preferred over user/password)', 'ds.apiKeyHint': 'Base64 encoded id:api_key', 'ds.schemas': 'Schemas (comma separated; empty = all user schemas)', 'ds.https': 'Use HTTPS',
@@ -1496,6 +1510,12 @@ window.__ModuleLoader__.load({
1496
1510
  'settings.limits': 'Query limits', 'settings.maxRows': 'Max rows per query', 'settings.timeoutMs': 'Query timeout (ms)', 'settings.save': 'Save settings', 'settings.saved': 'Saved',
1497
1511
  'composer.none': 'No data source', 'composer.title': 'Data source queried by this session',
1498
1512
  'tool.query': 'Data query', 'tool.chart': 'Chart', 'tool.running': 'Querying…', 'tool.failed': 'Query failed', 'error.generic': 'Failed: {message}',
1513
+ 'dash.copyName': '{name} copy',
1514
+ 'set.leaveTitle': 'Discard unsaved settings?', 'set.leaveMsg': 'You have unsaved setting changes; they will be lost if you leave.', 'set.leaveOk': 'Discard and leave',
1515
+ 'fb.deleteConfirm': 'Delete this feedback?',
1516
+ 'hist.source.telegrambot': 'Telegram bot', 'hist.source.wecombot': 'WeCom bot', 'hist.source.dingtalkbot': 'DingTalk bot', 'hist.source.unknown': 'Unknown source', 'hist.source.genericBot': '{platform} bot',
1517
+ 'filter.valuesPh': 'Values, comma-separated',
1518
+ 'policy.tablesLabel': 'Accessible tables',
1499
1519
  }
1500
1520
 
1501
1521
  // --------------------------------------------------------------- RPC
@@ -1732,8 +1752,10 @@ window.__ModuleLoader__.load({
1732
1752
  .n2s-hist{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
1733
1753
  .n2s-hist-row{border-bottom:.5px solid var(--n2s-line)}
1734
1754
  .n2s-hist-row:last-child{border-bottom:0}
1735
- .n2s-hist-main{display:grid;grid-template-columns:76px minmax(0,1fr) 150px 84px 20px;align-items:center;gap:14px;width:100%;padding:11px 16px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
1736
- .n2s-hist-main:hover{background:var(--n2s-hover)}
1755
+ .n2s-hist-head{display:flex;align-items:center}
1756
+ .n2s-hist-head:hover{background:var(--n2s-hover)}
1757
+ .n2s-hist-head .n2s-rowdel{flex:none;margin-right:10px}
1758
+ .n2s-hist-main{display:grid;grid-template-columns:76px minmax(0,1fr) 150px 84px 20px;align-items:center;gap:14px;flex:1;min-width:0;width:100%;padding:11px 6px 11px 16px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
1737
1759
  .n2s-hist-main:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:-2px}
1738
1760
  .n2s-hist-what{display:grid;gap:2px;min-width:0}
1739
1761
  .n2s-hist-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1965,8 +1987,6 @@ window.__ModuleLoader__.load({
1965
1987
  .n2s-recent-row:hover .n2s-recent-del,.n2s-recent-del:focus-visible,.n2s-hist-row:hover .n2s-rowdel,.n2s-rowdel:focus-visible{opacity:1}
1966
1988
  .n2s-recent-del:hover,.n2s-rowdel:hover{color:var(--n2s-err)}
1967
1989
  @media (hover:none){.n2s-recent-del,.n2s-rowdel{opacity:1}}
1968
- .n2s-hist-row.has-del{position:relative}
1969
- .n2s-rowdel{position:absolute;top:10px;right:40px;z-index:1}
1970
1990
  .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}
1971
1991
  .n2s-morebtn:hover{background:var(--n2s-hover);color:var(--dsw-alias-label-primary)}
1972
1992
  .n2s-btn.danger-text{color:var(--n2s-err)}
@@ -2036,7 +2056,8 @@ window.__ModuleLoader__.load({
2036
2056
  .n2s-ptile[aria-checked=true]{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 6%,transparent);box-shadow:0 0 0 1px var(--n2s-accent)}
2037
2057
  .n2s-ptile:focus-visible,.n2s-kindcard:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:2px}
2038
2058
  .n2s-ptile-name{font-size:13.5px;font-weight:600}
2039
- .n2s-kindcards{display:grid;gap:8px;margin-top:8px}
2059
+ .n2s-kindcards{display:grid;gap:8px;margin:8px 0 0;padding:0;list-style:none}
2060
+ .n2s-kindcard-li{display:grid}
2040
2061
  .n2s-kindcard{display:grid;gap:4px;padding:12px 14px;border-radius:var(--n2s-radius);border:.5px solid var(--n2s-line-2);background:var(--dsw-alias-bg-base);color:inherit;font:inherit;text-align:left;cursor:pointer;transition:border-color .12s,background .12s}
2041
2062
  .n2s-kindcard:hover{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 4%,transparent)}
2042
2063
  .n2s-kindcard-title{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
@@ -2162,6 +2183,16 @@ window.__ModuleLoader__.load({
2162
2183
  const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
2163
2184
  /** Label + colon in the UI language: 「标签:」 / "Label: ". */
2164
2185
  const colon = (label) => (uiLang === 'zh' ? `${label}:` : `${label}: `)
2186
+ /** List separator in the UI language. */
2187
+ const listSep = () => (uiLang === 'zh' ? '、' : ', ')
2188
+ /** Clauses joined in the UI language: 「a;b」 / "a; b". */
2189
+ const joinClauses = (items) => joinList(items, uiLang === 'zh' ? ';' : '; ')
2190
+ /** Parenthetical in the UI language: 「(x)」 / " (x)". */
2191
+ const paren = (s) => (s === undefined || s === null || s === '' ? '' : uiLang === 'zh' ? `(${s})` : ` (${s})`)
2192
+ /** Quoted name in the UI language: 「x」 / "x". */
2193
+ const quoted = (s) => (uiLang === 'zh' ? `「${s}」` : `"${s}"`)
2194
+ /** BCP 47 tag for Intl formatting in the UI language. */
2195
+ const uiLocale = () => (uiLang === 'zh' ? 'zh-CN' : 'en-US')
2165
2196
  const setUiLang = (snapshot) => {
2166
2197
  const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
2167
2198
  uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
@@ -2176,18 +2207,26 @@ window.__ModuleLoader__.load({
2176
2207
  }
2177
2208
 
2178
2209
  function formatCompact(v) {
2210
+ if (v === null || v === undefined || v === '') return '—'
2179
2211
  const n = Number(v)
2180
- if (!Number.isFinite(n)) return String(v ?? '—')
2212
+ if (!Number.isFinite(n)) return String(v)
2181
2213
  const abs = Math.abs(n)
2182
- if (uiLang !== 'zh') {
2183
- if (abs >= 1e9) return `${(n / 1e9).toFixed(abs >= 1e11 ? 0 : 2)}B`
2184
- if (abs >= 1e6) return `${(n / 1e6).toFixed(abs >= 1e8 ? 0 : 1)}M`
2185
- if (abs >= 1e5) return `${(n / 1e3).toFixed(0)}K`
2186
- return n.toLocaleString(undefined, { maximumFractionDigits: 2 })
2214
+ // [divisor, suffix, threshold, digits(abs)] from the largest unit down.
2215
+ const scales = uiLang === 'zh'
2216
+ ? [[1e8, '亿', 1e8, a => (a >= 1e10 ? 0 : 2)], [1e4, '万', 1e5, a => (a >= 1e7 ? 0 : 1)]]
2217
+ : [[1e9, 'B', 1e9, a => (a >= 1e11 ? 0 : 2)], [1e6, 'M', 1e6, a => (a >= 1e8 ? 0 : 1)], [1e3, 'K', 1e5, () => 0]]
2218
+ for (let i = 0; i < scales.length; i++) {
2219
+ const [div, suffix, threshold, digits] = scales[i]
2220
+ if (abs < threshold) continue
2221
+ const s = (n / div).toFixed(digits(abs))
2222
+ // Rounding can reach the next unit (999 999 → "1000K"): use the larger unit instead.
2223
+ if (i > 0 && Math.abs(Number(s)) >= scales[i - 1][0] / div) {
2224
+ const [div2, suffix2, , digits2] = scales[i - 1]
2225
+ return `${(n / div2).toFixed(digits2(scales[i - 1][0]))}${suffix2}`
2226
+ }
2227
+ return `${s}${suffix}`
2187
2228
  }
2188
- if (abs >= 1e8) return `${(n / 1e8).toFixed(abs >= 1e10 ? 0 : 2)}亿`
2189
- if (abs >= 1e5) return `${(n / 1e4).toFixed(abs >= 1e7 ? 0 : 1)}万`
2190
- return n.toLocaleString(undefined, { maximumFractionDigits: 2 })
2229
+ return n.toLocaleString(uiLocale(), { maximumFractionDigits: 2 })
2191
2230
  }
2192
2231
 
2193
2232
  function toCsv(result) {
@@ -2261,23 +2300,72 @@ window.__ModuleLoader__.load({
2261
2300
  * Async data with request ordering: results of an older request (or of
2262
2301
  * previous deps) never overwrite newer ones, and `loading` is true from the
2263
2302
  * first render after deps change.
2303
+ *
2304
+ * `value` belongs to the current deps: while new deps load it is undefined,
2305
+ * so e.g. a previous data source's data is never shown or acted on.
2306
+ * `reload()` with unchanged deps keeps `value` until the new one arrives.
2307
+ * `scope` (optional) names the deps that identify *whose* data this is; when
2308
+ * only other deps change (a page offset, a filter) the previous value stays
2309
+ * on screen while the next one loads, as long as the scope is unchanged.
2264
2310
  */
2265
- function useAsync(fn, deps) {
2311
+ function useAsync(fn, deps, scope) {
2266
2312
  const depsKey = JSON.stringify(deps)
2267
- const [state, setState] = useState({ loading: true, value: undefined, error: undefined, key: null })
2313
+ const scopeKey = scope === undefined ? depsKey : JSON.stringify(scope)
2314
+ const [state, setState] = useState({ loading: true, value: undefined, error: undefined, key: null, scope: null })
2268
2315
  const seq = useRef(0)
2269
2316
  const reload = useCallback(() => {
2270
2317
  const id = ++seq.current
2271
2318
  setState(s => ({ ...s, loading: true, error: undefined }))
2272
2319
  Promise.resolve().then(fn).then(
2273
- value => { if (seq.current === id) setState({ loading: false, value, error: undefined, key: depsKey }) },
2274
- error => { if (seq.current === id) setState({ loading: false, value: undefined, error, key: depsKey }) },
2320
+ value => { if (seq.current === id) setState({ loading: false, value, error: undefined, key: depsKey, scope: scopeKey }) },
2321
+ error => { if (seq.current === id) setState({ loading: false, value: undefined, error, key: depsKey, scope: scopeKey }) },
2275
2322
  )
2276
2323
  return () => { if (seq.current === id) seq.current++ }
2277
2324
  }, deps)
2278
2325
  useEffect(reload, [reload])
2279
2326
  const stale = state.key !== depsKey
2280
- return { ...state, loading: state.loading || stale, stale, reload }
2327
+ const foreign = state.scope !== scopeKey
2328
+ return { loading: state.loading || stale, value: foreign ? undefined : state.value, error: stale ? undefined : state.error, stale, reload }
2329
+ }
2330
+
2331
+ /**
2332
+ * Guard against double submits: `run(fn)` ignores calls while one is in
2333
+ * flight (checked synchronously through a ref, so two clicks in the same
2334
+ * tick still run once) and `busy` re-renders the disabled state.
2335
+ */
2336
+ function useBusy() {
2337
+ const ref = useRef(false)
2338
+ const [busy, setBusy] = useState(false)
2339
+ const alive = useRef(true)
2340
+ useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
2341
+ const run = useCallback(async (fn) => {
2342
+ if (ref.current) return undefined
2343
+ ref.current = true
2344
+ setBusy(true)
2345
+ try { return await fn() } finally { ref.current = false; if (alive.current) setBusy(false) }
2346
+ }, [])
2347
+ return [busy, run]
2348
+ }
2349
+
2350
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
2351
+
2352
+ /** Keep Tab / Shift+Tab focus inside the element while `active`. */
2353
+ function useFocusTrap(ref, active = true) {
2354
+ useEffect(() => {
2355
+ const el = ref.current
2356
+ if (!active || !el) return
2357
+ const onKey = (e) => {
2358
+ if (e.key !== 'Tab') return
2359
+ const items = [...el.querySelectorAll(FOCUSABLE)].filter(x => !x.closest('[hidden]') && x.getAttribute('aria-hidden') !== 'true')
2360
+ if (!items.length) { e.preventDefault(); return }
2361
+ const first = items[0]
2362
+ const last = items[items.length - 1]
2363
+ const cur = document.activeElement
2364
+ if (e.shiftKey && (cur === first || !el.contains(cur))) { e.preventDefault(); last.focus() } else if (!e.shiftKey && (cur === last || !el.contains(cur))) { e.preventDefault(); first.focus() }
2365
+ }
2366
+ el.addEventListener('keydown', onKey)
2367
+ return () => el.removeEventListener('keydown', onKey)
2368
+ }, [ref, active, ref.current])
2281
2369
  }
2282
2370
 
2283
2371
  // ------------------------------------------------------------ feedback
@@ -2314,6 +2402,7 @@ window.__ModuleLoader__.load({
2314
2402
  const [value, setValue] = useState('')
2315
2403
  const [err, setErr] = useState(null)
2316
2404
  const box = useRef(null)
2405
+ useFocusTrap(box, Boolean(d))
2317
2406
  useEffect(() => {
2318
2407
  if (!d) return
2319
2408
  setValue(d.value ?? '')
@@ -2335,7 +2424,7 @@ window.__ModuleLoader__.load({
2335
2424
  return h(React.Fragment, null,
2336
2425
  h('div', { className: 'n2s-toasts', role: 'status', 'aria-live': 'polite' }, st.toasts.map(x => h('div', { key: x.id, className: `n2s-toast ${x.kind}` }, x.kind === 'ok' ? Icon.check(14) : x.kind === 'err' ? Icon.close(13) : null, h('span', null, x.text)))),
2337
2426
  d && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) close(d.kind === 'prompt' ? null : false) } },
2338
- h('div', { ref: box, className: 'n2s-modal', role: 'alertdialog', 'aria-modal': true, 'aria-labelledby': 'n2s-dlg-title', style: { width: 'min(440px, 100%)' }, onKeyDown: (e) => { if (e.key === 'Escape') close(d.kind === 'prompt' ? null : false); if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') ok() } },
2427
+ h('div', { ref: box, className: 'n2s-modal', role: 'alertdialog', 'aria-modal': true, 'aria-labelledby': 'n2s-dlg-title', style: { width: 'min(440px, 100%)' }, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(d.kind === 'prompt' ? null : false) } if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') ok() } },
2339
2428
  h('div', { id: 'n2s-dlg-title', className: 'n2s-modal-title', style: { marginBottom: 8 } }, d.title),
2340
2429
  d.message && h('div', { className: 'n2s-muted', style: { fontSize: 13.5, marginBottom: 12, whiteSpace: 'pre-wrap' } }, d.message),
2341
2430
  d.kind === 'prompt' && h('input', { className: 'n2s-input', value, placeholder: d.placeholder, 'aria-label': d.title, onChange: e => { setValue(e.target.value); setErr(null) } }),
@@ -2345,6 +2434,22 @@ window.__ModuleLoader__.load({
2345
2434
  h('button', { type: 'button', className: `n2s-btn ${d.danger ? 'danger-solid' : 'primary'}`, onClick: ok }, d.confirmLabel ?? t('fb2.ok'))))), document.body))
2346
2435
  }
2347
2436
 
2437
+
2438
+ /** Mount the FeedbackLayer once into its own root under document.body; returns the disposer. */
2439
+ function mountFeedbackLayer(ctx) {
2440
+ const el = document.createElement('div')
2441
+ el.className = 'n2s-feedback-root'
2442
+ el.dataset.plugin = PKG
2443
+ document.body.appendChild(el)
2444
+ const root = ReactDOMClient.createRoot(el)
2445
+ function GlobalFeedback() {
2446
+ const t = useMemo(() => ctx.locale.bind(NS), [])
2447
+ setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
2448
+ return h(FeedbackLayer, { t })
2449
+ }
2450
+ root.render(h(GlobalFeedback))
2451
+ return () => { const d = feedbackStore.get().dialog; feedbackStore.set({ ...feedbackStore.get(), dialog: null }); d?.resolve(d.kind === 'prompt' ? null : false); setTimeout(() => { root.unmount(); el.remove() }, 0) }
2452
+ }
2348
2453
  // ----------------------------------------------------------- charts
2349
2454
 
2350
2455
  let echartsPromise
@@ -2551,8 +2656,11 @@ window.__ModuleLoader__.load({
2551
2656
  const [sending, setSending] = useState(false)
2552
2657
  const [rating, setRating] = useState(null)
2553
2658
  const [drill, setDrill] = useState(null)
2659
+ const ratingBusy = useRef(false)
2554
2660
  useEffect(() => { setType(result.chart?.type ?? 'table') }, [result.resultId])
2555
2661
  const rate = async (value, comment) => {
2662
+ if (ratingBusy.current) return
2663
+ ratingBusy.current = true
2556
2664
  try {
2557
2665
  await call('submitFeedback', { resultId: result.resultId, rating: value, comment, question, sql: result.baseSql ?? result.sql, datasourceId: result.datasourceId, title: result.title, sessionId: onFeedback?.sessionId })
2558
2666
  setRated(value)
@@ -2560,11 +2668,15 @@ window.__ModuleLoader__.load({
2560
2668
  if (value === 'down') onFeedback?.down?.(comment)
2561
2669
  } catch (e) {
2562
2670
  notifyError(e)
2671
+ } finally {
2672
+ ratingBusy.current = false
2563
2673
  }
2564
2674
  }
2565
2675
  const chart = useMemo(() => (type === result.chart?.type ? result.chart : chartFor(type, result, result.suggestedChart ?? result.chart)) ?? { type: 'table' }, [type, result])
2566
2676
  const available = useMemo(() => CHART_CHOICES.filter(c => c === 'table' || chartFor(c, result, result.chart)), [result])
2567
- const pin = async () => {
2677
+ const [pinning, runPin] = useBusy()
2678
+ const pin = () => runPin(async () => {
2679
+ if (pinned) return
2568
2680
  try {
2569
2681
  await call('pinWidget', { title: result.title || result.sql.slice(0, 60), sql: result.sql, datasourceId: result.datasourceId, chart })
2570
2682
  setPinned(true)
@@ -2572,8 +2684,9 @@ window.__ModuleLoader__.load({
2572
2684
  } catch (e) {
2573
2685
  notifyError(e)
2574
2686
  }
2575
- }
2576
- const saveExample = async () => {
2687
+ })
2688
+ const [savingExample, runSaveExample] = useBusy()
2689
+ const saveExample = () => runSaveExample(async () => {
2577
2690
  const q = await promptDialog({ title: t('kb.examplePrompt'), value: question || result.title || '' })
2578
2691
  if (!q) return
2579
2692
  try {
@@ -2582,7 +2695,7 @@ window.__ModuleLoader__.load({
2582
2695
  } catch (e) {
2583
2696
  notifyError(e)
2584
2697
  }
2585
- }
2698
+ })
2586
2699
  const rerun = async (confirmScan) => {
2587
2700
  setRunning(true); setEditError(null)
2588
2701
  try {
@@ -2626,7 +2739,7 @@ window.__ModuleLoader__.load({
2626
2739
  isChart && result.chart?.x && result.rows.length >= 2 && { label: t('drill.why'), icon: Icon.spark(14), onClick: () => setDrill({ mode: 'why' }) },
2627
2740
  isChart && chartInstance && { label: t('result.png'), icon: Icon.download(14), onClick: exportPng },
2628
2741
  !hidden && { divider: true },
2629
- !hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample, onClick: saveExample },
2742
+ !hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample || savingExample, onClick: saveExample },
2630
2743
  ]
2631
2744
  const chartPicker = result.rows.length > 0 && h(Dropdown, {
2632
2745
  label: t('result.chartType'),
@@ -2653,7 +2766,7 @@ window.__ModuleLoader__.load({
2653
2766
  embedded && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'result.rows', { count: result.rowCount }), ' · ', fmt(t, 'result.elapsed', { ms: result.elapsedMs })),
2654
2767
  !hidden && h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-pressed': rated === 'up', title: t('fb.up'), 'aria-label': t('fb.up'), onClick: () => rate('up') }, Icon.thumbUp()),
2655
2768
  !hidden && h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-pressed': rated === 'down' || rating === 'down', title: t('fb.down'), 'aria-label': t('fb.down'), onClick: () => setRating(rating === 'down' ? null : 'down') }, Icon.thumbDown()),
2656
- !hidden && h('button', { type: 'button', className: 'n2s-iconbtn', disabled: pinned, 'aria-pressed': pinned, title: pinned ? t('result.pinned') : t('result.pin'), 'aria-label': t('result.pin'), onClick: pin }, Icon.pin()),
2769
+ !hidden && h('button', { type: 'button', className: 'n2s-iconbtn', disabled: pinned || pinning, 'aria-busy': pinning || undefined, 'aria-pressed': pinned, title: pinned ? t('result.pinned') : t('result.pin'), 'aria-label': t('result.pin'), onClick: pin }, Icon.pin()),
2657
2770
  h('button', { type: 'button', className: 'n2s-iconbtn', title: t('im.share'), 'aria-label': t('im.share'), onClick: () => setSending(true) }, Icon.send()),
2658
2771
  h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: moreItems })),
2659
2772
  rating === 'down' && h(FeedbackForm, { t, onCancel: () => setRating(null), onSubmit: (comment) => rate('down', comment) }),
@@ -2838,6 +2951,18 @@ window.__ModuleLoader__.load({
2838
2951
  const [state, setState] = useState(null)
2839
2952
  const [drag, setDrag] = useState(false)
2840
2953
  const inputRef = useRef(null)
2954
+ const box = useRef(null)
2955
+ useFocusTrap(box)
2956
+ const busyRef = useRef(false)
2957
+ busyRef.current = Boolean(state?.busy)
2958
+ useEffect(() => {
2959
+ const prev = document.activeElement
2960
+ setTimeout(() => box.current?.querySelector('[role=button], button')?.focus(), 0)
2961
+ // Escape works wherever focus is (not only inside the dialog), but not under a confirm dialog or mid-upload.
2962
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) onClose() }
2963
+ document.addEventListener('keydown', onKey)
2964
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
2965
+ }, [])
2841
2966
  const pick = async (f) => {
2842
2967
  if (!f) return
2843
2968
  setFile(f); setSheets(null); setState({ busy: true }); setProgress(0)
@@ -2850,6 +2975,8 @@ window.__ModuleLoader__.load({
2850
2975
  } catch (e) { setState({ error: e.message }) }
2851
2976
  }
2852
2977
  const run = async () => {
2978
+ if (busyRef.current) return
2979
+ busyRef.current = true
2853
2980
  setState({ busy: true })
2854
2981
  try {
2855
2982
  const res = await call('importUpload', { datasourceId: target || undefined, newName: target ? undefined : newName, fileName: file.name, uploadId, mode, sheets: sheets.filter(x => x.include).map(x => ({ sheet: x.sheet, table: x.table, mode, columns: x.columns })) })
@@ -2860,7 +2987,7 @@ window.__ModuleLoader__.load({
2860
2987
  }
2861
2988
  const typeOpts = ['TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME']
2862
2989
  return ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) onClose() } },
2863
- h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('up.title'), style: { width: 'min(860px, 100%)' }, onKeyDown: (e) => { if (e.key === 'Escape' && !state?.busy) onClose() } },
2990
+ h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('up.title'), style: { width: 'min(860px, 100%)' } },
2864
2991
  h('div', { className: 'n2s-row', style: { marginBottom: 12 } }, h('span', { className: 'n2s-modal-title' }, t('up.title')), h('span', { className: 'n2s-grow' }), h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-label': t('ds.cancel'), onClick: onClose }, Icon.close())),
2865
2992
  !sheets && h('div', {
2866
2993
  className: `n2s-drop ${drag ? 'over' : ''}`, role: 'button', tabIndex: 0,
@@ -2890,19 +3017,19 @@ window.__ModuleLoader__.load({
2890
3017
  h('tr', null, sh.columns.map((c, ci) => h('th', { key: ci, style: { top: 30, padding: '2px 8px' } }, h('select', { className: 'n2s-select', style: { height: 24, fontSize: 11, padding: '0 4px' }, value: c.type, 'aria-label': `${c.name} type`, onChange: e => setSheets(x => x.map((y, i) => i === si ? { ...y, columns: y.columns.map((cc, j) => j === ci ? { ...cc, type: e.target.value } : cc) } : y)) }, typeOpts.map(o => h('option', { key: o, value: o }, t(`up.type.${o}`)))))))),
2891
3018
  h('tbody', null, sh.preview.slice(0, 8).map((r, ri) => h('tr', { key: ri }, sh.columns.map((c, ci) => h('td', { key: ci, className: c.type === 'INTEGER' || c.type === 'REAL' ? 'num' : undefined }, r[ci] === null || r[ci] === undefined ? '' : String(r[ci]))))))))))),
2892
3019
  state?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, state.error),
2893
- state?.done && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, fmt(t, 'up.done', { name: state.done.datasourceName }), ' ', state.done.results.map(r => r.ok ? fmt(t, 'up.resultOk', { table: r.table, rows: r.rows }) : `${r.sheet}: ${r.error}`).join(';')),
3020
+ state?.done && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, fmt(t, 'up.done', { name: state.done.datasourceName }), ' ', joinClauses(state.done.results.map(r => r.ok ? fmt(t, 'up.resultOk', { table: r.table, rows: r.rows }) : `${colon(r.sheet)}${r.error}`))),
2894
3021
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
2895
3022
  h('span', { className: 'n2s-faint n2s-small n2s-grow' }, t('up.privacy')),
2896
3023
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, state?.done ? t('ds.close') : t('ds.cancel')),
2897
3024
  sheets && !state?.done && h('button', { type: 'button', className: 'n2s-btn primary', disabled: state?.busy || !sheets.some(x => x.include) || (!target && !newName.trim()), onClick: run }, state?.busy ? h('span', { className: 'n2s-spin' }) : null, t('up.import'))))), document.body)
2898
3025
  }
2899
3026
  function FileTables({ t, ds }) {
2900
- const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
3027
+ const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
2901
3028
  const tables = list.value ?? []
2902
3029
  if (!tables.length) return null
2903
3030
  return h('div', { className: 'n2s-chips', style: { marginTop: 6 } }, tables.map(tb => h('span', { key: tb.name, className: 'n2s-badge', title: `${tb.source ?? ''} · ${new Date(tb.updatedAt ?? tb.createdAt).toLocaleString()}` },
2904
3031
  `${tb.name} · ${formatCompact(tb.rows)}`,
2905
- h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 16, height: 16 }, 'aria-label': `${t('ds.delete')} ${tb.name}`, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'up.dropConfirm', { name: tb.name }), danger: true })) { await call('dropFileTable', { datasourceId: ds.id, table: tb.name }); list.reload(); refreshDatasources() } } }, Icon.close(10)))))
3032
+ h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 16, height: 16 }, 'aria-label': `${t('ds.delete')} ${tb.name}`, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'up.dropConfirm', { name: tb.name }), danger: true })) { try { await call('dropFileTable', { datasourceId: ds.id, table: tb.name }) } catch (e) { notifyError(e) } list.reload(); refreshDatasources() } } }, Icon.close(10)))))
2906
3033
  }
2907
3034
 
2908
3035
  function SourcesTab({ t, datasources }) {
@@ -2949,7 +3076,7 @@ window.__ModuleLoader__.load({
2949
3076
  h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
2950
3077
  h('button', { type: 'button', className: 'n2s-btn small', disabled: tr?.busy, onClick: () => test(ds) }, tr?.busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.test')),
2951
3078
  h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
2952
- !ds.current && { label: t('ds.setCurrent'), icon: Icon.check(14), onClick: async () => { await call('setCurrent', { id: ds.id }); refreshDatasources() } },
3079
+ !ds.current && { label: t('ds.setCurrent'), icon: Icon.check(14), onClick: async () => { try { await call('setCurrent', { id: ds.id }) } catch (e) { notifyError(e) } refreshDatasources() } },
2953
3080
  ds.type === 'sqlite' && { label: t('up.addFile'), icon: Icon.up(14), onClick: () => setUpload({ target: ds.id }) },
2954
3081
  ds.type !== 'sqlite' && { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(ds) },
2955
3082
  { label: t('policy.button'), icon: svg(14, [h('rect', { key: 1, x: 3.5, y: 7, width: 9, height: 6.5, rx: 1.4 }), h('path', { key: 2, d: 'M5.5 7V5a2.5 2.5 0 0 1 5 0v2' })]), onClick: () => setPolicyFor(ds) },
@@ -2998,11 +3125,12 @@ window.__ModuleLoader__.load({
2998
3125
  }
2999
3126
  const quoteTable = (name) => ds.type === 'mysql' ? `\`${name.replace(/`/g, '``')}\`` : ds.type === 'postgres' ? name.split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.') : `"${name.replace(/"/g, '""')}"`
3000
3127
  const runOverview = async (tb, pin) => {
3128
+ const forId = ds.id
3001
3129
  setPreview({ busy: true })
3002
3130
  try {
3003
- const res = await call('tableOverview', { datasourceId: ds.id, table: tb.name, pin })
3004
- setPreview({ overview: res.results, pinned: pin })
3005
- } catch (e) { setPreview({ error: e.message }) }
3131
+ const res = await call('tableOverview', { datasourceId: forId, table: tb.name, pin })
3132
+ if (currentDs.current === forId) setPreview({ overview: res.results, pinned: pin })
3133
+ } catch (e) { if (currentDs.current === forId) setPreview({ error: e.message }) }
3006
3134
  }
3007
3135
  // Default to the largest fact table so the detail pane is never blank.
3008
3136
  const defaultTable = schema ? ([...schema.tables].sort((x, y) => (y.role === 'fact') - (x.role === 'fact') || (y.rows ?? 0) - (x.rows ?? 0))[0]?.name ?? null) : null
@@ -3076,7 +3204,7 @@ window.__ModuleLoader__.load({
3076
3204
  * Hidden once every required step is done or the user dismisses it.
3077
3205
  */
3078
3206
  function Onboarding({ t, ds, onNavigate, onAsk, examples }) {
3079
- const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt])
3207
+ const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt], [])
3080
3208
  const [busy, setBusy] = useState(false)
3081
3209
  const v = st.value
3082
3210
  if (!v || v.dismissed) return null
@@ -3115,12 +3243,21 @@ window.__ModuleLoader__.load({
3115
3243
  const [exOffset, setExOffset] = useState(0)
3116
3244
  const [recentLimit, setRecentLimit] = useState(8)
3117
3245
  const [hiddenRecent, setHiddenRecent] = useState(() => new Set())
3118
- useEffect(() => { setExOffset(0); setRecentLimit(8) }, [ds?.id])
3119
- const sugg = useAsync(() => (ds ? call('askSuggestions', { datasourceId: ds.id, locale: uiLang, offset: exOffset, pageSize: 6, recentLimit }).catch(() => null) : null), [ds?.id, ds?.schemaAnalyzedAt, uiLang, exOffset, recentLimit])
3120
- // Keep the previous page on screen while the next one loads (no flicker on 换一批).
3121
- const [shown, setShown] = useState(null)
3122
- useEffect(() => { if (sugg.value) { setShown(sugg.value); setHiddenRecent(new Set()) } }, [sugg.value])
3123
- const data = sugg.value ?? shown
3246
+ // Data source switched: reset paging and per-source results during render, so the
3247
+ // first request for the new source already uses offset 0 (no extra fetch).
3248
+ const [stateFor, setStateFor] = useState(ds?.id)
3249
+ if (stateFor !== ds?.id) {
3250
+ setStateFor(ds?.id)
3251
+ setExOffset(0); setRecentLimit(8); setHiddenRecent(new Set())
3252
+ setResult(null); setPlan(null); setError(null)
3253
+ }
3254
+ const pageOffset = stateFor === ds?.id ? exOffset : 0
3255
+ const pageRecent = stateFor === ds?.id ? recentLimit : 8
3256
+ // Scoped by data source + language: within one source the previous page stays on
3257
+ // screen while the next loads (no flicker on 换一批); another source's never does.
3258
+ const sugg = useAsync(() => (ds ? call('askSuggestions', { datasourceId: ds.id, locale: uiLang, offset: pageOffset, pageSize: 6, recentLimit: pageRecent }).catch(() => null) : null), [ds?.id, ds?.schemaAnalyzedAt, uiLang, pageOffset, pageRecent], [ds?.id, uiLang])
3259
+ useEffect(() => { if (sugg.value && !sugg.stale) setHiddenRecent(new Set()) }, [sugg.value])
3260
+ const data = sugg.value
3124
3261
  const examples = data?.examples?.length ? data.examples : [t('ask.ex.tables'), t('ask.ex.biggest')]
3125
3262
  const canShuffle = (data?.total ?? 0) > (data?.pageSize ?? 6)
3126
3263
  const recent = (data?.recent ?? []).filter(r => !hiddenRecent.has(r.question))
@@ -3129,25 +3266,30 @@ window.__ModuleLoader__.load({
3129
3266
  setHiddenRecent(x => new Set(x).add(r.question))
3130
3267
  try { const res = await call('deleteHistory', { title: r.question, datasourceId: ds.id }); notify(fmt(t, 'ask.deletedN', { n: res.deleted }), 'ok') } catch (e) { notifyError(e); setHiddenRecent(x => { const n = new Set(x); n.delete(r.question); return n }) }
3131
3268
  }
3269
+ const currentDs = useRef(ds?.id)
3270
+ currentDs.current = ds?.id
3132
3271
  const run = async (confirmScan = false) => {
3272
+ const forId = ds.id
3133
3273
  setRunning(true); setError(null)
3134
3274
  try {
3135
- setResult(await call('runQuery', { sql, datasourceId: ds.id, title: '', confirmScan }))
3275
+ const r = await call('runQuery', { sql, datasourceId: forId, title: '', confirmScan })
3276
+ if (currentDs.current === forId) setResult(r)
3136
3277
  } catch (e) {
3137
- if (e.code === 'nl2sql/scan-guard' && await confirmDialog({ title: t('sql.confirmScan'), message: e.message })) { setRunning(false); return run(true) }
3138
- setError(e.message)
3278
+ if (e.code === 'nl2sql/scan-guard' && currentDs.current === forId && await confirmDialog({ title: t('sql.confirmScan'), message: e.message })) { setRunning(false); return run(true) }
3279
+ if (currentDs.current === forId) setError(e.message)
3139
3280
  }
3140
3281
  setRunning(false)
3141
3282
  }
3142
3283
  const explain = async () => {
3284
+ const forId = ds.id
3143
3285
  setRunning(true); setError(null)
3144
- try { setPlan(await call('explain', { sql, datasourceId: ds.id })) } catch (e) { setError(e.message) }
3286
+ try { const p = await call('explain', { sql, datasourceId: forId }); if (currentDs.current === forId) setPlan(p) } catch (e) { if (currentDs.current === forId) setError(e.message) }
3145
3287
  setRunning(false)
3146
3288
  }
3147
3289
  const submit = () => { if (question.trim()) { onAsk(question.trim()); setQuestion('') } }
3148
3290
  const [uploading, setUploading] = useState(false)
3149
3291
  const [sqlOpen, setSqlOpen] = useState(false)
3150
- const tableCount = ds?.tableCount ?? sugg.value?.tableCount
3292
+ const tableCount = ds?.tableCount ?? data?.tableCount
3151
3293
  return h('div', null,
3152
3294
  h(Onboarding, { t, ds, onNavigate, onAsk, examples }),
3153
3295
  h('div', { className: 'n2s-hero' },
@@ -3196,15 +3338,16 @@ window.__ModuleLoader__.load({
3196
3338
  result && h('div', { className: 'n2s-card', style: { marginTop: 12 } }, h(ResultView, { result, t, hideSql: true })))))
3197
3339
  }
3198
3340
 
3199
- /** "3 分钟前" style relative time. */
3341
+ /** "3 分钟前" style relative time in the UI language; future or older than a week → the date. */
3200
3342
  function relTime(t, at) {
3201
3343
  if (!at) return ''
3202
3344
  const d = (Date.now() - at) / 1000
3345
+ if (d < -60) return new Date(at).toLocaleDateString(uiLocale())
3203
3346
  if (d < 60) return t('time.justNow')
3204
3347
  if (d < 3600) return fmt(t, 'time.minAgo', { n: Math.floor(d / 60) })
3205
3348
  if (d < 86400) return fmt(t, 'time.hourAgo', { n: Math.floor(d / 3600) })
3206
3349
  if (d < 86400 * 7) return fmt(t, 'time.dayAgo', { n: Math.floor(d / 86400) })
3207
- return new Date(at).toLocaleDateString()
3350
+ return new Date(at).toLocaleDateString(uiLocale())
3208
3351
  }
3209
3352
 
3210
3353
  function PlanTable({ plan }) {
@@ -3290,6 +3433,7 @@ window.__ModuleLoader__.load({
3290
3433
  }
3291
3434
  function KnowledgeEditor({ kind, item, t, fields, schema, noteKeys, onCancel, onSave }) {
3292
3435
  const [form, setForm] = useState(() => ({ ...item, ...(Array.isArray(item?.synonyms) ? { synonyms: item.synonyms.join(',') } : {}) }))
3436
+ const [busy, runBusy] = useBusy()
3293
3437
  return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
3294
3438
  h('div', { className: 'n2s-form' }, fields.map(([key, label, type]) => h('label', { key, className: `n2s-field ${type === 'textarea' || type?.startsWith('code') ? 'full' : ''}` },
3295
3439
  h('span', null, t(label)),
@@ -3300,19 +3444,19 @@ window.__ModuleLoader__.load({
3300
3444
  h('datalist', { id: 'n2s-notekeys' }, noteKeys.slice(0, 2000).map(k2 => h('option', { key: k2, value: k2 }))),
3301
3445
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
3302
3446
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
3303
- h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => onSave(form) }, t('ds.save'))))
3447
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => runBusy(() => onSave(form)) }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
3304
3448
  }
3305
3449
 
3306
3450
  function KnowledgeTab({ t, ds }) {
3307
3451
  const kb = useAsync(() => (ds ? call('getKnowledge', { datasourceId: ds.id }) : null), [ds?.id])
3308
- const schema = useAsync(() => (ds?.schemaAnalyzedAt ? call('analyzeSchema', { id: ds.id }) : null), [ds?.id, ds?.schemaAnalyzedAt])
3452
+ const schema = useAsync(() => (ds?.schemaAnalyzedAt ? call('analyzeSchema', { id: ds.id }) : null), [ds?.id, ds?.schemaAnalyzedAt], [ds?.id])
3309
3453
  const [section, setSection] = useState('glossary')
3310
3454
  const [editing, setEditing] = useState(null)
3311
3455
  const [error, setError] = useState(null)
3312
3456
  const [drafts, setDrafts] = useState(null)
3313
3457
  const noteKeys = useMemo(() => (schema.value?.tables ?? []).flatMap(tb => [tb.name, ...tb.columns.map(c => `${tb.name}.${c.name}`)]), [schema.value])
3314
3458
  const allDatasources = useStore(s2 => s2.datasources)
3315
- const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value])
3459
+ const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value], [ds?.id])
3316
3460
  if (!ds) return null
3317
3461
  const k = kb.value ?? { glossary: [], metrics: [], notes: {}, examples: [] }
3318
3462
  const save = async (kind, item) => {
@@ -3321,7 +3465,7 @@ window.__ModuleLoader__.load({
3321
3465
  }
3322
3466
  const remove = async (kind, id) => {
3323
3467
  if (!await confirmDialog({ title: t('kb.deleteConfirm'), danger: true })) return
3324
- await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id }).catch(e => setError(e.message))
3468
+ try { await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id }) } catch (e) { setError(e.message) }
3325
3469
  kb.reload()
3326
3470
  }
3327
3471
  const FIELDS = {
@@ -3346,7 +3490,7 @@ window.__ModuleLoader__.load({
3346
3490
  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),
3347
3491
  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'))
3348
3492
  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),
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'))
3493
+ 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')}: ${joinList(m.dimensions)}` : ''}${m.synonyms?.length ? ` · ${t('kb.synonymsLabel')}: ${joinList(m.synonyms)}` : ''}`), m.description && h('div', { className: 'n2s-muted n2s-small' }, m.description)), 'metric', m)) : empty(t('kb.metricsEmpty'))
3350
3494
  if (section === 'note') {
3351
3495
  const entries = Object.entries(k.notes).sort(([a], [b]) => a.localeCompare(b))
3352
3496
  list = h(React.Fragment, null,
@@ -3371,7 +3515,7 @@ window.__ModuleLoader__.load({
3371
3515
  }
3372
3516
  function HistoryTab({ t, datasources, onOpenSession }) {
3373
3517
  const [filters, setFilters] = useState({ status: '', source: '', datasourceId: '', search: '' })
3374
- const list = useAsync(() => call('listHistory', { ...filters, limit: 300 }), [filters.status, filters.source, filters.datasourceId, filters.search])
3518
+ const list = useAsync(() => call('listHistory', { ...filters, limit: 300 }), [filters.status, filters.source, filters.datasourceId, filters.search], [])
3375
3519
  const [open, setOpen] = useState(null)
3376
3520
  const [rerun, setRerun] = useState(null)
3377
3521
  const [removed, setRemoved] = useState(() => new Set())
@@ -3397,13 +3541,7 @@ window.__ModuleLoader__.load({
3397
3541
  }, [rows])
3398
3542
  const [search, setSearch] = useState('')
3399
3543
  useEffect(() => { const id = setTimeout(() => setFilters(f => (f.search === search ? f : { ...f, search })), 250); return () => clearTimeout(id) }, [search])
3400
- const rel = (at) => {
3401
- const d = (Date.now() - at) / 1000
3402
- if (d < 60) return t('time.justNow')
3403
- if (d < 3600) return fmt(t, 'time.minAgo', { n: Math.floor(d / 60) })
3404
- if (d < 86400) return fmt(t, 'time.hourAgo', { n: Math.floor(d / 3600) })
3405
- return new Date(at).toLocaleDateString()
3406
- }
3544
+ const rel = (at) => relTime(t, at)
3407
3545
  const statusDot = (st) => h('span', { className: `n2s-statusdot ${st === 'error' ? 'err' : st === 'blocked' ? 'warn' : 'ok'}` }, t(`hist.status.${st ?? 'ok'}`))
3408
3546
  const oneLine = (sql) => sql.replace(/\s+/g, ' ').trim()
3409
3547
  return h('div', null,
@@ -3418,30 +3556,31 @@ window.__ModuleLoader__.load({
3418
3556
  h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
3419
3557
  { label: t('hist.export'), icon: Icon.download(14), disabled: rows.length === 0, onClick: exportCsv },
3420
3558
  { divider: true },
3421
- { label: t('hist.clear'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: t('hist.clearConfirm'), danger: true })) { await call('clearHistory'); list.reload() } } },
3559
+ { label: t('hist.clear'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: t('hist.clearConfirm'), danger: true })) { try { await call('clearHistory') } catch (e) { notifyError(e) } list.reload() } } },
3422
3560
  ] })),
3423
3561
  list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
3424
3562
  list.loading && !list.value && h('div', { className: 'n2s-skel', style: { height: 240 } }),
3425
3563
  !list.loading && rows.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('hist.empty')), h('div', null, t('hist.emptyHint'))),
3426
3564
  rows.length > 0 && h('div', { className: 'n2s-hist', role: 'list' }, rows.map(r => {
3427
3565
  const isOpen = open === r.id
3428
- return h('div', { key: r.id ?? `${r.at}`, className: 'n2s-hist-row has-del', role: 'listitem' },
3429
- h('button', { type: 'button', className: 'n2s-iconbtn n2s-rowdel', title: t('hist.deleteOne'), 'aria-label': `${t('hist.deleteOne')}: ${r.title || t('hist.untitled')}`, onClick: () => removeRow(r) }, Icon.trash(14)),
3566
+ return h('div', { key: r.id ?? `${r.at}`, className: 'n2s-hist-row', role: 'listitem' },
3567
+ h('div', { className: 'n2s-hist-head' },
3430
3568
  h('button', { type: 'button', className: 'n2s-hist-main', 'aria-expanded': isOpen, onClick: () => setOpen(isOpen ? null : r.id) },
3431
3569
  statusDot(r.status),
3432
3570
  h('span', { className: 'n2s-hist-what' },
3433
3571
  h('span', { className: `n2s-hist-title ${r.title ? '' : 'none'}` }, r.title || r.error?.slice(0, 80) || t('hist.untitled')),
3434
3572
  h('span', { className: 'n2s-hist-sql', title: oneLine(r.sql).slice(0, 400) }, oneLine(r.sql).slice(0, 300))),
3435
- h('span', { className: 'n2s-hist-when' }, h('span', { title: new Date(r.at).toLocaleString() }, rel(r.at)), h('span', { className: 'src' }, `${t(`hist.source.${r.source}`)} · ${r.datasourceName ?? ''}`)),
3573
+ h('span', { className: 'n2s-hist-when' }, h('span', { title: new Date(r.at).toLocaleString(uiLocale()) }, rel(r.at)), h('span', { className: 'src' }, `${sourceLabel(t, r.source)} · ${r.datasourceName ?? ''}`)),
3436
3574
  h('span', { className: 'n2s-hist-num' }, r.rows !== undefined ? fmt(t, 'result.rows', { count: r.rows }) : '—', h('br'), r.elapsedMs !== undefined ? `${r.elapsedMs} ms` : ''),
3437
3575
  h('span', { className: 'n2s-hist-chev' }, Icon.chev(12))),
3576
+ h('button', { type: 'button', className: 'n2s-iconbtn n2s-rowdel', title: t('hist.deleteOne'), 'aria-label': `${t('hist.deleteOne')}: ${r.title || t('hist.untitled')}`, onClick: () => removeRow(r) }, Icon.trash(14))),
3438
3577
  isOpen && h('div', { className: 'n2s-hist-detail' },
3439
3578
  r.error && h('div', { className: `n2s-notice ${r.status === 'blocked' ? 'warn' : 'err'}` }, r.error),
3440
3579
  h('div', { style: { position: 'relative' } },
3441
3580
  h('pre', { className: 'n2s-code', style: { maxHeight: 220, paddingRight: 44 } }, prettySql(r.sql)),
3442
3581
  h('button', { type: 'button', className: 'n2s-iconbtn', style: { position: 'absolute', top: 6, right: 6 }, title: t('result.copy'), 'aria-label': t('result.copy'), onClick: () => navigator.clipboard?.writeText(r.sql) }, Icon.copy(14))),
3443
3582
  h('div', { className: 'n2s-row' },
3444
- h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString()),
3583
+ h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString(uiLocale())),
3445
3584
  r.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onOpenSession(r.sessionId) }, Icon.external(13), t('hist.openSession')),
3446
3585
  h('button', {
3447
3586
  type: 'button', className: 'n2s-btn small',
@@ -3454,6 +3593,15 @@ window.__ModuleLoader__.load({
3454
3593
  rerun?.id === r.id && (rerun.busy ? h('div', { className: 'n2s-skel', style: { height: 200 } }) : rerun.error ? h('div', { className: 'n2s-notice err' }, rerun.error) : h('div', { className: 'n2s-card' }, h(ResultView, { result: rerun.result, t, hideSql: true })))))
3455
3594
  })))
3456
3595
  }
3596
+
3597
+ const HIST_SOURCES = ['agent', 'ui', 'dashboard', 'alert', 'eval', 'larkbot', 'telegrambot', 'wecombot', 'dingtalkbot']
3598
+ /** Label of a history `source`; unknown sources (a new bot platform…) fall back to a generic label. */
3599
+ function sourceLabel(t, source) {
3600
+ if (!source) return t('hist.source.unknown')
3601
+ if (HIST_SOURCES.includes(source)) return t(`hist.source.${source}`)
3602
+ const bot = /^(.+)bot$/.exec(source)
3603
+ return bot ? fmt(t, 'hist.source.genericBot', { platform: bot[1] }) : source
3604
+ }
3457
3605
  function OverviewGrid({ t, results, pinned }) {
3458
3606
  const [sending, setSending] = useState(false)
3459
3607
  const ok = results.filter(r => !r.error && r.resultId)
@@ -3531,7 +3679,7 @@ window.__ModuleLoader__.load({
3531
3679
  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' })])),
3532
3680
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
3533
3681
  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)),
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')}`)),
3682
+ h('div', { className: 'n2s-list2-sub' }, `${t(`rep.period.${r.period}`)} ${r.period === 'weekly' ? r.weekdays.map(d => t(`rep.wd.${d}`)).join(listSep()) + ' ' : ''}${r.time.slice(0, 5)} · ${r.datasourceName} · ${r.scope === 'question' ? r.question : t('rep.scope.dashboard')}`)),
3535
3683
  h('div', { className: 'n2s-list2-side' },
3536
3684
  statusDot(r.status),
3537
3685
  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' })}` : '')),
@@ -3547,25 +3695,32 @@ window.__ModuleLoader__.load({
3547
3695
  }
3548
3696
  function FeedbackForm({ t, onCancel, onSubmit }) {
3549
3697
  const [comment, setComment] = useState('')
3698
+ const [busy, runBusy] = useBusy()
3550
3699
  const reasons = ['fb.reason.wrongTable', 'fb.reason.wrongMetric', 'fb.reason.wrongFilter', 'fb.reason.wrongTime', 'fb.reason.chart']
3551
3700
  return h('div', { className: 'n2s-card', style: { padding: 10 } },
3552
3701
  h('div', { className: 'n2s-small', style: { marginBottom: 6 } }, t('fb.whatWrong')),
3553
- h('div', { className: 'n2s-chips', style: { marginBottom: 6 } }, reasons.map(r => h('button', { key: r, type: 'button', className: 'n2s-chip', onClick: () => setComment(c => (c ? `${c};${t(r)}` : t(r))) }, t(r)))),
3702
+ h('div', { className: 'n2s-chips', style: { marginBottom: 6 } }, reasons.map(r => h('button', { key: r, type: 'button', className: 'n2s-chip', onClick: () => setComment(c => (c ? joinClauses([c, t(r)]) : t(r))) }, t(r)))),
3554
3703
  h('textarea', { className: 'n2s-textarea', style: { minHeight: 56 }, value: comment, onChange: e => setComment(e.target.value), placeholder: t('fb.commentPh') }),
3555
3704
  h('div', { className: 'n2s-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
3556
3705
  h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: onCancel }, t('ds.cancel')),
3557
- h('button', { type: 'button', className: 'n2s-btn small primary', onClick: () => onSubmit(comment.trim()) }, t('fb.submit'))))
3706
+ h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy, onClick: () => runBusy(() => onSubmit(comment.trim())) }, busy ? h('span', { className: 'n2s-spin' }) : null, t('fb.submit'))))
3558
3707
  }
3559
3708
 
3560
3709
  function FeedbackSection({ t, ds }) {
3561
3710
  const [status, setStatus] = useState('open')
3562
- const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status])
3563
- const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value])
3711
+ const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status], [ds?.id])
3712
+ const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value], [ds?.id])
3564
3713
  const [fixing, setFixing] = useState(null)
3565
3714
  const [error, setError] = useState(null)
3566
- const resolve = async (payload) => {
3715
+ const [busy, runBusy] = useBusy()
3716
+ const resolve = (payload) => runBusy(async () => {
3567
3717
  setError(null)
3568
3718
  try { await call('resolveFeedback', payload); setFixing(null); list.reload() } catch (e) { setError(e.message) }
3719
+ })
3720
+ const remove = async (f) => {
3721
+ if (!await confirmDialog({ title: t('fb.deleteConfirm'), message: f.question || f.title || '', confirmLabel: t('ds.delete'), danger: true })) return
3722
+ try { await call('deleteFeedback', { id: f.id }) } catch (e) { notifyError(e) }
3723
+ list.reload()
3569
3724
  }
3570
3725
  const st = stats.value
3571
3726
  return h('div', null,
@@ -3587,13 +3742,13 @@ window.__ModuleLoader__.load({
3587
3742
  h('span', { className: 'n2s-muted n2s-small' }, new Date(f.at).toLocaleString())),
3588
3743
  f.comment && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, f.comment),
3589
3744
  h('pre', { className: 'n2s-code', style: { marginTop: 6 } }, f.correctedSql || f.sql),
3590
- f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${t('fb.resolution')}: ${f.resolution}`)),
3745
+ f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${colon(t('fb.resolution'))}${f.resolution}`)),
3591
3746
  h('div', { style: { display: 'grid', gap: 4 } },
3592
3747
  f.sessionId && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => fbCtx.openSession?.(f.sessionId) }, t('hist.openSession')),
3593
3748
  f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small primary', onClick: () => setFixing({ id: f.id, sql: f.sql, question: f.question || f.title, resolution: '' }) }, t('fb.fix')),
3594
3749
  f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'ignored' }) }, t('fb.ignore')),
3595
3750
  f.rating === 'up' && !f.savedAsExample && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'resolved', saveExample: true }) }, t('kb.saveExample')),
3596
- h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick: async () => { await call('deleteFeedback', { id: f.id }); list.reload() } }, t('ds.delete')))),
3751
+ h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick: () => remove(f) }, t('ds.delete')))),
3597
3752
  fixing?.id === f.id && h('div', { className: 'n2s-ask', style: { marginTop: 10 } },
3598
3753
  h('label', { className: 'n2s-field' }, h('span', null, t('kb.question')), h('input', { className: 'n2s-input', value: fixing.question, onChange: e => setFixing(x => ({ ...x, question: e.target.value })) })),
3599
3754
  h('label', { className: 'n2s-field' }, h('span', null, t('fb.correctedSql')), h('textarea', { className: 'n2s-textarea n2s-mono', style: { minHeight: 120 }, spellCheck: false, value: fixing.sql, onChange: e => setFixing(x => ({ ...x, sql: e.target.value })) })),
@@ -3639,10 +3794,16 @@ window.__ModuleLoader__.load({
3639
3794
  area('maskedColumns', 'policy.maskedColumns', 'users.phone\\n*.email')),
3640
3795
  h('div', null,
3641
3796
  h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 6 } }, tables.length ? fmt(t, 'policy.preview', { allowed: allowed.length, total: tables.length }) : t('schema.notAnalyzed')),
3642
- h('div', { className: 'n2s-list', style: { maxHeight: 320 } }, tables.map(tb => {
3797
+ h('div', { className: 'n2s-list', role: 'group', 'aria-label': t('policy.tablesLabel'), style: { maxHeight: 320 } }, tables.map(tb => {
3643
3798
  const ok = allowed.includes(tb)
3644
- return h('div', { key: tb.name, className: 'n2s-item', style: { cursor: form.mode === 'all' ? 'default' : 'pointer', opacity: ok ? 1 : 0.5 }, onClick: () => form.mode !== 'all' && toggleTable(tb.name) },
3645
- h('span', null, ok ? '✓' : '✕'), h('span', { className: 'name n2s-mono' }, tb.name), roleBadge(t, tb.role))
3799
+ const editable = form.mode !== 'all'
3800
+ return h('div', {
3801
+ key: tb.name, className: 'n2s-item', style: { cursor: editable ? 'pointer' : 'default', opacity: ok ? 1 : 0.5 },
3802
+ role: 'checkbox', 'aria-checked': ok, 'aria-disabled': editable ? undefined : true, tabIndex: editable ? 0 : -1, 'aria-label': tb.name,
3803
+ onClick: () => editable && toggleTable(tb.name),
3804
+ onKeyDown: (e) => { if (editable && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); toggleTable(tb.name) } },
3805
+ },
3806
+ h('span', { 'aria-hidden': true }, ok ? '✓' : '✕'), h('span', { className: 'name n2s-mono' }, tb.name), roleBadge(t, tb.role))
3646
3807
  })),
3647
3808
  form.mode !== 'all' && tables.length > 0 && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 6 } }, t('policy.clickHint')))),
3648
3809
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
@@ -3661,8 +3822,16 @@ window.__ModuleLoader__.load({
3661
3822
  const next = patch === null ? null : { kind: 'time', column: time?.column ?? cand.value?.time[0]?.column ?? 'created_at', range: 'last30d', ...time, ...patch }
3662
3823
  onChange([...(next ? [next] : []), ...ins])
3663
3824
  }
3664
- const setIn = (column, values) => onChange([...(time ? [time] : []), ...ins.filter(f => f.column !== column), ...(values.length ? [{ kind: 'in', column, values }] : [])])
3825
+ // keep: an emptied free-text field keeps its tag (with no values) instead of disappearing.
3826
+ const setIn = (column, values, keep) => {
3827
+ const others = ins.filter(f => f.column !== column)
3828
+ const at = ins.findIndex(f => f.column === column)
3829
+ const mine = values.length || keep ? [{ kind: 'in', column, values }] : []
3830
+ const next = at < 0 ? [...others, ...mine] : [...others.slice(0, at), ...mine, ...others.slice(at)]
3831
+ onChange([...(time ? [time] : []), ...next])
3832
+ }
3665
3833
  const c = cand.value ?? { time: [], dims: [] }
3834
+ const activeN = filters.filter(f => f.kind !== 'in' || f.values.length > 0).length
3666
3835
  return h('div', { className: 'n2s-filterbar', role: 'group', 'aria-label': t('filter.title') },
3667
3836
  h('span', { className: 'n2s-faint', style: { display: 'inline-flex' } }, svg(14, [h('path', { key: 1, d: 'M2.5 3.5h11l-4.2 5v4l-2.6 1.2V8.5L2.5 3.5z' })])),
3668
3837
  h('select', { className: 'n2s-select', value: time ? time.range : '', onChange: e => setTime(e.target.value ? { range: e.target.value } : null), 'aria-label': t('filter.range') },
@@ -3679,7 +3848,7 @@ window.__ModuleLoader__.load({
3679
3848
  h('span', { className: 'n2s-muted' }, dim?.label ?? f.column),
3680
3849
  dim?.values?.length
3681
3850
  ? h('select', { className: 'n2s-select', 'aria-label': dim.label, value: f.values[0] ?? '', onChange: e => setIn(f.column, e.target.value ? [e.target.value] : []) }, dim.values.map(v => h('option', { key: v, value: v }, v)))
3682
- : h('input', { className: 'n2s-input', style: { width: 120, height: 26, border: 0 }, value: f.values.join(','), onChange: e => setIn(f.column, e.target.value.split(/[,,]/).map(x => x.trim()).filter(Boolean)) }),
3851
+ : h(FilterValuesInput, { key: f.column, t, label: dim?.label ?? f.column, values: f.values, onCommit: (values) => setIn(f.column, values, true) }),
3683
3852
  h('button', { type: 'button', className: 'n2s-iconbtn', style: { width: 22, height: 22 }, 'aria-label': t('filter.remove'), onClick: () => setIn(f.column, []) }, Icon.close(11)))
3684
3853
  }),
3685
3854
  c.dims.filter(d => !ins.some(f => f.column === d.column)).length > 0 && h(Dropdown, {
@@ -3688,9 +3857,39 @@ window.__ModuleLoader__.load({
3688
3857
  items: c.dims.filter(d => !ins.some(f => f.column === d.column)).map(d => ({ label: d.label === d.column ? d.column : `${d.label}`, hint: d.label === d.column ? '' : d.column, onClick: () => setIn(d.column, d.values?.slice(0, 1) ?? []) })),
3689
3858
  }),
3690
3859
  h('span', { className: 'n2s-grow' }),
3691
- filters.length > 0 && h('span', { className: 'n2s-faint n2s-small', title: t('filter.hint') }, fmt(t, 'filter.activeN', { count: filters.length })),
3860
+ activeN > 0 && h('span', { className: 'n2s-faint n2s-small', title: t('filter.hint') }, fmt(t, 'filter.activeN', { count: activeN })),
3692
3861
  filters.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onChange([]) }, t('filter.clear')))
3693
3862
  }
3863
+
3864
+ /**
3865
+ * Free-text values of an "in" filter: typed into a local draft (commas allowed,
3866
+ * clearing does not remove the tag) and committed on Enter, blur or after a pause.
3867
+ */
3868
+ function FilterValuesInput({ t, label, values, onCommit }) {
3869
+ const joined = values.join(', ')
3870
+ const [draft, setDraft] = useState(joined)
3871
+ const committed = useRef(joined)
3872
+ // Adopt outside changes (clear all, another board) when not mid-edit.
3873
+ useEffect(() => { if (joined !== committed.current) { committed.current = joined; setDraft(joined) } }, [joined])
3874
+ const parse = (s) => s.split(/[,,]/).map(x => x.trim()).filter(Boolean)
3875
+ const commit = (s) => {
3876
+ const next = parse(s)
3877
+ if (next.join('\u0000') === parse(committed.current).join('\u0000')) return
3878
+ committed.current = next.join(', ')
3879
+ onCommit(next)
3880
+ }
3881
+ useEffect(() => {
3882
+ if (draft === committed.current) return
3883
+ const id = setTimeout(() => commit(draft), 800)
3884
+ return () => clearTimeout(id)
3885
+ }, [draft])
3886
+ return h('input', {
3887
+ className: 'n2s-input', style: { width: 120, height: 26, border: 0 }, 'aria-label': label, placeholder: t('filter.valuesPh'), value: draft,
3888
+ onChange: e => setDraft(e.target.value),
3889
+ onBlur: () => commit(draft),
3890
+ onKeyDown: (e) => { if (e.key === 'Enter' && !e.nativeEvent?.isComposing) { e.preventDefault(); commit(draft) } },
3891
+ })
3892
+ }
3694
3893
  // ------------------------------------------------------------ IM push
3695
3894
 
3696
3895
  function makeReplyAction(ctx) {
@@ -4072,6 +4271,11 @@ window.__ModuleLoader__.load({
4072
4271
  }, [])
4073
4272
  const [state, setState] = useState(null)
4074
4273
  const ref = useRef(null)
4274
+ useFocusTrap(ref)
4275
+ const closeTimer = useRef(null)
4276
+ useEffect(() => () => clearTimeout(closeTimer.current), [])
4277
+ const busyRef = useRef(false)
4278
+ busyRef.current = Boolean(state?.busy)
4075
4279
  useEffect(() => {
4076
4280
  const def = channels.find(c => c.isDefault) ?? channels[0]
4077
4281
  if (def && !form.channelId) setForm(f => ({ ...f, channelId: def.id }))
@@ -4079,11 +4283,14 @@ window.__ModuleLoader__.load({
4079
4283
  useEffect(() => {
4080
4284
  const prev = document.activeElement
4081
4285
  ref.current?.querySelector('select, textarea, input, button')?.focus()
4082
- const onKey = (e) => { if (e.key === 'Escape') onClose() }
4286
+ // Escape belongs to a confirm/prompt dialog on top, if any.
4287
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) onClose() }
4083
4288
  document.addEventListener('keydown', onKey)
4084
4289
  return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
4085
4290
  }, [])
4086
4291
  const send = async () => {
4292
+ if (busyRef.current) return
4293
+ busyRef.current = true
4087
4294
  setState({ busy: true })
4088
4295
  try {
4089
4296
  const res = initial.dashboard
@@ -4091,7 +4298,7 @@ window.__ModuleLoader__.load({
4091
4298
  : 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
4299
  setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
4093
4300
  refreshChannels()
4094
- setTimeout(onClose, 1200)
4301
+ closeTimer.current = setTimeout(onClose, 1200)
4095
4302
  } catch (e) {
4096
4303
  setState({ error: e.message })
4097
4304
  }
@@ -4158,6 +4365,7 @@ window.__ModuleLoader__.load({
4158
4365
  /** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
4159
4366
  function Modal({ label, labelledBy, onClose, width, children, className }) {
4160
4367
  const box = useRef(null)
4368
+ useFocusTrap(box)
4161
4369
  useEffect(() => {
4162
4370
  const prev = document.activeElement
4163
4371
  setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
@@ -4183,13 +4391,13 @@ window.__ModuleLoader__.load({
4183
4391
  h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
4184
4392
  p && h(React.Fragment, null,
4185
4393
  h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
4186
- h('div', { className: 'n2s-kindcards', role: 'list', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
4187
- (p.kinds ?? []).map(k => h('button', { key: k.id, type: 'button', role: 'listitem', 'data-nav': true, className: 'n2s-kindcard', onClick: () => onPick(p.platform, k.id) },
4394
+ h('ul', { className: 'n2s-kindcards', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
4395
+ (p.kinds ?? []).map(k => h('li', { key: k.id, className: 'n2s-kindcard-li' }, h('button', { type: 'button', 'data-nav': true, className: 'n2s-kindcard', onClick: () => onPick(p.platform, k.id) },
4188
4396
  h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
4189
4397
  k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
4190
4398
  h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
4191
4399
  h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
4192
- k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl'))))))))
4400
+ k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl')))))))))
4193
4401
  }
4194
4402
 
4195
4403
  // ---------------------------------------------------------------- step 2
@@ -4426,7 +4634,7 @@ window.__ModuleLoader__.load({
4426
4634
 
4427
4635
  // ----------------------------------------------------------------- list
4428
4636
 
4429
- const shortTime = (ts) => new Date(ts).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
4637
+ const shortTime = (ts) => new Date(ts).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
4430
4638
 
4431
4639
  function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
4432
4640
  const kind = findKind(catalog, c.type)?.kind
@@ -4490,7 +4698,7 @@ window.__ModuleLoader__.load({
4490
4698
  const channels = useChannels()
4491
4699
  const bots = useBots()
4492
4700
  const { catalog } = useCatalog(t)
4493
- const deliveries = useAsync(() => call('listDeliveries'), [channels])
4701
+ const deliveries = useAsync(() => call('listDeliveries'), [channels], [])
4494
4702
  const [editing, setEditing] = useState(null)
4495
4703
  // Pick up host upgrades (catalog, new channel shape) whenever the page opens.
4496
4704
  useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
@@ -4616,7 +4824,12 @@ window.__ModuleLoader__.load({
4616
4824
  useEffect(() => { if (!saved.loading && saved.value) { setFilters(saved.value); setFiltersFor(boardId) } }, [saved.value, saved.loading, boardId])
4617
4825
  const filtersReady = filters !== null && filtersFor === boardId
4618
4826
  const [sendingDash, setSendingDash] = useState(false)
4619
- const changeFilters = (next) => { setFilters(next); call('saveDashboardFilters', { filters: next }).catch(() => {}) }
4827
+ // Saves are serialized so an older save can never land after a newer one.
4828
+ const saveChain = useRef(Promise.resolve())
4829
+ const changeFilters = (next) => {
4830
+ setFilters(next)
4831
+ saveChain.current = saveChain.current.then(() => call('saveDashboardFilters', { filters: next })).catch((e) => notifyError(e))
4832
+ }
4620
4833
  const otherBoards = (boards.value?.dashboards ?? []).filter(d => d.id !== boards.value?.current)
4621
4834
  // Per-widget load status → header summary ("updated 10:42 · 2 from cache").
4622
4835
  const [loads, setLoads] = useState({})
@@ -4679,7 +4892,7 @@ window.__ModuleLoader__.load({
4679
4892
  h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4680
4893
  (mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
4681
4894
  ['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' ? '、' : ', ') })),
4895
+ showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: joinList(sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort'))) })),
4683
4896
  h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
4684
4897
  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' }) }) : '',
4685
4898
  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,
@@ -4700,7 +4913,7 @@ window.__ModuleLoader__.load({
4700
4913
  !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'))),
4701
4914
  widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
4702
4915
  filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
4703
- key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).length,
4916
+ key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).filter(f => f.kind !== 'in' || f.values.length > 0).length,
4704
4917
  onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
4705
4918
  onMove: (delta) => move(w, delta),
4706
4919
  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() },
@@ -4755,24 +4968,32 @@ window.__ModuleLoader__.load({
4755
4968
  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)}%` } })),
4756
4969
  h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
4757
4970
  h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
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' ? '、' : ', ')}`))
4971
+ 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(listSep())}`))
4759
4972
  }
4760
4973
 
4761
4974
  // ------------------------------------------------------------ alerts
4762
4975
 
4763
4976
  function AlertForm({ t, initial, datasources, ds, onDone, onCancel }) {
4764
4977
  const channels = useChannels()
4765
- const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id, source: 'metric', metric: '', grain: 'day', sql: '', every: 'hour', at: '09:00', cooldownMin: 60, channelId: channels.find(c => c.isDefault)?.id ?? '', mention: false, enabled: true, ...(initial ?? {}), condition: { type: 'change', direction: 'any', pct: 20, ...(initial?.condition ?? {}) } }))
4978
+ const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id, source: 'metric', metric: '', grain: 'day', sql: '', every: 'hour', at: '09:00', cooldownMin: 60, channelId: initial ? '' : (channels.find(c => c.isDefault)?.id ?? ''), mention: false, enabled: true, ...(initial ?? {}), condition: { type: 'change', direction: 'any', pct: 20, ...(initial?.condition ?? {}) } }))
4766
4979
  const k = useAsync(() => (form.datasourceId ? call('getKnowledge', { datasourceId: form.datasourceId }) : null), [form.datasourceId])
4767
4980
  const [error, setError] = useState(null)
4768
4981
  const [test, setTest] = useState(null)
4982
+ const [busy, runBusy] = useBusy()
4983
+ // Channels may load after the form opens: adopt the default once, unless the user already chose.
4984
+ const channelTouched = useRef(Boolean(initial) || Boolean(form.channelId))
4985
+ useEffect(() => {
4986
+ if (channelTouched.current) return
4987
+ const def = channels.find(c => c.isDefault)
4988
+ if (def) { channelTouched.current = true; setForm(f => ({ ...f, channelId: def.id })) }
4989
+ }, [channels])
4769
4990
  const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
4770
4991
  const setC = (patch) => setForm(f => ({ ...f, condition: { ...f.condition, ...patch } }))
4771
4992
  const metrics = k.value?.metrics ?? []
4772
- const save = async () => {
4993
+ const save = () => runBusy(async () => {
4773
4994
  setError(null)
4774
4995
  try { await call('saveAlert', { ...form, cooldownMin: Number(form.cooldownMin) }); onDone() } catch (e) { setError(e.message) }
4775
- }
4996
+ })
4776
4997
  const tryIt = async () => {
4777
4998
  if (test?.busy) return
4778
4999
  setTest({ busy: true })
@@ -4801,24 +5022,24 @@ window.__ModuleLoader__.load({
4801
5022
  h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
4802
5023
  h('label', { className: 'n2s-field' }, h('span', null, t('al.every')), h('select', { className: 'n2s-select', value: form.every, onChange: set('every') }, ['15m', 'hour', 'day'].map(v => h('option', { key: v, value: v }, t(`al.every.${v}`))))),
4803
5024
  form.every === 'day' ? h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.at, onChange: set('at') })) : h('label', { className: 'n2s-field' }, h('span', null, t('al.cooldown')), h('input', { className: 'n2s-input', type: 'number', value: form.cooldownMin, onChange: set('cooldownMin') })),
4804
- h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => setForm(f => ({ ...f, channelId: v })) })),
5025
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => { channelTouched.current = true; setForm(f => ({ ...f, channelId: v })) } })),
4805
5026
  h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
4806
- test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${test.triggered ? t('al.wouldFire') : t('al.wouldNotFire')}:${test.message}`),
5027
+ test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${colon(test.triggered ? t('al.wouldFire') : t('al.wouldNotFire'))}${test.message}`),
4807
5028
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
4808
5029
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
4809
5030
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
4810
5031
  h('button', { type: 'button', className: 'n2s-btn', disabled: test?.busy, onClick: tryIt }, t('al.test')),
4811
- h('button', { type: 'button', className: 'n2s-btn primary', onClick: save }, t('ds.save'))))
5032
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
4812
5033
  }
4813
5034
 
4814
5035
  function AlertsTab({ t, ds, datasources }) {
4815
5036
  const list = useAsync(() => call('listAlerts'), [])
4816
- const events = useAsync(() => call('listAlertEvents', {}), [list.value])
5037
+ const events = useAsync(() => call('listAlertEvents', {}), [list.value], [])
4817
5038
  const [editing, setEditing] = useState(null)
4818
5039
  const [notice, setNotice] = useState(null)
4819
5040
  const act = async (fn, ok) => { setNotice(null); try { const r = await fn(); if (ok) setNotice({ ok: true, text: typeof ok === 'function' ? ok(r) : ok }) } catch (e) { setNotice({ ok: false, text: e.message }) } list.reload() }
4820
5041
  const alerts = list.value ?? []
4821
- const sched = useAsync(() => call('schedulerStatus'), [list.value])
5042
+ const sched = useAsync(() => call('schedulerStatus'), [list.value], [])
4822
5043
  const condText = (a) => a.condition.type === 'threshold' ? `${a.condition.op} ${a.condition.value}` : a.condition.type === 'change' ? `${t(`al.dir.${a.condition.direction ?? 'any'}`)} ≥ ${a.condition.pct}%` : a.condition.type === 'anomaly' ? `Z ≥ ${a.condition.z ?? 3}` : t('al.cond.missing')
4823
5044
  return h('div', null,
4824
5045
  h(SectionHead, { title: t('tab.alerts'), desc: t('al.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !ds, onClick: () => setEditing({}) }, '+ ', t('al.add'))),
@@ -4869,7 +5090,7 @@ window.__ModuleLoader__.load({
4869
5090
  const id = setInterval(() => { runs.reload(); set.reload() }, 3000)
4870
5091
  return () => clearInterval(id)
4871
5092
  }, [latest?.status, latest?.id])
4872
- const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done])
5093
+ const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done], [open])
4873
5094
  const start = async () => { setError(null); try { await call('runEval', { datasourceId: ds.id, mode }); runs.reload() } catch (e) { setError(e.message) } }
4874
5095
  const cases = set.value ?? []
4875
5096
  return h('div', null,
@@ -4894,7 +5115,7 @@ window.__ModuleLoader__.load({
4894
5115
  c.status !== 'pass' && c.actualSql && h('details', { style: { marginTop: 6 } }, h('summary', { className: 'n2s-small n2s-muted', style: { cursor: 'pointer' } }, t('ev.compareSql')),
4895
5116
  h('div', { className: 'n2s-form', style: { marginTop: 6 } }, h('pre', { className: 'n2s-code' }, prettySql(c.expectedSql)), h('pre', { className: 'n2s-code' }, prettySql(c.actualSql))))))),
4896
5117
  !open && cases.length > 0 && h('div', { className: 'n2s-list2', style: { marginTop: 12 } }, cases.map(c => h('div', { key: c.id, className: 'n2s-list2-row', style: { padding: '8px 16px' } },
4897
- h('input', { type: 'checkbox', checked: c.enabled, 'aria-label': t('ev.include'), onChange: async (e) => { await call('setEvalCase', { datasourceId: ds.id, id: c.id, enabled: e.target.checked }); set.reload() } }),
5118
+ h('input', { type: 'checkbox', checked: c.enabled, 'aria-label': t('ev.include'), onChange: async (e) => { try { await call('setEvalCase', { datasourceId: ds.id, id: c.id, enabled: e.target.checked }) } catch (err) { notifyError(err) } set.reload() } }),
4898
5119
  h('span', { className: 'n2s-grow n2s-ellipsis' }, c.question),
4899
5120
  c.lastStatus && h('span', { className: `n2s-statusdot ${c.lastStatus === 'pass' ? 'ok' : c.lastStatus === 'fail' ? 'warn' : 'err'}` }, t(`ev.status.${c.lastStatus}`))))))
4900
5121
  }
@@ -4902,16 +5123,16 @@ window.__ModuleLoader__.load({
4902
5123
  // ---------------------------------------------------- schema changes
4903
5124
 
4904
5125
  function SchemaChanges({ t, ds }) {
4905
- const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
5126
+ const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
4906
5127
  const open = (list.value ?? []).filter(c => c.status === 'open')
4907
5128
  if (!open.length) return null
4908
5129
  const kindLabel = (k) => t(`sc.${k}`)
4909
5130
  return h('div', { className: 'n2s-notice warn', style: { marginBottom: 14 } },
4910
5131
  open.slice(0, 3).map(c => h('div', { key: c.id, style: { marginBottom: 6 } },
4911
5132
  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' }),
4912
- h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id }); list.reload() } }, t('sc.ack'))),
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(';')),
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' ? '、' : ', ')))))
5133
+ h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { try { await call('ackSchemaChange', { id: c.id }) } catch (e) { notifyError(e) } list.reload() } }, t('sc.ack'))),
5134
+ h('div', { className: 'n2s-small', style: { marginTop: 4 } }, joinClauses(c.changes.slice(0, 8).map(x => `${kindLabel(x.kind)} ${x.table}${x.column ? `.${x.column}` : ''}${paren(x.detail)}`))),
5135
+ c.impacts.length > 0 && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, h('strong', null, `${colon(t('sc.impacts'))}`), joinList(c.impacts.slice(0, 8).map(i => `${t(`sc.imp.${i.kind}`)}${uiLang === 'zh' ? '' : ' '}${quoted(i.name)}`))))))
4915
5136
  }
4916
5137
 
4917
5138
  // -------------------------------------------------- knowledge io
@@ -4919,9 +5140,26 @@ window.__ModuleLoader__.load({
4919
5140
  function KnowledgeIO({ t, ds, datasources, onImported }) {
4920
5141
  const fileRef = useRef(null)
4921
5142
  const [state, setState] = useState(null)
5143
+ const box = useRef(null)
5144
+ useFocusTrap(box, Boolean(state))
5145
+ // A dry run belongs to the data source it was computed for.
5146
+ useEffect(() => { setState(null) }, [ds?.id])
5147
+ useEffect(() => {
5148
+ if (!state) return
5149
+ const prev = document.activeElement
5150
+ setTimeout(() => box.current?.querySelector('button.primary, button')?.focus(), 0)
5151
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) setState(null) }
5152
+ document.addEventListener('keydown', onKey)
5153
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
5154
+ }, [Boolean(state)])
5155
+ const [busy, runBusy] = useBusy()
5156
+ const busyRef = useRef(false)
5157
+ busyRef.current = busy
4922
5158
  const doExport = async (all) => {
4923
- const data = await call('exportKnowledge', all ? {} : { datasourceId: ds.id })
4924
- download(`knowledge-${all ? 'all' : safeName(ds.name)}-${new Date().toISOString().slice(0, 10)}.json`, new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }))
5159
+ try {
5160
+ const data = await call('exportKnowledge', all ? {} : { datasourceId: ds.id })
5161
+ download(`knowledge-${all ? 'all' : safeName(ds.name)}-${new Date().toISOString().slice(0, 10)}.json`, new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }))
5162
+ } catch (e) { notifyError(e) }
4925
5163
  }
4926
5164
  const onFile = async (e) => {
4927
5165
  const f = e.target.files?.[0]
@@ -4931,13 +5169,14 @@ window.__ModuleLoader__.load({
4931
5169
  const text = await f.text()
4932
5170
  const doc = JSON.parse(text)
4933
5171
  const single = doc.datasources?.length === 1
4934
- const dry = await call('importKnowledge', { data: doc, targetDatasourceId: single ? ds.id : undefined, dryRun: true })
4935
- setState({ doc, single, dry })
5172
+ const dsId = ds.id
5173
+ const dry = await call('importKnowledge', { data: doc, targetDatasourceId: single ? dsId : undefined, dryRun: true })
5174
+ setState({ doc, single, dry, dsId, dsName: ds.name })
4936
5175
  } catch (err) { setState({ error: err.message }) }
4937
5176
  }
4938
- const apply = async (mode) => {
4939
- try { const r = await call('importKnowledge', { data: state.doc, targetDatasourceId: state.single ? ds.id : undefined, mode }); setState({ done: r }); onImported() } catch (err) { setState({ error: err.message }) }
4940
- }
5177
+ const apply = (mode) => runBusy(async () => {
5178
+ try { const r = await call('importKnowledge', { data: state.doc, targetDatasourceId: state.single ? state.dsId : undefined, mode }); setState({ done: r }); onImported() } catch (err) { setState({ error: err.message }) }
5179
+ })
4941
5180
  return h(React.Fragment, null,
4942
5181
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile }),
4943
5182
  h(Dropdown, { label: t('kb.io'), trigger: { className: 'n2s-btn', content: h(React.Fragment, null, t('kb.io'), Icon.chev(10)) }, items: [
@@ -4947,17 +5186,17 @@ window.__ModuleLoader__.load({
4947
5186
  { label: t('kb.import'), icon: Icon.up(14), onClick: () => fileRef.current?.click() },
4948
5187
  ] }),
4949
5188
  state && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setState(null) } },
4950
- h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true },
5189
+ h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('kb.import') },
4951
5190
  h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('kb.import')),
4952
5191
  state.error && h('div', { className: 'n2s-notice err' }, state.error),
4953
5192
  state.dry && h(React.Fragment, null,
4954
- h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name: ds.name }) : t('kb.importByName')),
5193
+ h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name: state.dsName }) : t('kb.importByName')),
4955
5194
  h('table', { className: 'n2s-table' }, h('thead', null, h('tr', null, [t('kb.src'), t('kb.target'), t('kb.glossary'), t('kb.metrics'), t('kb.notes'), t('kb.examples'), t('kb.invalid')].map((x, i) => h('th', { key: i }, x)))),
4956
5195
  h('tbody', null, state.dry.report.map((r, i) => h('tr', { key: i }, h('td', null, r.source), h('td', null, r.target ?? h('span', { style: { color: 'var(--n2s-err)' } }, r.skipped)), h('td', null, r.glossary ?? '—'), h('td', null, r.metrics ?? '—'), h('td', null, r.notes ?? '—'), h('td', null, r.examples ?? '—'), h('td', null, r.invalid ?? '—'))))),
4957
5196
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
4958
5197
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setState(null) }, t('ds.cancel')),
4959
- h('button', { type: 'button', className: 'n2s-btn danger', onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
4960
- h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => apply('merge') }, t('kb.merge')))),
5198
+ h('button', { type: 'button', className: 'n2s-btn danger', disabled: busy, onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
5199
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => apply('merge') }, busy ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge')))),
4961
5200
  state.done && h(React.Fragment, null, h('div', { className: 'n2s-notice ok' }, t('kb.imported')), h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setState(null) }, t('ds.close')))))), document.body))
4962
5201
  }
4963
5202
 
@@ -4980,7 +5219,7 @@ window.__ModuleLoader__.load({
4980
5219
  if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
4981
5220
  if (s.kind === 'metric') return h('div', null,
4982
5221
  h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
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(' · ')))
5222
+ (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')}: ${joinList(it.dimensions)}`].filter(Boolean).join(' · ')))
4984
5223
  if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
4985
5224
  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))
4986
5225
  }
@@ -4998,14 +5237,17 @@ window.__ModuleLoader__.load({
4998
5237
  const [busy, setBusy] = useState(null)
4999
5238
  const items = list.value?.suggestions ?? []
5000
5239
  const done = () => { list.reload(); onChanged?.() }
5240
+ const inFlight = useRef(new Set())
5001
5241
  const accept = async (s, item) => {
5242
+ if (inFlight.current.has(s.id)) return
5243
+ inFlight.current.add(s.id)
5002
5244
  setBusy(s.id)
5003
5245
  try {
5004
5246
  await call('acceptSuggestion', { datasourceId: ds.id, id: s.id, ...(item ? { item } : {}) })
5005
5247
  setEditing(null)
5006
5248
  notify(t('sg.accepted'), 'ok')
5007
5249
  done()
5008
- } catch (e) { notifyError(e) } finally { setBusy(null) }
5250
+ } catch (e) { notifyError(e) } finally { inFlight.current.delete(s.id); setBusy(null) }
5009
5251
  }
5010
5252
  const dismiss = async (s) => {
5011
5253
  if (!await confirmDialog({ title: t('sg.dismissConfirm'), confirmLabel: t('sg.dismiss') })) return
@@ -5028,8 +5270,8 @@ window.__ModuleLoader__.load({
5028
5270
  h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
5029
5271
  h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy === s.id, onClick: () => setEditing(s) }, t('sg.accept')),
5030
5272
  h(Dropdown, { label: t('sg.acceptDirect'), trigger: { className: 'n2s-iconbtn', content: Icon.chev(10) }, items: [
5031
- { label: t('sg.acceptEdit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
5032
- { label: t('sg.acceptDirect'), icon: Icon.check(14), onClick: () => accept(s) },
5273
+ { label: t('sg.acceptEdit'), icon: Icon.edit(14), disabled: busy === s.id, onClick: () => setEditing(s) },
5274
+ { label: t('sg.acceptDirect'), icon: Icon.check(14), disabled: busy === s.id, onClick: () => accept(s) },
5033
5275
  ] }),
5034
5276
  h('button', { type: 'button', className: 'n2s-btn small ghost', disabled: busy === s.id, onClick: () => dismiss(s) }, t('sg.dismiss')))),
5035
5277
  editing?.id === s.id && h('div', { style: { marginTop: 10 } },
@@ -5118,10 +5360,10 @@ window.__ModuleLoader__.load({
5118
5360
  seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
5119
5361
  items.length === 0
5120
5362
  ? 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 => {
5363
+ : h('div', { className: 'n2s-pickchips', role: 'group', 'aria-label': kind === 'chat' ? t('mb.pick.chats') : t('mb.pick.people') }, items.slice(0, 30).map(x => {
5122
5364
  const taken = x.memberId && !isChosen(x)
5123
5365
  return h('button', {
5124
- key: `${x.platform}:${x.id}`, type: 'button', role: 'listitem', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
5366
+ key: `${x.platform}:${x.id}`, type: 'button', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
5125
5367
  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
5368
  onClick: () => onPick(x),
5127
5369
  },
@@ -5299,8 +5541,8 @@ window.__ModuleLoader__.load({
5299
5541
  h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
5300
5542
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5301
5543
  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'))),
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'))),
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(';')}`)),
5544
+ 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(listSep()) || t('mb.noDs'))),
5545
+ (u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${colon(t('mb.rowRules'))}${joinClauses(u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`))}`)),
5304
5546
  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())),
5305
5547
  h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
5306
5548
  { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
@@ -5318,7 +5560,7 @@ window.__ModuleLoader__.load({
5318
5560
  // ---------------------------------------------------- dashboards bar
5319
5561
 
5320
5562
  function DashboardSwitcher({ t, value, onChange }) {
5321
- const list = useAsync(() => call('listDashboards'), [value?.id])
5563
+ const list = useAsync(() => call('listDashboards'), [value?.id], [])
5322
5564
  const v = list.value
5323
5565
  if (!v) return null
5324
5566
  const cur = v.dashboards.find(d => d.id === v.current)
@@ -5327,7 +5569,7 @@ window.__ModuleLoader__.load({
5327
5569
  v.dashboards.map(d => h('button', { key: d.id, type: 'button', role: 'tab', 'aria-selected': d.id === v.current, onClick: () => act(() => call('selectDashboard', { id: d.id })) }, d.name, h('span', { className: 'count' }, d.widgets))),
5328
5570
  h(Dropdown, { label: t('dash.manage'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, '+', Icon.chev(10)) }, items: [
5329
5571
  { label: t('dash.new'), icon: Icon.chart(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName') }); if (name) act(() => call('saveDashboard', { name })) } },
5330
- { label: t('dash.duplicate'), icon: Icon.copy(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName'), value: `${cur?.name ?? ''} 副本` }); if (name) act(() => call('saveDashboard', { name, copyFrom: v.current })) } },
5572
+ { label: t('dash.duplicate'), icon: Icon.copy(14), onClick: async () => { const name = await promptDialog({ title: t('dash.newName'), value: fmt(t, 'dash.copyName', { name: cur?.name ?? '' }) }); if (name) act(() => call('saveDashboard', { name, copyFrom: v.current })) } },
5331
5573
  { label: t('dash.renameBoard'), icon: Icon.edit(14), onClick: async () => { const name = await promptDialog({ title: t('dash.renameBoard'), value: cur?.name }); if (name) act(() => call('saveDashboard', { id: v.current, name })) } },
5332
5574
  { divider: true },
5333
5575
  v.dashboards.length > 1 && { label: t('dash.deleteBoard'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'dash.deleteBoardConfirm', { name: cur?.name }), danger: true })) act(() => call('deleteDashboard', { id: v.current })) } },
@@ -5339,7 +5581,7 @@ window.__ModuleLoader__.load({
5339
5581
  const v = st.value
5340
5582
  return h('div', { className: 'n2s-row' },
5341
5583
  h('span', { className: 'n2s-small n2s-muted' }, v ? fmt(t, 'settings.cacheStatsText', { results: v.results, mb: (v.bytes / 1048576).toFixed(1), cache: v.cacheEntries }) : '…'),
5342
- h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: async () => { await call('clearCache'); st.reload() } }, t('settings.clearCache')))
5584
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: async () => { try { await call('clearCache') } catch (e) { notifyError(e) } st.reload() } }, t('settings.clearCache')))
5343
5585
  }
5344
5586
 
5345
5587
  function ChannelSelect({ t, value, onChange }) {
@@ -5347,8 +5589,25 @@ window.__ModuleLoader__.load({
5347
5589
  }
5348
5590
 
5349
5591
  // Questions started from the page bind their new session to the chosen
5350
- // data source as soon as the session appears in the composer.
5592
+ // data source as soon as the session appears in the composer. Only the
5593
+ // session our own startSession created may claim an ask: the one whose id
5594
+ // startSession returned (when the host returns it), otherwise a session the
5595
+ // composer had not seen before the ask was made, within PENDING_ASK_MS.
5351
5596
  const pendingAsks = []
5597
+ const seenSessions = new Set()
5598
+ const PENDING_ASK_MS = 60000
5599
+ function addPendingAsk(datasourceId, question) {
5600
+ const pending = { datasourceId, question, at: Date.now(), seen: new Set(seenSessions), sessionId: null }
5601
+ pendingAsks.push(pending)
5602
+ setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, PENDING_ASK_MS)
5603
+ return pending
5604
+ }
5605
+ /** Remove and return the pending ask that belongs to this session, if any. */
5606
+ function claimPendingAsk(sessionId) {
5607
+ const now = Date.now()
5608
+ const i = pendingAsks.findIndex(p => now - p.at < PENDING_ASK_MS && (p.sessionId ? p.sessionId === sessionId : !p.seen.has(sessionId)))
5609
+ return i >= 0 ? pendingAsks.splice(i, 1)[0] : null
5610
+ }
5352
5611
 
5353
5612
  // ------------------------------------------------ composer selector
5354
5613
 
@@ -5365,8 +5624,15 @@ window.__ModuleLoader__.load({
5365
5624
  if (!sessionId) return
5366
5625
  let cancelled = false
5367
5626
  ;(async () => {
5368
- const pending = pendingAsks.shift()
5369
- if (pending) await call('bindSession', { sessionId, datasourceId: pending.datasourceId }).catch(() => {})
5627
+ const fresh = pendingAsks.length > 0 && !seenSessions.has(sessionId)
5628
+ seenSessions.add(sessionId)
5629
+ if (fresh) {
5630
+ // A session already bound to a data source, or running another preset, is not ours.
5631
+ const info = await call('sessionInfo', { sessionId }).catch(() => null)
5632
+ const candidate = info && !info.boundDatasourceId && (info.preset === PRESET || info.preset === null || info.preset === undefined)
5633
+ const pending = candidate ? claimPendingAsk(sessionId) : null
5634
+ if (pending) await call('bindSession', { sessionId, datasourceId: pending.datasourceId }).catch(() => {})
5635
+ }
5370
5636
  if (!cancelled) await loadSessionInfo(sessionId)
5371
5637
  })()
5372
5638
  if (!store.get().loaded) refreshDatasources()
@@ -5518,7 +5784,7 @@ window.__ModuleLoader__.load({
5518
5784
  }
5519
5785
 
5520
5786
  function AdminArea({ t, ctx, section, sub, onNavigate, ds, datasources }) {
5521
- const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section])
5787
+ const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section], [])
5522
5788
  const [alerts, reports, channels, users] = counts.value ?? []
5523
5789
  const badge = { sources: datasources.length, members: users?.length, automation: (alerts?.length ?? 0) + (reports?.length ?? 0), push: channels?.length }
5524
5790
  const firing = (alerts ?? []).filter(a => a.state === 'firing').length
@@ -5550,32 +5816,49 @@ window.__ModuleLoader__.load({
5550
5816
  const { datasources, loaded, error } = useStore(s => s)
5551
5817
  const [nav, setNavState] = useState(loadNav)
5552
5818
  const setNav = useCallback((patch) => setNavState(n => { const next = { ...n, ...patch }; try { localStorage.setItem(NAV_KEY, JSON.stringify(next)) } catch {} return next }), [])
5819
+ // Leaving 管理 → 设置 with unsaved settings asks first (SettingsPage sets fbCtx.settingsDirty).
5820
+ const navRef = useRef(nav)
5821
+ navRef.current = nav
5822
+ const guardedNav = useCallback(async (patch) => {
5823
+ const cur = navRef.current
5824
+ const next = { ...cur, ...patch }
5825
+ const inSettings = cur.tab === 'admin' && cur.section === 'settings'
5826
+ const leaving = inSettings && (next.tab !== 'admin' || next.section !== 'settings')
5827
+ if (leaving && fbCtx.settingsDirty) {
5828
+ const ok = await confirmDialog({ title: t('set.leaveTitle'), message: t('set.leaveMsg'), confirmLabel: t('set.leaveOk'), danger: true })
5829
+ if (!ok) return false
5830
+ fbCtx.settingsDirty = false
5831
+ }
5832
+ setNav(patch)
5833
+ return true
5834
+ }, [])
5553
5835
  const [selectedId, setSelectedId] = useState(null)
5554
5836
  useEffect(() => { refreshDatasources() }, [])
5555
5837
  const ds = datasources.find(d => d.id === selectedId) ?? datasources.find(d => d.current) ?? datasources[0]
5556
5838
  const needsSource = loaded && datasources.length === 0 && ['schema', 'knowledge'].includes(nav.tab)
5557
5839
  const ask = useCallback((question) => {
5558
5840
  if (!ds) return
5559
- const pending = { datasourceId: ds.id, question }
5560
- pendingAsks.push(pending)
5561
- setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, 60000)
5841
+ const pending = addPendingAsk(ds.id, question)
5562
5842
  try {
5563
- ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
5843
+ const r = ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
5844
+ // Hosts that return the new session id (directly or as a promise) pin the ask to it.
5845
+ const adopt = (id) => { if (typeof id === 'string' && id) pending.sessionId = id }
5846
+ if (r && typeof r.then === 'function') r.then(adopt, () => {}); else adopt(r)
5564
5847
  } catch (e) {
5848
+ const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1)
5565
5849
  notifyError(e)
5566
5850
  }
5567
5851
  }, [ds?.id])
5568
5852
  fbCtx.openSession = (id) => ctx.uiWorkspace.openSession(id)
5569
- fbCtx.navigate = (patch) => setNav(patch)
5853
+ fbCtx.navigate = (patch) => { guardedNav(patch) }
5570
5854
  const tabs = [...PRIMARY_TABS, 'admin']
5571
5855
  const onTabKey = (e) => {
5572
5856
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
5573
5857
  const i = tabs.indexOf(nav.tab)
5574
5858
  const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]
5575
- setNav({ tab: next })
5576
- setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0)
5859
+ guardedNav({ tab: next }).then(ok => { if (ok) setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0) })
5577
5860
  }
5578
- const tabBtn = (k) => h('button', { key: k, type: 'button', role: 'tab', id: `n2s-tab-${k}`, className: `n2s-tab${k === 'admin' ? ' admin' : ''}`, tabIndex: nav.tab === k ? 0 : -1, 'aria-selected': nav.tab === k, onClick: () => setNav({ tab: k }) },
5861
+ const tabBtn = (k) => h('button', { key: k, type: 'button', role: 'tab', id: `n2s-tab-${k}`, className: `n2s-tab${k === 'admin' ? ' admin' : ''}`, tabIndex: nav.tab === k ? 0 : -1, 'aria-selected': nav.tab === k, onClick: () => { guardedNav({ tab: k }) } },
5579
5862
  k === 'admin' && AdminIcons.settings(), t(`tab.${k}`))
5580
5863
  const status = !ds ? 'off' : ds.lastTestOk === false ? 'err' : ''
5581
5864
  const showPicker = datasources.length > 0 && nav.tab !== 'admin'
@@ -5590,20 +5873,19 @@ window.__ModuleLoader__.load({
5590
5873
  h('span', { className: 'name' }, ds?.name ?? '—'),
5591
5874
  ds && h('span', { className: 'meta' }, typeLabel(t, ds.type)),
5592
5875
  Icon.chev(10),
5593
- h('select', { 'aria-label': t('ds.select'), value: ds?.id ?? '', onChange: async (e) => { const id = e.target.value; setSelectedId(id); await call('setCurrent', { id }).catch(() => {}); refreshDatasources() } },
5876
+ h('select', { 'aria-label': t('ds.select'), value: ds?.id ?? '', onChange: async (e) => { const id = e.target.value; setSelectedId(id); try { await call('setCurrent', { id }) } catch (err) { notifyError(err) } refreshDatasources() } },
5594
5877
  datasources.map(d => h('option', { key: d.id, value: d.id }, `${d.name} · ${typeLabel(t, d.type)}${d.type === 'sqlite' ? '' : ` · ${d.database}`}`))))),
5595
5878
  h('div', { className: 'n2s-tabs', role: 'tablist', 'aria-label': t('page.title'), onKeyDown: onTabKey }, PRIMARY_TABS.map(tabBtn), h('span', { className: 'n2s-grow' }), tabBtn('admin'))),
5596
5879
  h('div', { className: 'n2s-body', role: 'tabpanel', 'aria-labelledby': `n2s-tab-${nav.tab}` },
5597
5880
  error && h('div', { className: 'n2s-notice err', style: { marginBottom: 12 } }, error),
5598
5881
  !loaded ? h('div', { style: { display: 'grid', gap: 12 } }, h('div', { className: 'n2s-skel', style: { height: 120 } }), h('div', { className: 'n2s-skel', style: { height: 60 } })) :
5599
- needsSource ? h(EmptySourceHint, { t, onGo: () => setNav({ tab: 'admin', section: 'sources' }) }) :
5600
- nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate: setNav }) :
5601
- nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment ? `(${tb.comment})` : '' })) }) :
5602
- nav.tab === 'knowledge' ? h(KnowledgeTab, { t, ds }) :
5882
+ needsSource ? h(EmptySourceHint, { t, onGo: () => guardedNav({ tab: 'admin', section: 'sources' }) }) :
5883
+ nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate: guardedNav }) :
5884
+ nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: paren(tb.comment) })) }) :
5885
+ nav.tab === 'knowledge' ? h(KnowledgeTab, { key: `kb:${ds?.id ?? ''}`, t, ds }) :
5603
5886
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
5604
5887
  nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
5605
- h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: setNav, ds, datasources }))),
5606
- h(FeedbackLayer, { t })))
5888
+ h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
5607
5889
  }
5608
5890
  }
5609
5891
 
@@ -5705,7 +5987,7 @@ window.__ModuleLoader__.load({
5705
5987
  form.resultToModel === 'rows' && h(SettingRow, { label: t('settings.modelRows'), hint: t('set.h.modelRows'), changed: changed('modelRows'), htmlFor: 'set-modelRows' }, num('modelRows', { min: 0, max: 500 })),
5706
5988
  h(SettingRow, { label: t('set.l.mask'), hint: t('set.h.maskSensitive'), changed: changed('maskSensitive') }, h('label', { className: 'n2s-switch' }, h('input', { type: 'checkbox', role: 'switch', checked: Boolean(form.maskSensitive), onChange: e => set('maskSensitive', e.target.checked) }), h('span', null, form.maskSensitive ? t('set.on') : t('set.off')))),
5707
5989
  h('div', { className: 'n2s-setnote' }, overrides.length
5708
- ? h(React.Fragment, null, t('set.overrides'), ' ', overrides.map((d, i) => h(React.Fragment, { key: d.id }, i > 0 && '、', h('button', { type: 'button', className: 'n2s-link', onClick: () => onNavigate({ section: 'sources' }) }, d.name))))
5990
+ ? h(React.Fragment, null, t('set.overrides'), ' ', overrides.map((d, i) => h(React.Fragment, { key: d.id }, i > 0 && listSep(), h('button', { type: 'button', className: 'n2s-link', onClick: () => onNavigate({ section: 'sources' }) }, d.name))))
5709
5991
  : t('set.noOverrides'))) :
5710
5992
  group === 'query' ? h(React.Fragment, null,
5711
5993
  h(SettingRow, { label: t('set.l.maxRows'), hint: t('set.h.maxRows'), changed: changed('maxRows'), htmlFor: 'set-maxRows' }, num('maxRows', { min: 1, max: 100000 })),
@@ -5809,6 +6091,17 @@ window.__ModuleLoader__.load({
5809
6091
  const retention = form.retention ?? defaults.retention ?? {}
5810
6092
  const fileRef = useRef(null)
5811
6093
  const [importState, setImportState] = useState(null)
6094
+ const importBox = useRef(null)
6095
+ useFocusTrap(importBox, Boolean(importState))
6096
+ const [importing, runImport] = useBusy()
6097
+ useEffect(() => {
6098
+ if (!importState) return
6099
+ const prev = document.activeElement
6100
+ setTimeout(() => importBox.current?.querySelector('button.primary')?.focus(), 0)
6101
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) setImportState(null) }
6102
+ document.addEventListener('keydown', onKey)
6103
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
6104
+ }, [Boolean(importState)])
5812
6105
  const exportAll = async () => {
5813
6106
  try {
5814
6107
  const doc = await call('exportConfig')
@@ -5826,7 +6119,7 @@ window.__ModuleLoader__.load({
5826
6119
  setImportState({ doc, dry })
5827
6120
  } catch (err) { notifyError(err) }
5828
6121
  }
5829
- const doImport = async (mode) => {
6122
+ const doImport = (mode) => runImport(async () => {
5830
6123
  if (mode === 'replace' && !await confirmDialog({ title: t('set.importReplaceConfirm'), message: t('set.importReplaceMsg'), danger: true })) return
5831
6124
  try {
5832
6125
  const r = await call('importConfig', { data: importState.doc, mode })
@@ -5836,7 +6129,7 @@ window.__ModuleLoader__.load({
5836
6129
  const needs = (r.report?.datasources?.needsSecret ?? r.needsSecret ?? []).length
5837
6130
  notify(needs ? fmt(t, 'set.importedNeedsSecret', { n: needs }) : t('set.imported'), 'ok', 6000)
5838
6131
  } catch (err) { notifyError(err) }
5839
- }
6132
+ })
5840
6133
  const purge = async (kind) => {
5841
6134
  if (!await confirmDialog({ title: fmt(t, 'set.purgeConfirm', { kind: t(`set.rk.${kind}`) }), danger: true })) return
5842
6135
  try { await call('purgeRecords', { kind }); st.reload(); notify(t('set.purged'), 'ok') } catch (e) { notifyError(e) }
@@ -5862,17 +6155,17 @@ window.__ModuleLoader__.load({
5862
6155
  h('button', { type: 'button', className: 'n2s-btn', onClick: () => fileRef.current?.click() }, Icon.up(13), t('set.import')),
5863
6156
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile })),
5864
6157
  importState && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setImportState(null) } },
5865
- h('div', { className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import'), onKeyDown: (e) => { if (e.key === 'Escape') setImportState(null) } },
6158
+ h('div', { ref: importBox, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import') },
5866
6159
  h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('set.import')),
5867
6160
  h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 10 } }, fmt(t, 'set.importFrom', { time: importState.doc.exportedAt ? new Date(importState.doc.exportedAt).toLocaleString() : '?' })),
5868
6161
  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)))),
5869
6162
  h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
5870
6163
  h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
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(';')))))),
6164
+ h('td', { className: 'n2s-muted n2s-small', style: { whiteSpace: 'normal' } }, joinClauses([r.needsSecret?.length ? fmt(t, 'set.needsSecret', { names: joinList(r.needsSecret) }) : '', r.paused ? t('set.reportsPaused') : '', r.skipped ? fmt(t, 'set.skipped', { n: r.skipped }) : ''])))))),
5872
6165
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
5873
6166
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
5874
- h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
5875
- h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => doImport('merge') }, t('kb.merge'))))), document.body))
6167
+ h('button', { type: 'button', className: 'n2s-btn danger', disabled: importing, onClick: () => doImport('replace') }, t('kb.replace')),
6168
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: importing, onClick: () => doImport('merge') }, importing ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge'))))), document.body))
5876
6169
  }
5877
6170
 
5878
6171
  function AboutSection({ t }) {
@@ -5901,6 +6194,9 @@ window.__ModuleLoader__.load({
5901
6194
  }, 'nl2sql: styles')
5902
6195
  ctx.effect(() => ctx.locale.register(NS, { zh, en, 'zh-CN': zh }), 'nl2sql: locale')
5903
6196
  const t = ctx.locale.bind(NS)
6197
+ // One feedback layer (toasts + confirm / prompt dialogs) for the page and every
6198
+ // tool view or reply action in conversations, mounted once outside the page.
6199
+ ctx.effect(() => mountFeedbackLayer(ctx), 'nl2sql: feedback layer')
5904
6200
  const Page = makePage(ctx)
5905
6201
  const PanelIcon = ({ size }) => Icon.db(size ?? 16)
5906
6202
  ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Page))