@hedeqiang/dsh-nl2sql 0.12.1 → 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
@@ -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,7 +3556,7 @@ 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 } }),
@@ -3432,7 +3570,7 @@ window.__ModuleLoader__.load({
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))),
3438
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))),
@@ -3442,7 +3580,7 @@ window.__ModuleLoader__.load({
3442
3580
  h('pre', { className: 'n2s-code', style: { maxHeight: 220, paddingRight: 44 } }, prettySql(r.sql)),
3443
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))),
3444
3582
  h('div', { className: 'n2s-row' },
3445
- 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())),
3446
3584
  r.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onOpenSession(r.sessionId) }, Icon.external(13), t('hist.openSession')),
3447
3585
  h('button', {
3448
3586
  type: 'button', className: 'n2s-btn small',
@@ -3455,6 +3593,15 @@ window.__ModuleLoader__.load({
3455
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 })))))
3456
3594
  })))
3457
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
+ }
3458
3605
  function OverviewGrid({ t, results, pinned }) {
3459
3606
  const [sending, setSending] = useState(false)
3460
3607
  const ok = results.filter(r => !r.error && r.resultId)
@@ -3532,7 +3679,7 @@ window.__ModuleLoader__.load({
3532
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' })])),
3533
3680
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
3534
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)),
3535
- 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')}`)),
3536
3683
  h('div', { className: 'n2s-list2-side' },
3537
3684
  statusDot(r.status),
3538
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' })}` : '')),
@@ -3548,25 +3695,32 @@ window.__ModuleLoader__.load({
3548
3695
  }
3549
3696
  function FeedbackForm({ t, onCancel, onSubmit }) {
3550
3697
  const [comment, setComment] = useState('')
3698
+ const [busy, runBusy] = useBusy()
3551
3699
  const reasons = ['fb.reason.wrongTable', 'fb.reason.wrongMetric', 'fb.reason.wrongFilter', 'fb.reason.wrongTime', 'fb.reason.chart']
3552
3700
  return h('div', { className: 'n2s-card', style: { padding: 10 } },
3553
3701
  h('div', { className: 'n2s-small', style: { marginBottom: 6 } }, t('fb.whatWrong')),
3554
- 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)))),
3555
3703
  h('textarea', { className: 'n2s-textarea', style: { minHeight: 56 }, value: comment, onChange: e => setComment(e.target.value), placeholder: t('fb.commentPh') }),
3556
3704
  h('div', { className: 'n2s-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
3557
3705
  h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: onCancel }, t('ds.cancel')),
3558
- 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'))))
3559
3707
  }
3560
3708
 
3561
3709
  function FeedbackSection({ t, ds }) {
3562
3710
  const [status, setStatus] = useState('open')
3563
- const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status])
3564
- 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])
3565
3713
  const [fixing, setFixing] = useState(null)
3566
3714
  const [error, setError] = useState(null)
3567
- const resolve = async (payload) => {
3715
+ const [busy, runBusy] = useBusy()
3716
+ const resolve = (payload) => runBusy(async () => {
3568
3717
  setError(null)
3569
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()
3570
3724
  }
3571
3725
  const st = stats.value
3572
3726
  return h('div', null,
@@ -3588,13 +3742,13 @@ window.__ModuleLoader__.load({
3588
3742
  h('span', { className: 'n2s-muted n2s-small' }, new Date(f.at).toLocaleString())),
3589
3743
  f.comment && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, f.comment),
3590
3744
  h('pre', { className: 'n2s-code', style: { marginTop: 6 } }, f.correctedSql || f.sql),
3591
- 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}`)),
3592
3746
  h('div', { style: { display: 'grid', gap: 4 } },
3593
3747
  f.sessionId && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => fbCtx.openSession?.(f.sessionId) }, t('hist.openSession')),
3594
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')),
3595
3749
  f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'ignored' }) }, t('fb.ignore')),
3596
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')),
3597
- 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')))),
3598
3752
  fixing?.id === f.id && h('div', { className: 'n2s-ask', style: { marginTop: 10 } },
3599
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 })) })),
3600
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 })) })),
@@ -3640,10 +3794,16 @@ window.__ModuleLoader__.load({
3640
3794
  area('maskedColumns', 'policy.maskedColumns', 'users.phone\\n*.email')),
3641
3795
  h('div', null,
3642
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')),
3643
- 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 => {
3644
3798
  const ok = allowed.includes(tb)
3645
- 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) },
3646
- 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))
3647
3807
  })),
3648
3808
  form.mode !== 'all' && tables.length > 0 && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 6 } }, t('policy.clickHint')))),
3649
3809
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
@@ -3662,8 +3822,16 @@ window.__ModuleLoader__.load({
3662
3822
  const next = patch === null ? null : { kind: 'time', column: time?.column ?? cand.value?.time[0]?.column ?? 'created_at', range: 'last30d', ...time, ...patch }
3663
3823
  onChange([...(next ? [next] : []), ...ins])
3664
3824
  }
3665
- 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
+ }
3666
3833
  const c = cand.value ?? { time: [], dims: [] }
3834
+ const activeN = filters.filter(f => f.kind !== 'in' || f.values.length > 0).length
3667
3835
  return h('div', { className: 'n2s-filterbar', role: 'group', 'aria-label': t('filter.title') },
3668
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' })])),
3669
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') },
@@ -3680,7 +3848,7 @@ window.__ModuleLoader__.load({
3680
3848
  h('span', { className: 'n2s-muted' }, dim?.label ?? f.column),
3681
3849
  dim?.values?.length
3682
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)))
3683
- : 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) }),
3684
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)))
3685
3853
  }),
3686
3854
  c.dims.filter(d => !ins.some(f => f.column === d.column)).length > 0 && h(Dropdown, {
@@ -3689,9 +3857,39 @@ window.__ModuleLoader__.load({
3689
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) ?? []) })),
3690
3858
  }),
3691
3859
  h('span', { className: 'n2s-grow' }),
3692
- 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 })),
3693
3861
  filters.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onChange([]) }, t('filter.clear')))
3694
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
+ }
3695
3893
  // ------------------------------------------------------------ IM push
3696
3894
 
3697
3895
  function makeReplyAction(ctx) {
@@ -4073,6 +4271,11 @@ window.__ModuleLoader__.load({
4073
4271
  }, [])
4074
4272
  const [state, setState] = useState(null)
4075
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)
4076
4279
  useEffect(() => {
4077
4280
  const def = channels.find(c => c.isDefault) ?? channels[0]
4078
4281
  if (def && !form.channelId) setForm(f => ({ ...f, channelId: def.id }))
@@ -4080,11 +4283,14 @@ window.__ModuleLoader__.load({
4080
4283
  useEffect(() => {
4081
4284
  const prev = document.activeElement
4082
4285
  ref.current?.querySelector('select, textarea, input, button')?.focus()
4083
- 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() }
4084
4288
  document.addEventListener('keydown', onKey)
4085
4289
  return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
4086
4290
  }, [])
4087
4291
  const send = async () => {
4292
+ if (busyRef.current) return
4293
+ busyRef.current = true
4088
4294
  setState({ busy: true })
4089
4295
  try {
4090
4296
  const res = initial.dashboard
@@ -4092,7 +4298,7 @@ window.__ModuleLoader__.load({
4092
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 })
4093
4299
  setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
4094
4300
  refreshChannels()
4095
- setTimeout(onClose, 1200)
4301
+ closeTimer.current = setTimeout(onClose, 1200)
4096
4302
  } catch (e) {
4097
4303
  setState({ error: e.message })
4098
4304
  }
@@ -4159,6 +4365,7 @@ window.__ModuleLoader__.load({
4159
4365
  /** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
4160
4366
  function Modal({ label, labelledBy, onClose, width, children, className }) {
4161
4367
  const box = useRef(null)
4368
+ useFocusTrap(box)
4162
4369
  useEffect(() => {
4163
4370
  const prev = document.activeElement
4164
4371
  setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
@@ -4184,13 +4391,13 @@ window.__ModuleLoader__.load({
4184
4391
  h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
4185
4392
  p && h(React.Fragment, null,
4186
4393
  h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
4187
- h('div', { className: 'n2s-kindcards', role: 'list', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
4188
- (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) },
4189
4396
  h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
4190
4397
  k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
4191
4398
  h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
4192
4399
  h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
4193
- 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')))))))))
4194
4401
  }
4195
4402
 
4196
4403
  // ---------------------------------------------------------------- step 2
@@ -4427,7 +4634,7 @@ window.__ModuleLoader__.load({
4427
4634
 
4428
4635
  // ----------------------------------------------------------------- list
4429
4636
 
4430
- 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' })
4431
4638
 
4432
4639
  function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
4433
4640
  const kind = findKind(catalog, c.type)?.kind
@@ -4491,7 +4698,7 @@ window.__ModuleLoader__.load({
4491
4698
  const channels = useChannels()
4492
4699
  const bots = useBots()
4493
4700
  const { catalog } = useCatalog(t)
4494
- const deliveries = useAsync(() => call('listDeliveries'), [channels])
4701
+ const deliveries = useAsync(() => call('listDeliveries'), [channels], [])
4495
4702
  const [editing, setEditing] = useState(null)
4496
4703
  // Pick up host upgrades (catalog, new channel shape) whenever the page opens.
4497
4704
  useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
@@ -4617,7 +4824,12 @@ window.__ModuleLoader__.load({
4617
4824
  useEffect(() => { if (!saved.loading && saved.value) { setFilters(saved.value); setFiltersFor(boardId) } }, [saved.value, saved.loading, boardId])
4618
4825
  const filtersReady = filters !== null && filtersFor === boardId
4619
4826
  const [sendingDash, setSendingDash] = useState(false)
4620
- 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
+ }
4621
4833
  const otherBoards = (boards.value?.dashboards ?? []).filter(d => d.id !== boards.value?.current)
4622
4834
  // Per-widget load status → header summary ("updated 10:42 · 2 from cache").
4623
4835
  const [loads, setLoads] = useState({})
@@ -4680,7 +4892,7 @@ window.__ModuleLoader__.load({
4680
4892
  h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4681
4893
  (mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
4682
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 ?? '' }))))),
4683
- 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'))) })),
4684
4896
  h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
4685
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' }) }) : '',
4686
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,
@@ -4701,7 +4913,7 @@ window.__ModuleLoader__.load({
4701
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'))),
4702
4914
  widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
4703
4915
  filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
4704
- 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,
4705
4917
  onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
4706
4918
  onMove: (delta) => move(w, delta),
4707
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() },
@@ -4756,24 +4968,32 @@ window.__ModuleLoader__.load({
4756
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)}%` } })),
4757
4969
  h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
4758
4970
  h('span', { className: 'n2s-wf-share n2s-faint' }, it.share === null || Math.abs(it.share) > 3 ? '' : `${Math.round(it.share * 100)}%`)))),
4759
- 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())}`))
4760
4972
  }
4761
4973
 
4762
4974
  // ------------------------------------------------------------ alerts
4763
4975
 
4764
4976
  function AlertForm({ t, initial, datasources, ds, onDone, onCancel }) {
4765
4977
  const channels = useChannels()
4766
- 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 ?? {}) } }))
4767
4979
  const k = useAsync(() => (form.datasourceId ? call('getKnowledge', { datasourceId: form.datasourceId }) : null), [form.datasourceId])
4768
4980
  const [error, setError] = useState(null)
4769
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])
4770
4990
  const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
4771
4991
  const setC = (patch) => setForm(f => ({ ...f, condition: { ...f.condition, ...patch } }))
4772
4992
  const metrics = k.value?.metrics ?? []
4773
- const save = async () => {
4993
+ const save = () => runBusy(async () => {
4774
4994
  setError(null)
4775
4995
  try { await call('saveAlert', { ...form, cooldownMin: Number(form.cooldownMin) }); onDone() } catch (e) { setError(e.message) }
4776
- }
4996
+ })
4777
4997
  const tryIt = async () => {
4778
4998
  if (test?.busy) return
4779
4999
  setTest({ busy: true })
@@ -4802,24 +5022,24 @@ window.__ModuleLoader__.load({
4802
5022
  h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
4803
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}`))))),
4804
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') })),
4805
- 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 })) } })),
4806
5026
  h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
4807
- 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}`),
4808
5028
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
4809
5029
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
4810
5030
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
4811
5031
  h('button', { type: 'button', className: 'n2s-btn', disabled: test?.busy, onClick: tryIt }, t('al.test')),
4812
- 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'))))
4813
5033
  }
4814
5034
 
4815
5035
  function AlertsTab({ t, ds, datasources }) {
4816
5036
  const list = useAsync(() => call('listAlerts'), [])
4817
- const events = useAsync(() => call('listAlertEvents', {}), [list.value])
5037
+ const events = useAsync(() => call('listAlertEvents', {}), [list.value], [])
4818
5038
  const [editing, setEditing] = useState(null)
4819
5039
  const [notice, setNotice] = useState(null)
4820
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() }
4821
5041
  const alerts = list.value ?? []
4822
- const sched = useAsync(() => call('schedulerStatus'), [list.value])
5042
+ const sched = useAsync(() => call('schedulerStatus'), [list.value], [])
4823
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')
4824
5044
  return h('div', null,
4825
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'))),
@@ -4870,7 +5090,7 @@ window.__ModuleLoader__.load({
4870
5090
  const id = setInterval(() => { runs.reload(); set.reload() }, 3000)
4871
5091
  return () => clearInterval(id)
4872
5092
  }, [latest?.status, latest?.id])
4873
- 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])
4874
5094
  const start = async () => { setError(null); try { await call('runEval', { datasourceId: ds.id, mode }); runs.reload() } catch (e) { setError(e.message) } }
4875
5095
  const cases = set.value ?? []
4876
5096
  return h('div', null,
@@ -4895,7 +5115,7 @@ window.__ModuleLoader__.load({
4895
5115
  c.status !== 'pass' && c.actualSql && h('details', { style: { marginTop: 6 } }, h('summary', { className: 'n2s-small n2s-muted', style: { cursor: 'pointer' } }, t('ev.compareSql')),
4896
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))))))),
4897
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' } },
4898
- 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() } }),
4899
5119
  h('span', { className: 'n2s-grow n2s-ellipsis' }, c.question),
4900
5120
  c.lastStatus && h('span', { className: `n2s-statusdot ${c.lastStatus === 'pass' ? 'ok' : c.lastStatus === 'fail' ? 'warn' : 'err'}` }, t(`ev.status.${c.lastStatus}`))))))
4901
5121
  }
@@ -4903,16 +5123,16 @@ window.__ModuleLoader__.load({
4903
5123
  // ---------------------------------------------------- schema changes
4904
5124
 
4905
5125
  function SchemaChanges({ t, ds }) {
4906
- 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])
4907
5127
  const open = (list.value ?? []).filter(c => c.status === 'open')
4908
5128
  if (!open.length) return null
4909
5129
  const kindLabel = (k) => t(`sc.${k}`)
4910
5130
  return h('div', { className: 'n2s-notice warn', style: { marginBottom: 14 } },
4911
5131
  open.slice(0, 3).map(c => h('div', { key: c.id, style: { marginBottom: 6 } },
4912
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' }),
4913
- h('button', { type: 'button', className: 'n2s-btn small', onClick: async () => { await call('ackSchemaChange', { id: c.id }); list.reload() } }, t('sc.ack'))),
4914
- 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(';')),
4915
- 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)}`))))))
4916
5136
  }
4917
5137
 
4918
5138
  // -------------------------------------------------- knowledge io
@@ -4920,9 +5140,26 @@ window.__ModuleLoader__.load({
4920
5140
  function KnowledgeIO({ t, ds, datasources, onImported }) {
4921
5141
  const fileRef = useRef(null)
4922
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
4923
5158
  const doExport = async (all) => {
4924
- const data = await call('exportKnowledge', all ? {} : { datasourceId: ds.id })
4925
- 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) }
4926
5163
  }
4927
5164
  const onFile = async (e) => {
4928
5165
  const f = e.target.files?.[0]
@@ -4932,13 +5169,14 @@ window.__ModuleLoader__.load({
4932
5169
  const text = await f.text()
4933
5170
  const doc = JSON.parse(text)
4934
5171
  const single = doc.datasources?.length === 1
4935
- const dry = await call('importKnowledge', { data: doc, targetDatasourceId: single ? ds.id : undefined, dryRun: true })
4936
- 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 })
4937
5175
  } catch (err) { setState({ error: err.message }) }
4938
5176
  }
4939
- const apply = async (mode) => {
4940
- 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 }) }
4941
- }
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
+ })
4942
5180
  return h(React.Fragment, null,
4943
5181
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile }),
4944
5182
  h(Dropdown, { label: t('kb.io'), trigger: { className: 'n2s-btn', content: h(React.Fragment, null, t('kb.io'), Icon.chev(10)) }, items: [
@@ -4948,17 +5186,17 @@ window.__ModuleLoader__.load({
4948
5186
  { label: t('kb.import'), icon: Icon.up(14), onClick: () => fileRef.current?.click() },
4949
5187
  ] }),
4950
5188
  state && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setState(null) } },
4951
- 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') },
4952
5190
  h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('kb.import')),
4953
5191
  state.error && h('div', { className: 'n2s-notice err' }, state.error),
4954
5192
  state.dry && h(React.Fragment, null,
4955
- 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')),
4956
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)))),
4957
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 ?? '—'))))),
4958
5196
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
4959
5197
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setState(null) }, t('ds.cancel')),
4960
- h('button', { type: 'button', className: 'n2s-btn danger', onClick: async () => { if (await confirmDialog({ title: t('kb.replaceConfirm'), danger: true })) apply('replace') } }, t('kb.replace')),
4961
- 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')))),
4962
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))
4963
5201
  }
4964
5202
 
@@ -4981,7 +5219,7 @@ window.__ModuleLoader__.load({
4981
5219
  if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
4982
5220
  if (s.kind === 'metric') return h('div', null,
4983
5221
  h('div', { className: 'n2s-mono n2s-small' }, `${it.expression}${it.table ? ` FROM ${it.table}` : ''}${it.filter ? ` WHERE ${it.filter}` : ''}`),
4984
- (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(' · ')))
4985
5223
  if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
4986
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))
4987
5225
  }
@@ -4999,14 +5237,17 @@ window.__ModuleLoader__.load({
4999
5237
  const [busy, setBusy] = useState(null)
5000
5238
  const items = list.value?.suggestions ?? []
5001
5239
  const done = () => { list.reload(); onChanged?.() }
5240
+ const inFlight = useRef(new Set())
5002
5241
  const accept = async (s, item) => {
5242
+ if (inFlight.current.has(s.id)) return
5243
+ inFlight.current.add(s.id)
5003
5244
  setBusy(s.id)
5004
5245
  try {
5005
5246
  await call('acceptSuggestion', { datasourceId: ds.id, id: s.id, ...(item ? { item } : {}) })
5006
5247
  setEditing(null)
5007
5248
  notify(t('sg.accepted'), 'ok')
5008
5249
  done()
5009
- } catch (e) { notifyError(e) } finally { setBusy(null) }
5250
+ } catch (e) { notifyError(e) } finally { inFlight.current.delete(s.id); setBusy(null) }
5010
5251
  }
5011
5252
  const dismiss = async (s) => {
5012
5253
  if (!await confirmDialog({ title: t('sg.dismissConfirm'), confirmLabel: t('sg.dismiss') })) return
@@ -5029,8 +5270,8 @@ window.__ModuleLoader__.load({
5029
5270
  h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
5030
5271
  h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy === s.id, onClick: () => setEditing(s) }, t('sg.accept')),
5031
5272
  h(Dropdown, { label: t('sg.acceptDirect'), trigger: { className: 'n2s-iconbtn', content: Icon.chev(10) }, items: [
5032
- { label: t('sg.acceptEdit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
5033
- { 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) },
5034
5275
  ] }),
5035
5276
  h('button', { type: 'button', className: 'n2s-btn small ghost', disabled: busy === s.id, onClick: () => dismiss(s) }, t('sg.dismiss')))),
5036
5277
  editing?.id === s.id && h('div', { style: { marginTop: 10 } },
@@ -5119,10 +5360,10 @@ window.__ModuleLoader__.load({
5119
5360
  seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
5120
5361
  items.length === 0
5121
5362
  ? h('div', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.pick.noChats') : t('mb.pick.noPeople'))
5122
- : 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 => {
5123
5364
  const taken = x.memberId && !isChosen(x)
5124
5365
  return h('button', {
5125
- 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),
5126
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'),
5127
5368
  onClick: () => onPick(x),
5128
5369
  },
@@ -5300,8 +5541,8 @@ window.__ModuleLoader__.load({
5300
5541
  h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
5301
5542
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5302
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'))),
5303
- 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'))),
5304
- (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(', ')}}`))}`)),
5305
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())),
5306
5547
  h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
5307
5548
  { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
@@ -5319,7 +5560,7 @@ window.__ModuleLoader__.load({
5319
5560
  // ---------------------------------------------------- dashboards bar
5320
5561
 
5321
5562
  function DashboardSwitcher({ t, value, onChange }) {
5322
- const list = useAsync(() => call('listDashboards'), [value?.id])
5563
+ const list = useAsync(() => call('listDashboards'), [value?.id], [])
5323
5564
  const v = list.value
5324
5565
  if (!v) return null
5325
5566
  const cur = v.dashboards.find(d => d.id === v.current)
@@ -5328,7 +5569,7 @@ window.__ModuleLoader__.load({
5328
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))),
5329
5570
  h(Dropdown, { label: t('dash.manage'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, '+', Icon.chev(10)) }, items: [
5330
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 })) } },
5331
- { 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 })) } },
5332
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 })) } },
5333
5574
  { divider: true },
5334
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 })) } },
@@ -5340,7 +5581,7 @@ window.__ModuleLoader__.load({
5340
5581
  const v = st.value
5341
5582
  return h('div', { className: 'n2s-row' },
5342
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 }) : '…'),
5343
- 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')))
5344
5585
  }
5345
5586
 
5346
5587
  function ChannelSelect({ t, value, onChange }) {
@@ -5348,8 +5589,25 @@ window.__ModuleLoader__.load({
5348
5589
  }
5349
5590
 
5350
5591
  // Questions started from the page bind their new session to the chosen
5351
- // 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.
5352
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
+ }
5353
5611
 
5354
5612
  // ------------------------------------------------ composer selector
5355
5613
 
@@ -5366,8 +5624,15 @@ window.__ModuleLoader__.load({
5366
5624
  if (!sessionId) return
5367
5625
  let cancelled = false
5368
5626
  ;(async () => {
5369
- const pending = pendingAsks.shift()
5370
- 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
+ }
5371
5636
  if (!cancelled) await loadSessionInfo(sessionId)
5372
5637
  })()
5373
5638
  if (!store.get().loaded) refreshDatasources()
@@ -5519,7 +5784,7 @@ window.__ModuleLoader__.load({
5519
5784
  }
5520
5785
 
5521
5786
  function AdminArea({ t, ctx, section, sub, onNavigate, ds, datasources }) {
5522
- 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], [])
5523
5788
  const [alerts, reports, channels, users] = counts.value ?? []
5524
5789
  const badge = { sources: datasources.length, members: users?.length, automation: (alerts?.length ?? 0) + (reports?.length ?? 0), push: channels?.length }
5525
5790
  const firing = (alerts ?? []).filter(a => a.state === 'firing').length
@@ -5551,32 +5816,49 @@ window.__ModuleLoader__.load({
5551
5816
  const { datasources, loaded, error } = useStore(s => s)
5552
5817
  const [nav, setNavState] = useState(loadNav)
5553
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
+ }, [])
5554
5835
  const [selectedId, setSelectedId] = useState(null)
5555
5836
  useEffect(() => { refreshDatasources() }, [])
5556
5837
  const ds = datasources.find(d => d.id === selectedId) ?? datasources.find(d => d.current) ?? datasources[0]
5557
5838
  const needsSource = loaded && datasources.length === 0 && ['schema', 'knowledge'].includes(nav.tab)
5558
5839
  const ask = useCallback((question) => {
5559
5840
  if (!ds) return
5560
- const pending = { datasourceId: ds.id, question }
5561
- pendingAsks.push(pending)
5562
- setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, 60000)
5841
+ const pending = addPendingAsk(ds.id, question)
5563
5842
  try {
5564
- 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)
5565
5847
  } catch (e) {
5848
+ const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1)
5566
5849
  notifyError(e)
5567
5850
  }
5568
5851
  }, [ds?.id])
5569
5852
  fbCtx.openSession = (id) => ctx.uiWorkspace.openSession(id)
5570
- fbCtx.navigate = (patch) => setNav(patch)
5853
+ fbCtx.navigate = (patch) => { guardedNav(patch) }
5571
5854
  const tabs = [...PRIMARY_TABS, 'admin']
5572
5855
  const onTabKey = (e) => {
5573
5856
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
5574
5857
  const i = tabs.indexOf(nav.tab)
5575
5858
  const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]
5576
- setNav({ tab: next })
5577
- setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0)
5859
+ guardedNav({ tab: next }).then(ok => { if (ok) setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0) })
5578
5860
  }
5579
- 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 }) } },
5580
5862
  k === 'admin' && AdminIcons.settings(), t(`tab.${k}`))
5581
5863
  const status = !ds ? 'off' : ds.lastTestOk === false ? 'err' : ''
5582
5864
  const showPicker = datasources.length > 0 && nav.tab !== 'admin'
@@ -5591,20 +5873,19 @@ window.__ModuleLoader__.load({
5591
5873
  h('span', { className: 'name' }, ds?.name ?? '—'),
5592
5874
  ds && h('span', { className: 'meta' }, typeLabel(t, ds.type)),
5593
5875
  Icon.chev(10),
5594
- 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() } },
5595
5877
  datasources.map(d => h('option', { key: d.id, value: d.id }, `${d.name} · ${typeLabel(t, d.type)}${d.type === 'sqlite' ? '' : ` · ${d.database}`}`))))),
5596
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'))),
5597
5879
  h('div', { className: 'n2s-body', role: 'tabpanel', 'aria-labelledby': `n2s-tab-${nav.tab}` },
5598
5880
  error && h('div', { className: 'n2s-notice err', style: { marginBottom: 12 } }, error),
5599
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 } })) :
5600
- needsSource ? h(EmptySourceHint, { t, onGo: () => setNav({ tab: 'admin', section: 'sources' }) }) :
5601
- nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate: setNav }) :
5602
- nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment ? `(${tb.comment})` : '' })) }) :
5603
- 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 }) :
5604
5886
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
5605
5887
  nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
5606
- h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: setNav, ds, datasources }))),
5607
- h(FeedbackLayer, { t })))
5888
+ h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
5608
5889
  }
5609
5890
  }
5610
5891
 
@@ -5706,7 +5987,7 @@ window.__ModuleLoader__.load({
5706
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 })),
5707
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')))),
5708
5989
  h('div', { className: 'n2s-setnote' }, overrides.length
5709
- ? 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))))
5710
5991
  : t('set.noOverrides'))) :
5711
5992
  group === 'query' ? h(React.Fragment, null,
5712
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 })),
@@ -5810,6 +6091,17 @@ window.__ModuleLoader__.load({
5810
6091
  const retention = form.retention ?? defaults.retention ?? {}
5811
6092
  const fileRef = useRef(null)
5812
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)])
5813
6105
  const exportAll = async () => {
5814
6106
  try {
5815
6107
  const doc = await call('exportConfig')
@@ -5827,7 +6119,7 @@ window.__ModuleLoader__.load({
5827
6119
  setImportState({ doc, dry })
5828
6120
  } catch (err) { notifyError(err) }
5829
6121
  }
5830
- const doImport = async (mode) => {
6122
+ const doImport = (mode) => runImport(async () => {
5831
6123
  if (mode === 'replace' && !await confirmDialog({ title: t('set.importReplaceConfirm'), message: t('set.importReplaceMsg'), danger: true })) return
5832
6124
  try {
5833
6125
  const r = await call('importConfig', { data: importState.doc, mode })
@@ -5837,7 +6129,7 @@ window.__ModuleLoader__.load({
5837
6129
  const needs = (r.report?.datasources?.needsSecret ?? r.needsSecret ?? []).length
5838
6130
  notify(needs ? fmt(t, 'set.importedNeedsSecret', { n: needs }) : t('set.imported'), 'ok', 6000)
5839
6131
  } catch (err) { notifyError(err) }
5840
- }
6132
+ })
5841
6133
  const purge = async (kind) => {
5842
6134
  if (!await confirmDialog({ title: fmt(t, 'set.purgeConfirm', { kind: t(`set.rk.${kind}`) }), danger: true })) return
5843
6135
  try { await call('purgeRecords', { kind }); st.reload(); notify(t('set.purged'), 'ok') } catch (e) { notifyError(e) }
@@ -5863,17 +6155,17 @@ window.__ModuleLoader__.load({
5863
6155
  h('button', { type: 'button', className: 'n2s-btn', onClick: () => fileRef.current?.click() }, Icon.up(13), t('set.import')),
5864
6156
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile })),
5865
6157
  importState && ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) setImportState(null) } },
5866
- 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') },
5867
6159
  h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('set.import')),
5868
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() : '?' })),
5869
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)))),
5870
6162
  h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
5871
6163
  h('td', null, t(`set.sec.${k}`)), h('td', null, r.add ?? r.added ?? 0), h('td', null, r.update ?? r.updated ?? 0),
5872
- 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 }) : ''])))))),
5873
6165
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
5874
6166
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: () => setImportState(null) }, t('ds.cancel')),
5875
- h('button', { type: 'button', className: 'n2s-btn danger', onClick: () => doImport('replace') }, t('kb.replace')),
5876
- 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))
5877
6169
  }
5878
6170
 
5879
6171
  function AboutSection({ t }) {
@@ -5902,6 +6194,9 @@ window.__ModuleLoader__.load({
5902
6194
  }, 'nl2sql: styles')
5903
6195
  ctx.effect(() => ctx.locale.register(NS, { zh, en, 'zh-CN': zh }), 'nl2sql: locale')
5904
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')
5905
6200
  const Page = makePage(ctx)
5906
6201
  const PanelIcon = ({ size }) => Icon.db(size ?? 16)
5907
6202
  ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Page))