@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -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
 
@@ -15,6 +17,129 @@ window.__ModuleLoader__.load({
15
17
  // ------------------------------------------------------------ locale
16
18
 
17
19
  const zh = {
20
+ 'admin.group.insight': "洞察与自动化",
21
+ 'admin.group.connect': "集成",
22
+ "fq.title": "数据新鲜度",
23
+ "fq.refresh": "刷新",
24
+ "fq.checkedAt": "检查于 {time}",
25
+ "fq.staleCount": "{n} 张表数据滞后",
26
+ "fq.warnCount": "{n} 张表更新偏慢",
27
+ "fq.allFresh": "数据均为最新",
28
+ "fq.lagMin": "{n} 分钟",
29
+ "fq.lagHour": "{n} 小时",
30
+ "fq.lagDay": "{n} 天",
31
+ "fq.today": "今天 {time}",
32
+ "fq.yesterday": "昨天 {time}",
33
+ "fq.barItem": "{table} 最新数据停在 {when}(滞后 {lag})",
34
+ "fq.barMore": "另有 {n} 张表",
35
+ "fq.st.error": "检查失败",
36
+ "fq.st.empty": "无数据",
37
+ "fq.running": "正在检查数据新鲜度…",
38
+ "fq.failed": "检查失败",
39
+ "fq.noTime": "无时间字段",
40
+ "fq.notAnalyzed": "该数据源尚未分析表结构,请先在「表结构」中分析。",
41
+ "fq.noTables": "没有带时间字段的表。",
42
+ "fq.col.table": "表",
43
+ "fq.col.column": "时间字段",
44
+ "fq.col.latest": "最新数据",
45
+ "fq.col.lag": "滞后",
46
+ "fq.col.threshold": "阈值(小时)",
47
+ "fq.thresholdFor": "{table} 的滞后阈值(小时)",
48
+ "fq.events": "最近的延迟事件",
49
+ "fq.evStale": "数据滞后 {lag}",
50
+ "fq.evRecovered": "已恢复",
51
+ "fq.pushed": "已推送",
52
+ "fq.q.title": "数据质量巡检",
53
+ "fq.q.lastRun": "上次巡检 {time}",
54
+ "fq.q.run": "立即巡检",
55
+ "fq.q.running": "巡检中…",
56
+ "fq.q.empty": "还没有巡检记录,点击「立即巡检」开始。",
57
+ "fq.q.done": "巡检完成,健康分 {score}",
58
+ "fq.q.history": "近期分数",
59
+ "fq.q.score": "健康分",
60
+ "fq.q.rows": "行数",
61
+ "fq.q.issues": "问题",
62
+ "fq.q.noIssues": "未发现问题",
63
+ "fq.more": "还有 {n} 条",
64
+ "fq.less": "收起",
65
+ "fq.admin.nav": "数据健康",
66
+ "fq.admin.title": "数据健康",
67
+ "fq.admin.desc": "监控各表数据是否按时更新,并定期巡检空值、重复键、异常值、未来时间与负值。",
68
+ "fq.admin.nextF": "下次新鲜度检查 {time}",
69
+ "fq.admin.fOff": "新鲜度定时检查已关闭",
70
+ "fq.admin.nextQ": "每天 {time} 质量巡检",
71
+ "fq.admin.qOff": "每日质量巡检已关闭",
72
+ "fq.admin.noDs": "请先添加数据源",
73
+ "fq.set.title": "设置",
74
+ "fq.set.fEnabled": "定时检查数据新鲜度",
75
+ "fq.set.qEnabled": "每日数据质量巡检",
76
+ "fq.set.interval": "新鲜度检查间隔(分钟)",
77
+ "fq.set.time": "质量巡检时间",
78
+ "fq.set.staleHours": "默认滞后阈值(小时)",
79
+ "fq.set.drop": "健康分下降超过(分)时推送",
80
+ "fq.set.fChannel": "数据延迟通知渠道",
81
+ "fq.set.qChannel": "健康分下降通知渠道",
82
+ "fq.set.noPush": "不推送",
83
+ "fq.set.hint": "每张表的阈值可在上方表格中单独设置(0 表示不监控);延迟通知每个滞后周期只推送一次,恢复时再推送一次。",
84
+ "fq.saved": "已保存",
85
+ 'pro.close': '关闭', 'pro.sendNow': '立即推送', 'pro.sent': '已推送', 'pro.deleteConfirm': '删除「{name}」?', 'pro.weekday': '星期',
86
+ 'pro.wd.0': '周日', 'pro.wd.1': '周一', 'pro.wd.2': '周二', 'pro.wd.3': '周三', 'pro.wd.4': '周四', 'pro.wd.5': '周五', 'pro.wd.6': '周六',
87
+ 'pro.fc.menu': '预测未来 {n} 天', 'pro.fc.title': '预测', 'pro.fc.heading': '未来 {n} 期预测', 'pro.fc.running': '正在预测…', 'pro.fc.forecast': '预测', 'pro.fc.band': '95% 区间', 'pro.fc.lower': '下限', 'pro.fc.upper': '上限',
88
+ 'pro.fc.hw': 'Holt-Winters · 周季节性', 'pro.fc.holt': 'Holt 线性趋势', 'pro.fc.horizon': '预测期数', 'pro.fc.nDays': '{n} 期', 'pro.fc.filled': '补齐 {n} 天', 'pro.fc.filledHint': '没有数据的日期按 0 计入',
89
+ 'pro.fc.note': '基于历史数据的统计外推,未考虑促销、节假日等已知事件;阴影为 95% 预测区间。',
90
+ 'pro.goals.title': '目标', 'pro.goals.desc': '为指标设定月 / 季 / 年目标:按已完成天数的日均(区分工作日与周末)预测期末值,并在看板上显示进度。', 'pro.goals.add': '新建目标',
91
+ 'pro.goals.name': '名称', 'pro.goals.namePh': '例如:11 月交易量', 'pro.goals.period': '周期', 'pro.goals.target': '目标值', 'pro.goals.direction': '方向', 'pro.goals.dashboard': '显示在看板', 'pro.goals.anyBoard': '使用同一数据源的看板',
92
+ 'pro.goals.empty': '还没有目标', 'pro.goals.emptyHint': '先在业务知识 → 指标库定义指标,再为它设定目标。', 'pro.goals.actual': '已完成', 'pro.goals.projected': '预计期末', 'pro.goals.proj': '预计 {v}({p})', 'pro.goals.weekdayAware': '按星期估算',
93
+ 'pro.goals.weekly': '目标周报', 'pro.goals.weeklyOn': '每周推送目标进度', 'pro.goals.weeklyHint': '在所选时间把所有目标的进度与预测推送到渠道。',
94
+ 'pro.period.month': '月', 'pro.period.quarter': '季度', 'pro.period.year': '年', 'pro.dir.up': '不低于', 'pro.dir.down': '不高于',
95
+ 'pro.st.on-track': '进度正常', 'pro.st.at-risk': '有风险', 'pro.st.achieved': '已达成', 'pro.st.missed': '未达成', 'pro.st.error': '出错',
96
+ 'pro.dg.title': '每日简报', 'pro.dg.desc': '每天定时对比昨天与近 7 日均值、上周同期,找出明显变化并自动归因,推送一段简短摘要和图表。', 'pro.dg.add': '新建简报',
97
+ 'pro.dg.name': '名称', 'pro.dg.namePh': '例如:交易日报', 'pro.dg.source': '内容来源', 'pro.dg.src.metrics': '指标库指标', 'pro.dg.src.dashboard': '看板中的趋势图', 'pro.dg.metrics': '指标', 'pro.dg.dashboard': '看板',
98
+ 'pro.dg.language': '语言', 'pro.dg.threshold': '变化阈值(%)', 'pro.dg.hint': '昨天的值偏离近 7 日均值超过 2 个标准差,或变化幅度超过阈值时,标记为值得关注并归因到主要维度。',
99
+ 'pro.dg.empty': '还没有每日简报', 'pro.dg.emptyHint': '选择几个核心指标,每天早上自动推送到群里。', 'pro.dg.preview': '预览', 'pro.dg.previewing': '正在生成预览…', 'pro.dg.dailyAt': '每天 {time}',
100
+ "pre.working": "正在生成,请稍候…", "pre.exported": "已导出", "pre.exportPpt": "导出 PPT", "pre.exportImage": "导出长图", "pre.sendImage": "发送长图到 IM", "pre.sendImageHint": "把整个看板渲染成一张长图,发送到飞书应用机器人或 Telegram 机器人渠道。", "pre.noImageChannel": "没有可以发送图片的渠道:请先在「管理 → 渠道」添加飞书应用机器人或 Telegram 机器人。", "pre.channel": "渠道", "pre.send": "发送", "pre.imageSent": "长图已发送到「{channel}」", "pre.mode": "大屏模式", "pre.loading": "加载中…", "pre.lastRefresh": "最后刷新 {time}", "pre.clock": "当前时间", "pre.refresh": "自动刷新", "pre.rotate": "轮播看板", "pre.rotateOff": "不轮播", "pre.everySec": "每 {s} 秒", "pre.prev": "上一个看板(←)", "pre.next": "下一个看板(→)", "pre.exit": "退出大屏模式", "pre.exitHint": "退出(Esc)", "pre.bt.nav": "多维表格同步", "pre.bt.title": "多维表格同步", "pre.bt.desc": "把看板组件或 SQL 的查询结果写入飞书多维表格:缺少的字段自动创建(文本 / 数字 / 日期),可每天定时同步。", "pre.bt.add": "新建同步", "pre.bt.empty": "还没有同步任务", "pre.bt.emptyHint": "例如:每天早上把「按渠道的成交额」同步到运营团队的多维表格。", "pre.bt.name": "名称", "pre.bt.namePh": "如:渠道日报 → 运营多维表格", "pre.bt.channel": "飞书应用(提供 App 凭据)", "pre.bt.noChannel": "请先添加一个飞书应用机器人渠道", "pre.bt.channelMissing": "渠道已删除", "pre.bt.appTokenPh": "粘贴多维表格链接或 app_token", "pre.bt.source": "数据来源", "pre.bt.src.widget": "看板组件", "pre.bt.src.sql": "SQL 查询", "pre.bt.widget": "组件", "pre.bt.mode": "写入方式", "pre.bt.mode.replace": "覆盖", "pre.bt.mode.append": "追加", "pre.bt.modeHint.replace": "每次先删除表中全部记录,再写入最新结果。", "pre.bt.modeHint.append": "每次把结果追加到表末尾,保留已有记录。", "pre.bt.schedule": "定时", "pre.bt.daily": "每天同步", "pre.bt.dailyAt": "每天 {time}", "pre.bt.permHint": "需要在多维表格「… → 更多 → 添加文档应用」中添加该应用(可编辑),并为应用开通 bitable:app 权限。", "pre.bt.run": "立即同步", "pre.bt.ran": "同步完成:写入 {created} 条,删除 {deleted} 条", "pre.bt.lastOk": "成功 · {n} 条", "pre.bt.lastErr": "失败", "pre.bt.never": "未运行", "pre.bt.deleteConfirm": "删除同步任务「{name}」?",
101
+ 'mcp.nav': 'MCP 接入', 'mcp.title': 'MCP 接入', 'mcp.desc': '让 Claude Desktop、Cursor、Cline、VS Code 等 AI 工具通过 Model Context Protocol 使用这里的数据源:只读准入、访问控制、成员范围、行级权限与审计同样生效。',
102
+ 'mcp.enabled': '已启用', 'mcp.disabled': '未启用', 'mcp.running': '运行中', 'mcp.stopped': '未运行', 'mcp.port': '端口', 'mcp.applyPort': '应用',
103
+ 'mcp.endpoint': '端点', 'mcp.localOnly': '服务只监听本机 127.0.0.1,并拒绝非本机的 Origin / Host 请求;每个请求都需要令牌。', 'mcp.copy': '复制', 'mcp.copied': '已复制',
104
+ 'mcp.tokens': '访问令牌', 'mcp.newToken': '新建令牌', 'mcp.tokenName': '名称', 'mcp.tokenNamePh': '例如:我的 Cursor', 'mcp.create': '创建令牌',
105
+ 'mcp.scope': '权限范围', 'mcp.scope.owner': '本机用户(全部权限)', 'mcp.scope.member': '某个成员', 'mcp.scope.role': '角色 + 数据源',
106
+ 'mcp.scopeHint.owner': '与在本页提问相同的权限,仍受访问控制策略约束。', 'mcp.scopeHint.member': '跟随该成员的角色、数据源范围和行级规则;成员被停用后令牌失效。', 'mcp.scopeHint.role': '只能访问所选数据源,可附加一条行级规则。',
107
+ 'mcp.member': '成员', 'mcp.pickMember': '选择成员…', 'mcp.noMembers': '还没有成员,请先在「成员」中添加。', 'mcp.memberMissing': '成员已删除', 'mcp.memberDisabled': '成员已停用',
108
+ 'mcp.role': '角色', 'mcp.role.admin': '管理员', 'mcp.role.analyst': '分析师', 'mcp.role.viewer': '只读', 'mcp.datasources': '数据源', 'mcp.allDs': '全部数据源',
109
+ 'mcp.rowRule': '行级规则(可选)', 'mcp.rowColumn': '字段名', 'mcp.rowValues': '允许的值,逗号分隔', 'mcp.rowRuleHint': '查询含该字段的表时自动加上「字段 IN (值)」过滤。', 'mcp.rowRulesN': '{n} 条行级规则',
110
+ 'mcp.err.name': '请填写令牌名称', 'mcp.err.member': '请选择成员', 'mcp.err.ds': '请至少选择一个数据源',
111
+ 'mcp.freshTitle': '令牌「{name}」已创建', 'mcp.freshHint': '令牌只显示这一次,请立即复制保存。系统只保存它的哈希值,丢失后只能重新创建。', 'mcp.done': '我已保存',
112
+ 'mcp.col.name': '名称', 'mcp.col.scope': '范围', 'mcp.col.created': '创建时间', 'mcp.col.lastUsed': '最近使用', 'mcp.never': '从未',
113
+ 'mcp.revoke': '吊销', 'mcp.revokeConfirm': '吊销令牌「{name}」?', 'mcp.revokeHint': '使用该令牌的客户端将立即无法访问。', 'mcp.revoked': '令牌已吊销',
114
+ 'mcp.noTokens': '还没有令牌', 'mcp.noTokensHint': '创建一个令牌,再把下面的配置填入 AI 工具。',
115
+ 'mcp.setup': '客户端配置', 'mcp.client.claude': 'Claude Desktop', 'mcp.client.cursor': 'Cursor', 'mcp.client.vscode': 'VS Code',
116
+ 'mcp.where.claude': '写入 claude_desktop_config.json(通过 stdio 桥接 npx nl2sql-mcp,需要 Node.js 18+)。', 'mcp.where.cursor': '写入 ~/.cursor/mcp.json 或项目的 .cursor/mcp.json(直接走 HTTP)。', 'mcp.where.vscode': '写入 .vscode/mcp.json(直接走 HTTP)。',
117
+ 'mcp.placeholderHint': '把 <YOUR_TOKEN> 替换为你的令牌。', 'mcp.docsHint': '详细说明见插件文档 docs/mcp.md。使用期间请保持 DeepSeek Harness 运行。',
118
+ 'rc.title': "对账", 'rc.hint': "对比两个数据源(或两条查询)的结果:按键列把行对上,找出只在一侧存在的行和值不一致的列;可每天定时运行并推送差异。", 'rc.add': "新建对账", 'rc.empty': "还没有对账任务", 'rc.emptyHint': "例如:核对订单库与财务库每天的笔数和金额。", 'rc.name': "名称", 'rc.namePh': "如:订单 vs 财务 · 日金额", 'rc.left': "左侧", 'rc.right': "右侧", 'rc.leftCol': "左侧列", 'rc.rightCol': "右侧列(同名可留空)", 'rc.keys': "键列", 'rc.keysHint': "用来把两边的行对上的列(如订单号,或 日期 + 渠道)。", 'rc.addKey': "添加键列", 'rc.compare': "比较列", 'rc.compareHint': "需要一致的值;数值可设置容差(绝对值,或相对比例如 0.001 = 0.1%)。", 'rc.addCompare': "添加比较列", 'rc.tolerance': "容差", 'rc.tolMode': "容差类型", 'rc.tolAbs': "绝对", 'rc.tolRel': "相对", 'rc.trim': "比较文本前去掉首尾空格", 'rc.ignoreCase': "忽略大小写", 'rc.daily': "每天定时运行", 'rc.dailyAt': "每天 {time}", 'rc.manual': "手动", 'rc.noPush': "不推送", 'rc.notifyOn': "推送时机", 'rc.notify.diff': "有差异时", 'rc.notify.always': "每次运行", 'rc.dryRun': "试运行", 'rc.dryResult': "试运行结果(未保存)", 'rc.saved': "已保存对账任务", 'rc.runNow': "立即运行", 'rc.pushed': "已推送", 'rc.history': "运行记录", 'rc.historyOf': "运行记录 · {name}", 'rc.allRuns': "全部任务", 'rc.runOf': "对账结果 · {name}", 'rc.close': "关闭", 'rc.deleteConfirm': "删除对账任务「{name}」?", 'rc.detail': "详情", 'rc.push': "推送", 'rc.scheduled': "定时", 'rc.never': "未运行", 'rc.status.ok': "一致", 'rc.status.diff': "有差异", 'rc.status.error': "失败", 'rc.matchRate': "一致率", 'rc.matched': "匹配的键", 'rc.mismatched': "值不一致的行", 'rc.rowsOf': "{side} 行数", 'rc.onlyIn': "仅{side}", 'rc.totalOf': "{side} 合计", 'rc.col': "比较列", 'rc.mismatchRows': "不一致行", 'rc.diff': "差值", 'rc.hasDiff': "发现差异,一致率 {rate}", 'rc.noDiff': "两边一致,一致率 {rate}", 'rc.incomplete': "结果不完整(已截断)", 'rc.short.onlyLeft': "仅左侧 {n}", 'rc.short.onlyRight': "仅右侧 {n}", 'rc.short.mismatch': "不一致 {n} 行", 'rc.tab.summary': "汇总", 'rc.tab.mismatches': "值不一致", 'rc.tab.only': "仅{side}", 'rc.tool': "对账", 'rc.toolRunning': "正在对账",
119
+ "sub.nav": "群订阅",
120
+ "sub.title": "群订阅",
121
+ "sub.desc": "IM 群里通过机器人创建的定时订阅和告警,按群分组;推送发回原来的群。",
122
+ "sub.refresh": "刷新",
123
+ "sub.empty": "还没有群订阅",
124
+ "sub.emptyHint": "在群里 @机器人 说「每天 9 点把这个发到群里」或「超过 1000 提醒我」即可创建。",
125
+ "sub.col.item": "内容",
126
+ "sub.col.creator": "创建人",
127
+ "sub.col.schedule": "时间",
128
+ "sub.col.last": "最近推送",
129
+ "sub.col.status": "状态",
130
+ "sub.kind.subscription": "订阅",
131
+ "sub.kind.alert": "告警",
132
+ "sub.status.active": "运行中",
133
+ "sub.status.paused": "已暂停",
134
+ "sub.status.suspended": "已停用",
135
+ "sub.next": "下次",
136
+ "sub.pause": "暂停",
137
+ "sub.resume": "恢复",
138
+ "sub.delete": "删除",
139
+ "sub.deleteConfirm": "删除「{name}」?",
140
+ "sub.done.pause": "已暂停",
141
+ "sub.done.resume": "已恢复",
142
+ "sub.done.delete": "已删除",
18
143
  'mb.pick.people': "从和机器人说过话的人中选择",
19
144
  'mb.pick.chats': "从机器人所在的群中选择",
20
145
  'mb.pick.refresh': "刷新",
@@ -1030,8 +1155,143 @@ window.__ModuleLoader__.load({
1030
1155
  'tool.running': '正在查询…',
1031
1156
  'tool.failed': '查询失败',
1032
1157
  'error.generic': '操作失败:{message}',
1158
+ 'dash.copyName': '{name} 副本',
1159
+ 'set.leaveTitle': '放弃未保存的设置?',
1160
+ 'set.leaveMsg': '设置中有未保存的修改,离开后这些修改会丢失。',
1161
+ 'set.leaveOk': '放弃并离开',
1162
+ 'fb.deleteConfirm': '删除这条反馈?',
1163
+ 'hist.source.telegrambot': 'Telegram 机器人',
1164
+ 'hist.source.wecombot': '企业微信机器人',
1165
+ 'hist.source.dingtalkbot': '钉钉机器人',
1166
+ 'hist.source.unknown': '未知来源',
1167
+ 'hist.source.genericBot': '{platform} 机器人',
1168
+ 'filter.valuesPh': '值,用逗号分隔',
1169
+ 'policy.tablesLabel': '可访问的表',
1033
1170
  }
1034
1171
  const en = {
1172
+ 'admin.group.insight': "Insight & automation",
1173
+ 'admin.group.connect': "Integrations",
1174
+ "fq.title": "Data freshness",
1175
+ "fq.refresh": "Refresh",
1176
+ "fq.checkedAt": "Checked {time}",
1177
+ "fq.staleCount": "{n} stale table(s)",
1178
+ "fq.warnCount": "{n} table(s) lagging",
1179
+ "fq.allFresh": "All data up to date",
1180
+ "fq.lagMin": "{n} min",
1181
+ "fq.lagHour": "{n} h",
1182
+ "fq.lagDay": "{n} days",
1183
+ "fq.today": "today {time}",
1184
+ "fq.yesterday": "yesterday {time}",
1185
+ "fq.barItem": "{table}: latest data from {when} ({lag} behind)",
1186
+ "fq.barMore": "+{n} more tables",
1187
+ "fq.st.error": "Check failed",
1188
+ "fq.st.empty": "No data",
1189
+ "fq.running": "Checking data freshness…",
1190
+ "fq.failed": "Check failed",
1191
+ "fq.noTime": "No time column",
1192
+ "fq.notAnalyzed": "This data source has not been analyzed yet; analyze it under Schema first.",
1193
+ "fq.noTables": "No tables with a time column.",
1194
+ "fq.col.table": "Table",
1195
+ "fq.col.column": "Time column",
1196
+ "fq.col.latest": "Latest data",
1197
+ "fq.col.lag": "Lag",
1198
+ "fq.col.threshold": "Threshold (h)",
1199
+ "fq.thresholdFor": "Lag threshold for {table} (hours)",
1200
+ "fq.events": "Recent delay events",
1201
+ "fq.evStale": "stale for {lag}",
1202
+ "fq.evRecovered": "recovered",
1203
+ "fq.pushed": "pushed",
1204
+ "fq.q.title": "Data quality patrol",
1205
+ "fq.q.lastRun": "Last run {time}",
1206
+ "fq.q.run": "Run now",
1207
+ "fq.q.running": "Running…",
1208
+ "fq.q.empty": "No runs yet. Click \"Run now\" to start.",
1209
+ "fq.q.done": "Patrol finished, health score {score}",
1210
+ "fq.q.history": "Recent scores",
1211
+ "fq.q.score": "Score",
1212
+ "fq.q.rows": "Rows",
1213
+ "fq.q.issues": "Issues",
1214
+ "fq.q.noIssues": "No issues found",
1215
+ "fq.more": "{n} more",
1216
+ "fq.less": "Show less",
1217
+ "fq.admin.nav": "Data health",
1218
+ "fq.admin.title": "Data health",
1219
+ "fq.admin.desc": "Watch whether tables keep receiving new data, and patrol them for nulls, duplicate keys, outliers, future timestamps and negative amounts.",
1220
+ "fq.admin.nextF": "Next freshness check {time}",
1221
+ "fq.admin.fOff": "Scheduled freshness checks are off",
1222
+ "fq.admin.nextQ": "Quality patrol daily at {time}",
1223
+ "fq.admin.qOff": "Daily quality patrol is off",
1224
+ "fq.admin.noDs": "Add a data source first",
1225
+ "fq.set.title": "Settings",
1226
+ "fq.set.fEnabled": "Check data freshness on a schedule",
1227
+ "fq.set.qEnabled": "Daily data quality patrol",
1228
+ "fq.set.interval": "Freshness check interval (min)",
1229
+ "fq.set.time": "Quality patrol time",
1230
+ "fq.set.staleHours": "Default lag threshold (hours)",
1231
+ "fq.set.drop": "Push when the score drops by more than (points)",
1232
+ "fq.set.fChannel": "Delay notification channel",
1233
+ "fq.set.qChannel": "Score drop notification channel",
1234
+ "fq.set.noPush": "Do not push",
1235
+ "fq.set.hint": "Per-table thresholds can be set in the table above (0 = not monitored). A delay is pushed once per stale episode, and once more when it recovers.",
1236
+ "fq.saved": "Saved",
1237
+ 'pro.close': 'Close', 'pro.sendNow': 'Send now', 'pro.sent': 'Sent', 'pro.deleteConfirm': 'Delete "{name}"?', 'pro.weekday': 'Weekday',
1238
+ 'pro.wd.0': 'Sunday', 'pro.wd.1': 'Monday', 'pro.wd.2': 'Tuesday', 'pro.wd.3': 'Wednesday', 'pro.wd.4': 'Thursday', 'pro.wd.5': 'Friday', 'pro.wd.6': 'Saturday',
1239
+ 'pro.fc.menu': 'Forecast next {n} days', 'pro.fc.title': 'Forecast', 'pro.fc.heading': 'Forecast, next {n} periods', 'pro.fc.running': 'Forecasting…', 'pro.fc.forecast': 'Forecast', 'pro.fc.band': '95% interval', 'pro.fc.lower': 'Lower', 'pro.fc.upper': 'Upper',
1240
+ 'pro.fc.hw': 'Holt-Winters · weekly', 'pro.fc.holt': 'Holt linear trend', 'pro.fc.horizon': 'Horizon', 'pro.fc.nDays': '{n} periods', 'pro.fc.filled': '{n} days filled', 'pro.fc.filledHint': 'Days without data count as 0',
1241
+ 'pro.fc.note': 'A statistical extrapolation of the history; promotions, holidays and other known events are not taken into account. The band is the 95% prediction interval.',
1242
+ 'pro.goals.title': 'Goals', 'pro.goals.desc': 'Monthly / quarterly / yearly targets for metrics: the period end is projected from the daily run rate (weekday-aware), and progress shows on dashboards.', 'pro.goals.add': 'New goal',
1243
+ 'pro.goals.name': 'Name', 'pro.goals.namePh': 'e.g. November transactions', 'pro.goals.period': 'Period', 'pro.goals.target': 'Target', 'pro.goals.direction': 'Direction', 'pro.goals.dashboard': 'Show on dashboard', 'pro.goals.anyBoard': 'Dashboards using the same data source',
1244
+ 'pro.goals.empty': 'No goals yet', 'pro.goals.emptyHint': 'Define a metric in Knowledge → Metrics first, then set a goal for it.', 'pro.goals.actual': 'Actual', 'pro.goals.projected': 'Projected', 'pro.goals.proj': 'Projected {v} ({p})', 'pro.goals.weekdayAware': 'weekday-aware',
1245
+ 'pro.goals.weekly': 'Weekly goal report', 'pro.goals.weeklyOn': 'Push goal progress every week', 'pro.goals.weeklyHint': 'At the chosen time, progress and projections of all goals are pushed to the channel.',
1246
+ 'pro.period.month': 'Month', 'pro.period.quarter': 'Quarter', 'pro.period.year': 'Year', 'pro.dir.up': 'At least', 'pro.dir.down': 'At most',
1247
+ 'pro.st.on-track': 'On track', 'pro.st.at-risk': 'At risk', 'pro.st.achieved': 'Achieved', 'pro.st.missed': 'Missed', 'pro.st.error': 'Error',
1248
+ 'pro.dg.title': 'Daily digest', 'pro.dg.desc': 'Every day, compares yesterday with the 7-day average and the same day last week, explains notable changes and pushes a short summary with charts.', 'pro.dg.add': 'New digest',
1249
+ 'pro.dg.name': 'Name', 'pro.dg.namePh': 'e.g. Daily transactions', 'pro.dg.source': 'Content', 'pro.dg.src.metrics': 'Library metrics', 'pro.dg.src.dashboard': 'Dashboard trend charts', 'pro.dg.metrics': 'Metrics', 'pro.dg.dashboard': 'Dashboard',
1250
+ 'pro.dg.language': 'Language', 'pro.dg.threshold': 'Change threshold (%)', 'pro.dg.hint': 'A metric is notable when yesterday deviates from the 7-day average by more than 2 standard deviations or by more than the threshold; notable changes are attributed to the main dimension.',
1251
+ 'pro.dg.empty': 'No daily digests yet', 'pro.dg.emptyHint': 'Pick a few key metrics and get them in your group chat every morning.', 'pro.dg.preview': 'Preview', 'pro.dg.previewing': 'Generating preview…', 'pro.dg.dailyAt': 'Daily at {time}',
1252
+ "pre.working": "Generating…", "pre.exported": "Exported", "pre.exportPpt": "Export PPT", "pre.exportImage": "Export long image", "pre.sendImage": "Send long image to IM", "pre.sendImageHint": "Render the whole dashboard as one tall image and send it to a Lark app bot or Telegram bot channel.", "pre.noImageChannel": "No channel can receive images: add a Lark app bot or Telegram bot under Admin → Channels first.", "pre.channel": "Channel", "pre.send": "Send", "pre.imageSent": "Long image sent to \"{channel}\"", "pre.mode": "Presentation mode", "pre.loading": "Loading…", "pre.lastRefresh": "Last refresh {time}", "pre.clock": "Current time", "pre.refresh": "Auto refresh", "pre.rotate": "Rotate boards", "pre.rotateOff": "Off", "pre.everySec": "Every {s} s", "pre.prev": "Previous dashboard (←)", "pre.next": "Next dashboard (→)", "pre.exit": "Exit presentation mode", "pre.exitHint": "Exit (Esc)", "pre.bt.nav": "Bitable sync", "pre.bt.title": "Bitable sync", "pre.bt.desc": "Write a dashboard widget or SQL result into a Lark Bitable: missing fields are created (text / number / date); optionally synced daily.", "pre.bt.add": "New sync", "pre.bt.empty": "No syncs yet", "pre.bt.emptyHint": "For example: every morning, sync \"revenue by channel\" to the ops team's Bitable.", "pre.bt.name": "Name", "pre.bt.namePh": "e.g. Channel daily → Ops Bitable", "pre.bt.channel": "Lark app (provides app credentials)", "pre.bt.noChannel": "Add a Lark app bot channel first", "pre.bt.channelMissing": "Channel deleted", "pre.bt.appTokenPh": "Paste the Bitable link or app_token", "pre.bt.source": "Source", "pre.bt.src.widget": "Dashboard widget", "pre.bt.src.sql": "SQL query", "pre.bt.widget": "Widget", "pre.bt.mode": "Write mode", "pre.bt.mode.replace": "Replace", "pre.bt.mode.append": "Append", "pre.bt.modeHint.replace": "Delete all records first, then write the latest result.", "pre.bt.modeHint.append": "Append the result each time, keeping existing records.", "pre.bt.schedule": "Schedule", "pre.bt.daily": "Sync daily", "pre.bt.dailyAt": "Daily {time}", "pre.bt.permHint": "Add the app to the Bitable (… → More → Add document app, with edit access) and grant it the bitable:app scope.", "pre.bt.run": "Sync now", "pre.bt.ran": "Synced: {created} written, {deleted} deleted", "pre.bt.lastOk": "OK · {n} rows", "pre.bt.lastErr": "Failed", "pre.bt.never": "Never run", "pre.bt.deleteConfirm": "Delete sync \"{name}\"?",
1253
+ 'mcp.nav': 'MCP access', 'mcp.title': 'MCP access', 'mcp.desc': 'Let AI tools such as Claude Desktop, Cursor, Cline and VS Code use these data sources through the Model Context Protocol. Read-only admission, access policies, member scopes, row-level rules and audit apply as usual.',
1254
+ 'mcp.enabled': 'Enabled', 'mcp.disabled': 'Disabled', 'mcp.running': 'Running', 'mcp.stopped': 'Not running', 'mcp.port': 'Port', 'mcp.applyPort': 'Apply',
1255
+ 'mcp.endpoint': 'Endpoint', 'mcp.localOnly': 'The server listens on 127.0.0.1 only and rejects non-local Origin / Host headers; every request needs a token.', 'mcp.copy': 'Copy', 'mcp.copied': 'Copied',
1256
+ 'mcp.tokens': 'Access tokens', 'mcp.newToken': 'New token', 'mcp.tokenName': 'Name', 'mcp.tokenNamePh': 'e.g. My Cursor', 'mcp.create': 'Create token',
1257
+ 'mcp.scope': 'Scope', 'mcp.scope.owner': 'Local owner (full access)', 'mcp.scope.member': 'A member', 'mcp.scope.role': 'Role + data sources',
1258
+ 'mcp.scopeHint.owner': 'Same access as asking on this page; access policies still apply.', 'mcp.scopeHint.member': 'Follows the member\'s role, data source scope and row-level rules; the token stops working when the member is disabled.', 'mcp.scopeHint.role': 'Only the selected data sources, with an optional row-level rule.',
1259
+ 'mcp.member': 'Member', 'mcp.pickMember': 'Choose a member…', 'mcp.noMembers': 'No members yet; add one under Members first.', 'mcp.memberMissing': 'Member deleted', 'mcp.memberDisabled': 'Member disabled',
1260
+ 'mcp.role': 'Role', 'mcp.role.admin': 'Admin', 'mcp.role.analyst': 'Analyst', 'mcp.role.viewer': 'Viewer', 'mcp.datasources': 'Data sources', 'mcp.allDs': 'All data sources',
1261
+ 'mcp.rowRule': 'Row-level rule (optional)', 'mcp.rowColumn': 'Column', 'mcp.rowValues': 'Allowed values, comma-separated', 'mcp.rowRuleHint': 'Queries on tables with this column get "column IN (values)" added automatically.', 'mcp.rowRulesN': '{n} row-level rules',
1262
+ 'mcp.err.name': 'Enter a token name', 'mcp.err.member': 'Choose a member', 'mcp.err.ds': 'Select at least one data source',
1263
+ 'mcp.freshTitle': 'Token "{name}" created', 'mcp.freshHint': 'The token is shown only once — copy it now. Only its hash is stored; if lost, create a new one.', 'mcp.done': 'I saved it',
1264
+ 'mcp.col.name': 'Name', 'mcp.col.scope': 'Scope', 'mcp.col.created': 'Created', 'mcp.col.lastUsed': 'Last used', 'mcp.never': 'Never',
1265
+ 'mcp.revoke': 'Revoke', 'mcp.revokeConfirm': 'Revoke token "{name}"?', 'mcp.revokeHint': 'Clients using this token lose access immediately.', 'mcp.revoked': 'Token revoked',
1266
+ 'mcp.noTokens': 'No tokens yet', 'mcp.noTokensHint': 'Create a token, then add the configuration below to your AI tool.',
1267
+ 'mcp.setup': 'Client configuration', 'mcp.client.claude': 'Claude Desktop', 'mcp.client.cursor': 'Cursor', 'mcp.client.vscode': 'VS Code',
1268
+ 'mcp.where.claude': 'Add to claude_desktop_config.json (stdio bridge via npx nl2sql-mcp; needs Node.js 18+).', 'mcp.where.cursor': 'Add to ~/.cursor/mcp.json or the project\'s .cursor/mcp.json (HTTP).', 'mcp.where.vscode': 'Add to .vscode/mcp.json (HTTP).',
1269
+ 'mcp.placeholderHint': 'Replace <YOUR_TOKEN> with your token.', 'mcp.docsHint': 'See docs/mcp.md in the plugin for details. Keep DeepSeek Harness running while you use it.',
1270
+ 'rc.title': "Reconciliation", 'rc.hint': "Compare the results of two data sources (or two queries): rows are matched on key columns to find rows present on one side only and values that differ; run daily and push the differences.", 'rc.add': "New reconciliation", 'rc.empty': "No reconciliations yet", 'rc.emptyHint': "For example: check daily order counts and amounts between the order database and the finance database.", 'rc.name': "Name", 'rc.namePh': "e.g. Orders vs finance · daily amount", 'rc.left': "Left", 'rc.right': "Right", 'rc.leftCol': "Left column", 'rc.rightCol': "Right column (blank = same name)", 'rc.keys': "Key columns", 'rc.keysHint': "Columns that match rows across both sides (an order number, or date + channel).", 'rc.addKey': "Add key", 'rc.compare': "Compare columns", 'rc.compareHint': "Values that must agree; numbers can have a tolerance (absolute, or relative such as 0.001 = 0.1%).", 'rc.addCompare': "Add compare column", 'rc.tolerance': "Tolerance", 'rc.tolMode': "Tolerance type", 'rc.tolAbs': "Absolute", 'rc.tolRel': "Relative", 'rc.trim': "Trim spaces before comparing text", 'rc.ignoreCase': "Ignore case", 'rc.daily': "Run daily", 'rc.dailyAt': "Daily {time}", 'rc.manual': "Manual", 'rc.noPush': "Do not push", 'rc.notifyOn': "Push when", 'rc.notify.diff': "Differences found", 'rc.notify.always': "Every run", 'rc.dryRun': "Dry run", 'rc.dryResult': "Dry run result (not saved)", 'rc.saved': "Reconciliation saved", 'rc.runNow': "Run now", 'rc.pushed': "pushed", 'rc.history': "Run history", 'rc.historyOf': "Run history · {name}", 'rc.allRuns': "All jobs", 'rc.runOf': "Reconciliation result · {name}", 'rc.close': "Close", 'rc.deleteConfirm': "Delete reconciliation \"{name}\"?", 'rc.detail': "Details", 'rc.push': "Push", 'rc.scheduled': "scheduled", 'rc.never': "Never run", 'rc.status.ok': "Consistent", 'rc.status.diff': "Differences", 'rc.status.error': "Failed", 'rc.matchRate': "Match rate", 'rc.matched': "Matched keys", 'rc.mismatched': "Rows with different values", 'rc.rowsOf': "{side} rows", 'rc.onlyIn': "Only in {side}", 'rc.totalOf': "{side} total", 'rc.col': "Column", 'rc.mismatchRows': "Different rows", 'rc.diff': "Difference", 'rc.hasDiff': "Differences found, match rate {rate}", 'rc.noDiff': "Both sides agree, match rate {rate}", 'rc.incomplete': "incomplete (truncated)", 'rc.short.onlyLeft': "left only {n}", 'rc.short.onlyRight': "right only {n}", 'rc.short.mismatch': "{n} rows differ", 'rc.tab.summary': "Summary", 'rc.tab.mismatches': "Different values", 'rc.tab.only': "Only in {side}", 'rc.tool': "Reconciliation", 'rc.toolRunning': "Reconciling",
1271
+ "sub.nav": "Chat subscriptions",
1272
+ "sub.title": "Chat subscriptions",
1273
+ "sub.desc": "Scheduled deliveries and alerts created from IM chats through a bot, grouped by chat; they are delivered back to that chat.",
1274
+ "sub.refresh": "Refresh",
1275
+ "sub.empty": "No chat subscriptions yet",
1276
+ "sub.emptyHint": "In a chat, @-mention the bot with \"send this here every day at 9\" or \"tell me when it exceeds 1000\".",
1277
+ "sub.col.item": "Item",
1278
+ "sub.col.creator": "Created by",
1279
+ "sub.col.schedule": "Schedule",
1280
+ "sub.col.last": "Last delivery",
1281
+ "sub.col.status": "Status",
1282
+ "sub.kind.subscription": "Subscription",
1283
+ "sub.kind.alert": "Alert",
1284
+ "sub.status.active": "Active",
1285
+ "sub.status.paused": "Paused",
1286
+ "sub.status.suspended": "Suspended",
1287
+ "sub.next": "Next",
1288
+ "sub.pause": "Pause",
1289
+ "sub.resume": "Resume",
1290
+ "sub.delete": "Delete",
1291
+ "sub.deleteConfirm": "Delete \"{name}\"?",
1292
+ "sub.done.pause": "Paused",
1293
+ "sub.done.resume": "Resumed",
1294
+ "sub.done.delete": "Deleted",
1035
1295
  'mb.pick.people': "Pick someone who talked to a bot",
1036
1296
  'mb.pick.chats': "Pick a group the bot is in",
1037
1297
  'mb.pick.refresh': "Refresh",
@@ -1426,7 +1686,7 @@ window.__ModuleLoader__.load({
1426
1686
  '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
1687
  '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
1688
  '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.',
1689
+ 'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.',
1430
1690
  'im.includeSql': 'Include SQL', 'im.send': 'Send', 'im.sent': 'Sent to {channel} ({images} images)', 'rep.push': 'Push to', 'rep.pushNone': 'Do not push',
1431
1691
  'tab.reports': 'Reports', 'ds.type': 'Database type', 'ds.indexPattern': 'Indices (comma separated, wildcards allowed)', 'ds.userOptional': 'User (optional)',
1432
1692
  '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 +1756,12 @@ window.__ModuleLoader__.load({
1496
1756
  'settings.limits': 'Query limits', 'settings.maxRows': 'Max rows per query', 'settings.timeoutMs': 'Query timeout (ms)', 'settings.save': 'Save settings', 'settings.saved': 'Saved',
1497
1757
  'composer.none': 'No data source', 'composer.title': 'Data source queried by this session',
1498
1758
  'tool.query': 'Data query', 'tool.chart': 'Chart', 'tool.running': 'Querying…', 'tool.failed': 'Query failed', 'error.generic': 'Failed: {message}',
1759
+ 'dash.copyName': '{name} copy',
1760
+ 'set.leaveTitle': 'Discard unsaved settings?', 'set.leaveMsg': 'You have unsaved setting changes; they will be lost if you leave.', 'set.leaveOk': 'Discard and leave',
1761
+ 'fb.deleteConfirm': 'Delete this feedback?',
1762
+ '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',
1763
+ 'filter.valuesPh': 'Values, comma-separated',
1764
+ 'policy.tablesLabel': 'Accessible tables',
1499
1765
  }
1500
1766
 
1501
1767
  // --------------------------------------------------------------- RPC
@@ -1978,6 +2244,100 @@ window.__ModuleLoader__.load({
1978
2244
  .n2s-pickchip:disabled{opacity:.55;cursor:default}
1979
2245
  .n2s-pickchip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
1980
2246
  .n2s-idpicker{gap:6px}
2247
+ .n2s-sub-group { margin-bottom: 12px; }
2248
+ .n2s-sub-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
2249
+ .n2s-sub-chat { font-weight: 600; }
2250
+ .n2s-sub-kind { font-size: 11px; }
2251
+ .n2s-sub-err { color: var(--n2s-err); white-space: normal; max-width: 260px; }
2252
+ /* ---- reconciliation (rc) ---- */
2253
+ .n2s-rc-panes{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:10px 0}
2254
+ @media (max-width:820px){.n2s-rc-panes{grid-template-columns:1fr}}
2255
+ .n2s-rc-pane{display:flex;flex-direction:column;gap:8px;padding:10px;border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);min-width:0}
2256
+ .n2s-rc-pane-title{font-weight:500;margin-bottom:2px}
2257
+ .n2s-rc-maprow{display:flex;align-items:center;gap:6px;margin-top:6px}
2258
+ .n2s-rc-maprow .n2s-input{flex:1;min-width:0}
2259
+ .n2s-rc-maprow .n2s-input.n2s-rc-tol{flex:0 0 100px}
2260
+ .n2s-rc-dry{margin-top:14px;padding-top:12px;border-top:.5px solid var(--n2s-line)}
2261
+ .n2s-rc-stats{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
2262
+ .n2s-rc-stat{padding:8px 12px;border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius);min-width:96px}
2263
+ .n2s-rc-stat-v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
2264
+ .n2s-rc-stat-l{font-size:12px;color:var(--n2s-muted)}
2265
+ .n2s-rc-stat.bad .n2s-rc-stat-v,.n2s-rc-bad{color:var(--n2s-err)}
2266
+ .n2s-rc-run{cursor:pointer}
2267
+ .n2s-rc-run:hover td{background:color-mix(in srgb,currentColor 4%,transparent)}
2268
+ .n2s-mcp .n2s-card{margin-bottom:12px}
2269
+ .n2s-mcp-endpoint{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0 6px}
2270
+ .n2s-mcp-endpoint code{padding:4px 8px;border-radius:var(--n2s-radius-sm);background:var(--n2s-hover);font-size:12.5px;word-break:break-all}
2271
+ .n2s-mcp-form{display:grid;gap:12px}
2272
+ .n2s-mcp-snippets{margin-top:10px}
2273
+ .n2s-mcp-code{position:relative}
2274
+ .n2s-mcp-code pre{margin:0;padding:10px 12px;max-height:260px;overflow:auto;white-space:pre;font-size:12px}
2275
+ .n2s-mcp-codeact{position:absolute;top:6px;right:6px}
2276
+ .n2s-mcp-fresh{border-color:var(--n2s-accent)}
2277
+ /* ---- presentation mode (feature present) ---- */
2278
+ .n2s-pre{position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;gap:18px;padding:22px 28px 28px;overflow:auto;box-sizing:border-box;background:#0b1020;color:#e8ecf4;font-size:16px;line-height:1.5;outline:none;
2279
+ --dsw-alias-label-primary:#e8ecf4;--dsw-alias-label-secondary:#9aa4b8;--dsw-alias-label-tertiary:#6f7a90;--dsw-alias-border-l1:rgba(255,255,255,.10);--dsw-alias-border-l2:rgba(255,255,255,.16);--dsw-alias-bg-layer-1:#141b2e;--dsw-alias-bg-base:#0b1020;--n2s-muted:#9aa4b8;--n2s-faint:#6f7a90;--n2s-line:rgba(255,255,255,.10);--n2s-line-2:rgba(255,255,255,.16);--n2s-hover:rgba(255,255,255,.06);--n2s-fill:rgba(255,255,255,.05);--n2s-accent:#5b8cff;--n2s-ok:#34c759;--n2s-warn:#ffb020;--n2s-err:#ff5c5c}
2280
+ .n2s-pre-head{display:flex;align-items:center;gap:24px;flex-wrap:wrap}
2281
+ .n2s-pre-titles{flex:1;min-width:240px}
2282
+ .n2s-pre-titles h1{margin:0;font-size:clamp(26px,2.6vw,40px);font-weight:700;letter-spacing:-.01em;line-height:1.2}
2283
+ .n2s-pre-sub{color:#9aa4b8;font-size:14px;margin-top:4px}
2284
+ .n2s-pre-clock{text-align:right;font-variant-numeric:tabular-nums}
2285
+ .n2s-pre-clock .time{font-size:clamp(26px,2.4vw,38px);font-weight:600;line-height:1.1}
2286
+ .n2s-pre-clock .date{color:#9aa4b8;font-size:13px}
2287
+ .n2s-pre-ctl{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
2288
+ .n2s-pre-ctl label{display:inline-flex;align-items:center;gap:6px;color:#9aa4b8;font-size:13px}
2289
+ .n2s-pre-ctl select{height:32px;padding:0 8px;border-radius:8px;border:1px solid rgba(255,255,255,.16);background:#141b2e;color:#e8ecf4;font:inherit;font-size:13px}
2290
+ .n2s-pre-btn{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:34px;padding:0 8px;border-radius:8px;border:1px solid rgba(255,255,255,.16);background:#141b2e;color:#e8ecf4;font:inherit;font-size:16px;cursor:pointer}
2291
+ .n2s-pre-btn:hover{background:#1d2640}
2292
+ .n2s-pre-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:18px;align-content:start}
2293
+ .n2s-pre-card{grid-column:span 3;min-width:0;padding:16px 18px;border-radius:14px;background:#141b2e;border:1px solid rgba(255,255,255,.08);box-shadow:0 8px 30px rgba(0,0,0,.25)}
2294
+ .n2s-pre-card.w-third{grid-column:span 2}
2295
+ .n2s-pre-card.w-full{grid-column:span 6}
2296
+ .n2s-pre-card-title{display:flex;align-items:center;font-size:clamp(16px,1.3vw,22px);font-weight:600;margin-bottom:8px}
2297
+ .n2s-pre-card .n2s-result-foot{display:none}
2298
+ .n2s-pre-card .n2s-chart{min-height:300px}
2299
+ .n2s-pre-card .n2s-kpi .v{font-size:clamp(34px,3.6vw,60px)}
2300
+ .n2s-pre-card .n2s-table{font-size:14px}
2301
+ .n2s-pre-empty{padding:80px 0;text-align:center;color:#9aa4b8;font-size:20px}
2302
+ @media (max-width:1100px){.n2s-pre-card,.n2s-pre-card.w-third{grid-column:span 6}}
2303
+ @media (min-width:1101px) and (max-width:1500px){.n2s-pre-card.w-third{grid-column:span 3}}
2304
+ .n2s-pre-dlg{margin-top:10px}
2305
+
2306
+ /* ---- proactive: forecast, goals, digest ---- */
2307
+ .n2s-pro-fc{margin-top:10px;padding:10px 12px;border:.5px dashed var(--n2s-line-2);border-radius:var(--n2s-radius-sm)}
2308
+ .n2s-pro-fc-title{display:inline-flex;align-items:center;gap:6px;font-weight:500;font-size:13px}
2309
+ .n2s-pro-chips{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
2310
+ .n2s-pro-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:.5px solid var(--n2s-line-2);font-size:12px;white-space:nowrap}
2311
+ .n2s-pro-bar{position:relative;display:inline-block;width:64px;height:5px;border-radius:999px;background:var(--n2s-fill);overflow:hidden;flex:none}
2312
+ .n2s-pro-bar .proj,.n2s-pro-bar .cur{position:absolute;left:0;top:0;bottom:0;border-radius:999px}
2313
+ .n2s-pro-bar .proj{background:color-mix(in srgb,var(--n2s-accent) 28%,transparent)}
2314
+ .n2s-pro-bar .cur{background:var(--n2s-accent)}
2315
+ .n2s-pro-bar.ok .cur{background:var(--n2s-ok)}.n2s-pro-bar.ok .proj{background:color-mix(in srgb,var(--n2s-ok) 28%,transparent)}
2316
+ .n2s-pro-bar.warn .cur{background:var(--n2s-warn)}.n2s-pro-bar.warn .proj{background:color-mix(in srgb,var(--n2s-warn) 28%,transparent)}
2317
+ .n2s-pro-bar.err .cur{background:var(--n2s-err)}
2318
+ .n2s-pro-bar .tick{position:absolute;top:0;bottom:0;width:1px;background:var(--dsw-alias-label-secondary)}
2319
+ .n2s-list2-row .n2s-pro-bar{width:140px}
2320
+ .n2s-pro-pct{font-variant-numeric:tabular-nums;color:var(--n2s-muted)}
2321
+ .n2s-pro-pct.ok{color:var(--n2s-ok)}.n2s-pro-pct.warn{color:color-mix(in srgb,var(--n2s-warn) 85%,black)}.n2s-pro-pct.err{color:var(--n2s-err)}
2322
+ .n2s-pro-preview{margin-top:10px;padding:12px;border-radius:var(--n2s-radius-sm);background:var(--n2s-fill)}
2323
+ .n2s-pro-preview-charts{display:grid;gap:10px;margin-top:10px}
2324
+ /* ---- data freshness / health (21a-freshness.js) ---- */
2325
+ .n2s-fq-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px;padding:6px 10px;border-radius:var(--n2s-radius-sm);font-size:12.5px;line-height:1.5;color:color-mix(in srgb,var(--n2s-warn) 80%,black);background:color-mix(in srgb,var(--n2s-warn) 12%,transparent)}
2326
+ .n2s-fq-panel{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius-sm);padding:6px 10px;margin-bottom:12px}
2327
+ .n2s-fq-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:2px 16px;margin-top:6px}
2328
+ .n2s-fq-item{display:flex;align-items:center;gap:6px;min-width:0;font-size:12.5px;padding:2px 0}
2329
+ .n2s-fq-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--n2s-faint)}
2330
+ .n2s-fq-dot.ok{background:var(--n2s-ok)}
2331
+ .n2s-fq-dot.warn{background:var(--n2s-warn)}
2332
+ .n2s-fq-dot.err{background:var(--n2s-err)}
2333
+ .n2s-fq-score{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:22px;padding:0 6px;border-radius:6px;font-weight:600;font-size:12px;background:var(--n2s-fill);color:var(--n2s-muted)}
2334
+ .n2s-fq-score.ok{color:var(--n2s-ok);background:color-mix(in srgb,var(--n2s-ok) 12%,transparent)}
2335
+ .n2s-fq-score.warn{color:color-mix(in srgb,var(--n2s-warn) 85%,black);background:color-mix(in srgb,var(--n2s-warn) 14%,transparent)}
2336
+ .n2s-fq-score.err{color:var(--n2s-err);background:color-mix(in srgb,var(--n2s-err) 10%,transparent)}
2337
+ .n2s-fq-summary{display:flex;align-items:center;gap:12px;font-size:13px}
2338
+ .n2s-fq-summary .n2s-fq-score{min-width:48px;height:34px;font-size:17px}
2339
+ .n2s-adminnav-group{margin:14px 12px 4px;font-size:11.5px;letter-spacing:.02em;color:var(--n2s-faint)}
2340
+ .n2s-adminnav-sep{height:.5px;margin:10px 12px;background:var(--n2s-line)}
1981
2341
  /* ---- dashboard v2 ---- */
1982
2342
  .n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
1983
2343
  .n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
@@ -2036,7 +2396,8 @@ window.__ModuleLoader__.load({
2036
2396
  .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
2397
  .n2s-ptile:focus-visible,.n2s-kindcard:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:2px}
2038
2398
  .n2s-ptile-name{font-size:13.5px;font-weight:600}
2039
- .n2s-kindcards{display:grid;gap:8px;margin-top:8px}
2399
+ .n2s-kindcards{display:grid;gap:8px;margin:8px 0 0;padding:0;list-style:none}
2400
+ .n2s-kindcard-li{display:grid}
2040
2401
  .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
2402
  .n2s-kindcard:hover{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 4%,transparent)}
2042
2403
  .n2s-kindcard-title{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
@@ -2158,10 +2519,35 @@ window.__ModuleLoader__.load({
2158
2519
  /** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
2159
2520
  let uiLang = 'zh'
2160
2521
  // LocaleSnapshot is { active, locales, revision } (dsh-client-locale); older hosts passed a string.
2522
+ /**
2523
+ * Extension points for feature files (client-src/2x-*.js). Each feature calls
2524
+ * `extend.<point>.push(...)` at module evaluation:
2525
+ * adminSections: { id, label: (t) => string, icon: () => node, Component({ t, ds, datasources, onNavigate }) }
2526
+ * dashboardMenu: (ctx: { t, boardId, widgets, filters }) => [Dropdown items]
2527
+ * dashboardBar: Component({ t, boardId, widgets, filters }) rendered next to the dashboard actions
2528
+ * resultMenu: (ctx: { t, result }) => [Dropdown items]
2529
+ * resultPanels: Component({ t, result }) rendered under a result (e.g. forecast, freshness note)
2530
+ * schemaPanels: Component({ t, ds, schema, selected }) rendered above the table list
2531
+ * askPanels: Component({ t, ds }) rendered on the Ask page under the composer
2532
+ * toolViews: { key: '<tool name>', make: (ctx) => Component } conversation tool result views
2533
+ */
2534
+ const extend = { adminSections: [], dashboardMenu: [], dashboardBar: [], resultMenu: [], resultPanels: [], schemaPanels: [], askPanels: [], toolViews: [] }
2535
+ const extItems = (list, arg) => list.flatMap(f => { try { return f(arg) ?? [] } catch (e) { console.warn('nl2sql ext', e); return [] } })
2536
+
2161
2537
  /** Join a list for display in the UI language: 「a、b」 / "a, b". */
2162
2538
  const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
2163
2539
  /** Label + colon in the UI language: 「标签:」 / "Label: ". */
2164
2540
  const colon = (label) => (uiLang === 'zh' ? `${label}:` : `${label}: `)
2541
+ /** List separator in the UI language. */
2542
+ const listSep = () => (uiLang === 'zh' ? '、' : ', ')
2543
+ /** Clauses joined in the UI language: 「a;b」 / "a; b". */
2544
+ const joinClauses = (items) => joinList(items, uiLang === 'zh' ? ';' : '; ')
2545
+ /** Parenthetical in the UI language: 「(x)」 / " (x)". */
2546
+ const paren = (s) => (s === undefined || s === null || s === '' ? '' : uiLang === 'zh' ? `(${s})` : ` (${s})`)
2547
+ /** Quoted name in the UI language: 「x」 / "x". */
2548
+ const quoted = (s) => (uiLang === 'zh' ? `「${s}」` : `"${s}"`)
2549
+ /** BCP 47 tag for Intl formatting in the UI language. */
2550
+ const uiLocale = () => (uiLang === 'zh' ? 'zh-CN' : 'en-US')
2165
2551
  const setUiLang = (snapshot) => {
2166
2552
  const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
2167
2553
  uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
@@ -2176,18 +2562,26 @@ window.__ModuleLoader__.load({
2176
2562
  }
2177
2563
 
2178
2564
  function formatCompact(v) {
2565
+ if (v === null || v === undefined || v === '') return '—'
2179
2566
  const n = Number(v)
2180
- if (!Number.isFinite(n)) return String(v ?? '—')
2567
+ if (!Number.isFinite(n)) return String(v)
2181
2568
  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 })
2569
+ // [divisor, suffix, threshold, digits(abs)] from the largest unit down.
2570
+ const scales = uiLang === 'zh'
2571
+ ? [[1e8, '亿', 1e8, a => (a >= 1e10 ? 0 : 2)], [1e4, '万', 1e5, a => (a >= 1e7 ? 0 : 1)]]
2572
+ : [[1e9, 'B', 1e9, a => (a >= 1e11 ? 0 : 2)], [1e6, 'M', 1e6, a => (a >= 1e8 ? 0 : 1)], [1e3, 'K', 1e5, () => 0]]
2573
+ for (let i = 0; i < scales.length; i++) {
2574
+ const [div, suffix, threshold, digits] = scales[i]
2575
+ if (abs < threshold) continue
2576
+ const s = (n / div).toFixed(digits(abs))
2577
+ // Rounding can reach the next unit (999 999 → "1000K"): use the larger unit instead.
2578
+ if (i > 0 && Math.abs(Number(s)) >= scales[i - 1][0] / div) {
2579
+ const [div2, suffix2, , digits2] = scales[i - 1]
2580
+ return `${(n / div2).toFixed(digits2(scales[i - 1][0]))}${suffix2}`
2581
+ }
2582
+ return `${s}${suffix}`
2187
2583
  }
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 })
2584
+ return n.toLocaleString(uiLocale(), { maximumFractionDigits: 2 })
2191
2585
  }
2192
2586
 
2193
2587
  function toCsv(result) {
@@ -2261,23 +2655,72 @@ window.__ModuleLoader__.load({
2261
2655
  * Async data with request ordering: results of an older request (or of
2262
2656
  * previous deps) never overwrite newer ones, and `loading` is true from the
2263
2657
  * first render after deps change.
2658
+ *
2659
+ * `value` belongs to the current deps: while new deps load it is undefined,
2660
+ * so e.g. a previous data source's data is never shown or acted on.
2661
+ * `reload()` with unchanged deps keeps `value` until the new one arrives.
2662
+ * `scope` (optional) names the deps that identify *whose* data this is; when
2663
+ * only other deps change (a page offset, a filter) the previous value stays
2664
+ * on screen while the next one loads, as long as the scope is unchanged.
2264
2665
  */
2265
- function useAsync(fn, deps) {
2666
+ function useAsync(fn, deps, scope) {
2266
2667
  const depsKey = JSON.stringify(deps)
2267
- const [state, setState] = useState({ loading: true, value: undefined, error: undefined, key: null })
2668
+ const scopeKey = scope === undefined ? depsKey : JSON.stringify(scope)
2669
+ const [state, setState] = useState({ loading: true, value: undefined, error: undefined, key: null, scope: null })
2268
2670
  const seq = useRef(0)
2269
2671
  const reload = useCallback(() => {
2270
2672
  const id = ++seq.current
2271
2673
  setState(s => ({ ...s, loading: true, error: undefined }))
2272
2674
  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 }) },
2675
+ value => { if (seq.current === id) setState({ loading: false, value, error: undefined, key: depsKey, scope: scopeKey }) },
2676
+ error => { if (seq.current === id) setState({ loading: false, value: undefined, error, key: depsKey, scope: scopeKey }) },
2275
2677
  )
2276
2678
  return () => { if (seq.current === id) seq.current++ }
2277
2679
  }, deps)
2278
2680
  useEffect(reload, [reload])
2279
2681
  const stale = state.key !== depsKey
2280
- return { ...state, loading: state.loading || stale, stale, reload }
2682
+ const foreign = state.scope !== scopeKey
2683
+ return { loading: state.loading || stale, value: foreign ? undefined : state.value, error: stale ? undefined : state.error, stale, reload }
2684
+ }
2685
+
2686
+ /**
2687
+ * Guard against double submits: `run(fn)` ignores calls while one is in
2688
+ * flight (checked synchronously through a ref, so two clicks in the same
2689
+ * tick still run once) and `busy` re-renders the disabled state.
2690
+ */
2691
+ function useBusy() {
2692
+ const ref = useRef(false)
2693
+ const [busy, setBusy] = useState(false)
2694
+ const alive = useRef(true)
2695
+ useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
2696
+ const run = useCallback(async (fn) => {
2697
+ if (ref.current) return undefined
2698
+ ref.current = true
2699
+ setBusy(true)
2700
+ try { return await fn() } finally { ref.current = false; if (alive.current) setBusy(false) }
2701
+ }, [])
2702
+ return [busy, run]
2703
+ }
2704
+
2705
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
2706
+
2707
+ /** Keep Tab / Shift+Tab focus inside the element while `active`. */
2708
+ function useFocusTrap(ref, active = true) {
2709
+ useEffect(() => {
2710
+ const el = ref.current
2711
+ if (!active || !el) return
2712
+ const onKey = (e) => {
2713
+ if (e.key !== 'Tab') return
2714
+ const items = [...el.querySelectorAll(FOCUSABLE)].filter(x => !x.closest('[hidden]') && x.getAttribute('aria-hidden') !== 'true')
2715
+ if (!items.length) { e.preventDefault(); return }
2716
+ const first = items[0]
2717
+ const last = items[items.length - 1]
2718
+ const cur = document.activeElement
2719
+ 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() }
2720
+ }
2721
+ el.addEventListener('keydown', onKey)
2722
+ return () => el.removeEventListener('keydown', onKey)
2723
+ }, [ref, active, ref.current])
2281
2724
  }
2282
2725
 
2283
2726
  // ------------------------------------------------------------ feedback
@@ -2314,6 +2757,7 @@ window.__ModuleLoader__.load({
2314
2757
  const [value, setValue] = useState('')
2315
2758
  const [err, setErr] = useState(null)
2316
2759
  const box = useRef(null)
2760
+ useFocusTrap(box, Boolean(d))
2317
2761
  useEffect(() => {
2318
2762
  if (!d) return
2319
2763
  setValue(d.value ?? '')
@@ -2335,7 +2779,7 @@ window.__ModuleLoader__.load({
2335
2779
  return h(React.Fragment, null,
2336
2780
  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
2781
  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() } },
2782
+ 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
2783
  h('div', { id: 'n2s-dlg-title', className: 'n2s-modal-title', style: { marginBottom: 8 } }, d.title),
2340
2784
  d.message && h('div', { className: 'n2s-muted', style: { fontSize: 13.5, marginBottom: 12, whiteSpace: 'pre-wrap' } }, d.message),
2341
2785
  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 +2789,22 @@ window.__ModuleLoader__.load({
2345
2789
  h('button', { type: 'button', className: `n2s-btn ${d.danger ? 'danger-solid' : 'primary'}`, onClick: ok }, d.confirmLabel ?? t('fb2.ok'))))), document.body))
2346
2790
  }
2347
2791
 
2792
+
2793
+ /** Mount the FeedbackLayer once into its own root under document.body; returns the disposer. */
2794
+ function mountFeedbackLayer(ctx) {
2795
+ const el = document.createElement('div')
2796
+ el.className = 'n2s-feedback-root'
2797
+ el.dataset.plugin = PKG
2798
+ document.body.appendChild(el)
2799
+ const root = ReactDOMClient.createRoot(el)
2800
+ function GlobalFeedback() {
2801
+ const t = useMemo(() => ctx.locale.bind(NS), [])
2802
+ setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
2803
+ return h(FeedbackLayer, { t })
2804
+ }
2805
+ root.render(h(GlobalFeedback))
2806
+ 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) }
2807
+ }
2348
2808
  // ----------------------------------------------------------- charts
2349
2809
 
2350
2810
  let echartsPromise
@@ -2551,8 +3011,11 @@ window.__ModuleLoader__.load({
2551
3011
  const [sending, setSending] = useState(false)
2552
3012
  const [rating, setRating] = useState(null)
2553
3013
  const [drill, setDrill] = useState(null)
3014
+ const ratingBusy = useRef(false)
2554
3015
  useEffect(() => { setType(result.chart?.type ?? 'table') }, [result.resultId])
2555
3016
  const rate = async (value, comment) => {
3017
+ if (ratingBusy.current) return
3018
+ ratingBusy.current = true
2556
3019
  try {
2557
3020
  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
3021
  setRated(value)
@@ -2560,11 +3023,15 @@ window.__ModuleLoader__.load({
2560
3023
  if (value === 'down') onFeedback?.down?.(comment)
2561
3024
  } catch (e) {
2562
3025
  notifyError(e)
3026
+ } finally {
3027
+ ratingBusy.current = false
2563
3028
  }
2564
3029
  }
2565
3030
  const chart = useMemo(() => (type === result.chart?.type ? result.chart : chartFor(type, result, result.suggestedChart ?? result.chart)) ?? { type: 'table' }, [type, result])
2566
3031
  const available = useMemo(() => CHART_CHOICES.filter(c => c === 'table' || chartFor(c, result, result.chart)), [result])
2567
- const pin = async () => {
3032
+ const [pinning, runPin] = useBusy()
3033
+ const pin = () => runPin(async () => {
3034
+ if (pinned) return
2568
3035
  try {
2569
3036
  await call('pinWidget', { title: result.title || result.sql.slice(0, 60), sql: result.sql, datasourceId: result.datasourceId, chart })
2570
3037
  setPinned(true)
@@ -2572,8 +3039,9 @@ window.__ModuleLoader__.load({
2572
3039
  } catch (e) {
2573
3040
  notifyError(e)
2574
3041
  }
2575
- }
2576
- const saveExample = async () => {
3042
+ })
3043
+ const [savingExample, runSaveExample] = useBusy()
3044
+ const saveExample = () => runSaveExample(async () => {
2577
3045
  const q = await promptDialog({ title: t('kb.examplePrompt'), value: question || result.title || '' })
2578
3046
  if (!q) return
2579
3047
  try {
@@ -2582,7 +3050,7 @@ window.__ModuleLoader__.load({
2582
3050
  } catch (e) {
2583
3051
  notifyError(e)
2584
3052
  }
2585
- }
3053
+ })
2586
3054
  const rerun = async (confirmScan) => {
2587
3055
  setRunning(true); setEditError(null)
2588
3056
  try {
@@ -2626,7 +3094,8 @@ window.__ModuleLoader__.load({
2626
3094
  isChart && result.chart?.x && result.rows.length >= 2 && { label: t('drill.why'), icon: Icon.spark(14), onClick: () => setDrill({ mode: 'why' }) },
2627
3095
  isChart && chartInstance && { label: t('result.png'), icon: Icon.download(14), onClick: exportPng },
2628
3096
  !hidden && { divider: true },
2629
- !hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample, onClick: saveExample },
3097
+ !hidden && { label: savedExample ? t('kb.exampleSaved') : t('kb.saveExample'), icon: Icon.spark(14), disabled: savedExample || savingExample, onClick: saveExample },
3098
+ ...extItems(extend.resultMenu, { t, result }),
2630
3099
  ]
2631
3100
  const chartPicker = result.rows.length > 0 && h(Dropdown, {
2632
3101
  label: t('result.chartType'),
@@ -2644,6 +3113,7 @@ window.__ModuleLoader__.load({
2644
3113
  h('span', { className: 'n2s-grow' }),
2645
3114
  chartPicker),
2646
3115
  body,
3116
+ ...extend.resultPanels.map((C, k) => h(C, { key: `rp${k}`, t, result })),
2647
3117
  h('div', { className: 'n2s-result-foot' },
2648
3118
  !hideSql && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showSql, onClick: () => setShowSql(v => !v) }, Icon.code(14), t('result.sql')),
2649
3119
  isChart && result.rows.length > 0 && !compact && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showTable, onClick: () => setShowTable(v => !v) }, Icon.table(14), t('result.data')),
@@ -2653,7 +3123,7 @@ window.__ModuleLoader__.load({
2653
3123
  embedded && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'result.rows', { count: result.rowCount }), ' · ', fmt(t, 'result.elapsed', { ms: result.elapsedMs })),
2654
3124
  !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
3125
  !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()),
3126
+ !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
3127
  h('button', { type: 'button', className: 'n2s-iconbtn', title: t('im.share'), 'aria-label': t('im.share'), onClick: () => setSending(true) }, Icon.send()),
2658
3128
  h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: moreItems })),
2659
3129
  rating === 'down' && h(FeedbackForm, { t, onCancel: () => setRating(null), onSubmit: (comment) => rate('down', comment) }),
@@ -2838,6 +3308,18 @@ window.__ModuleLoader__.load({
2838
3308
  const [state, setState] = useState(null)
2839
3309
  const [drag, setDrag] = useState(false)
2840
3310
  const inputRef = useRef(null)
3311
+ const box = useRef(null)
3312
+ useFocusTrap(box)
3313
+ const busyRef = useRef(false)
3314
+ busyRef.current = Boolean(state?.busy)
3315
+ useEffect(() => {
3316
+ const prev = document.activeElement
3317
+ setTimeout(() => box.current?.querySelector('[role=button], button')?.focus(), 0)
3318
+ // Escape works wherever focus is (not only inside the dialog), but not under a confirm dialog or mid-upload.
3319
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) onClose() }
3320
+ document.addEventListener('keydown', onKey)
3321
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
3322
+ }, [])
2841
3323
  const pick = async (f) => {
2842
3324
  if (!f) return
2843
3325
  setFile(f); setSheets(null); setState({ busy: true }); setProgress(0)
@@ -2850,6 +3332,8 @@ window.__ModuleLoader__.load({
2850
3332
  } catch (e) { setState({ error: e.message }) }
2851
3333
  }
2852
3334
  const run = async () => {
3335
+ if (busyRef.current) return
3336
+ busyRef.current = true
2853
3337
  setState({ busy: true })
2854
3338
  try {
2855
3339
  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 +3344,7 @@ window.__ModuleLoader__.load({
2860
3344
  }
2861
3345
  const typeOpts = ['TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME']
2862
3346
  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() } },
3347
+ h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('up.title'), style: { width: 'min(860px, 100%)' } },
2864
3348
  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
3349
  !sheets && h('div', {
2866
3350
  className: `n2s-drop ${drag ? 'over' : ''}`, role: 'button', tabIndex: 0,
@@ -2890,19 +3374,19 @@ window.__ModuleLoader__.load({
2890
3374
  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
3375
  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
3376
  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(';')),
3377
+ 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
3378
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
2895
3379
  h('span', { className: 'n2s-faint n2s-small n2s-grow' }, t('up.privacy')),
2896
3380
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, state?.done ? t('ds.close') : t('ds.cancel')),
2897
3381
  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
3382
  }
2899
3383
  function FileTables({ t, ds }) {
2900
- const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
3384
+ const list = useAsync(() => call('listFileTables', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
2901
3385
  const tables = list.value ?? []
2902
3386
  if (!tables.length) return null
2903
3387
  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
3388
  `${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)))))
3389
+ 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
3390
  }
2907
3391
 
2908
3392
  function SourcesTab({ t, datasources }) {
@@ -2949,7 +3433,7 @@ window.__ModuleLoader__.load({
2949
3433
  h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
2950
3434
  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
3435
  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() } },
3436
+ !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
3437
  ds.type === 'sqlite' && { label: t('up.addFile'), icon: Icon.up(14), onClick: () => setUpload({ target: ds.id }) },
2954
3438
  ds.type !== 'sqlite' && { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(ds) },
2955
3439
  { 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 +3482,12 @@ window.__ModuleLoader__.load({
2998
3482
  }
2999
3483
  const quoteTable = (name) => ds.type === 'mysql' ? `\`${name.replace(/`/g, '``')}\`` : ds.type === 'postgres' ? name.split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.') : `"${name.replace(/"/g, '""')}"`
3000
3484
  const runOverview = async (tb, pin) => {
3485
+ const forId = ds.id
3001
3486
  setPreview({ busy: true })
3002
3487
  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 }) }
3488
+ const res = await call('tableOverview', { datasourceId: forId, table: tb.name, pin })
3489
+ if (currentDs.current === forId) setPreview({ overview: res.results, pinned: pin })
3490
+ } catch (e) { if (currentDs.current === forId) setPreview({ error: e.message }) }
3006
3491
  }
3007
3492
  // Default to the largest fact table so the detail pane is never blank.
3008
3493
  const defaultTable = schema ? ([...schema.tables].sort((x, y) => (y.role === 'fact') - (x.role === 'fact') || (y.rows ?? 0) - (x.rows ?? 0))[0]?.name ?? null) : null
@@ -3011,6 +3496,7 @@ window.__ModuleLoader__.load({
3011
3496
  const rels2 = tbl ? schema.relations.filter(r => r.from.table === tbl.name || r.to.table === tbl.name) : []
3012
3497
  const keyIcon = (c) => c.key === 'PRI' ? h('span', { className: 'n2s-colkey pk', title: 'PRIMARY KEY' }, 'PK') : c.key === 'UNI' ? h('span', { className: 'n2s-colkey', title: 'UNIQUE' }, 'UQ') : c.key === 'MUL' ? h('span', { className: 'n2s-colkey', title: 'INDEX' }, 'IX') : null
3013
3498
  return h('div', null,
3499
+ schema && extend.schemaPanels.map((C, k) => h(C, { key: `sp${k}`, t, ds, schema, selected })),
3014
3500
  h('div', { className: 'n2s-toolbar' },
3015
3501
  schema && h('span', { className: 'n2s-muted n2s-small' }, fmt(t, 'schema.tables', { count: schema.tables.length }), ' · ', fmt(t, 'schema.relations', { count: schema.relations.length }), ' · ', fmt(t, 'schema.analyzedAt', { time: new Date(schema.analyzedAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) })),
3016
3502
  h('span', { className: 'n2s-grow' }),
@@ -3076,7 +3562,7 @@ window.__ModuleLoader__.load({
3076
3562
  * Hidden once every required step is done or the user dismisses it.
3077
3563
  */
3078
3564
  function Onboarding({ t, ds, onNavigate, onAsk, examples }) {
3079
- const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt])
3565
+ const st = useAsync(() => call('onboardingStatus').catch(() => null), [ds?.id, ds?.schemaAnalyzedAt], [])
3080
3566
  const [busy, setBusy] = useState(false)
3081
3567
  const v = st.value
3082
3568
  if (!v || v.dismissed) return null
@@ -3115,12 +3601,21 @@ window.__ModuleLoader__.load({
3115
3601
  const [exOffset, setExOffset] = useState(0)
3116
3602
  const [recentLimit, setRecentLimit] = useState(8)
3117
3603
  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
3604
+ // Data source switched: reset paging and per-source results during render, so the
3605
+ // first request for the new source already uses offset 0 (no extra fetch).
3606
+ const [stateFor, setStateFor] = useState(ds?.id)
3607
+ if (stateFor !== ds?.id) {
3608
+ setStateFor(ds?.id)
3609
+ setExOffset(0); setRecentLimit(8); setHiddenRecent(new Set())
3610
+ setResult(null); setPlan(null); setError(null)
3611
+ }
3612
+ const pageOffset = stateFor === ds?.id ? exOffset : 0
3613
+ const pageRecent = stateFor === ds?.id ? recentLimit : 8
3614
+ // Scoped by data source + language: within one source the previous page stays on
3615
+ // screen while the next loads (no flicker on 换一批); another source's never does.
3616
+ 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])
3617
+ useEffect(() => { if (sugg.value && !sugg.stale) setHiddenRecent(new Set()) }, [sugg.value])
3618
+ const data = sugg.value
3124
3619
  const examples = data?.examples?.length ? data.examples : [t('ask.ex.tables'), t('ask.ex.biggest')]
3125
3620
  const canShuffle = (data?.total ?? 0) > (data?.pageSize ?? 6)
3126
3621
  const recent = (data?.recent ?? []).filter(r => !hiddenRecent.has(r.question))
@@ -3129,27 +3624,33 @@ window.__ModuleLoader__.load({
3129
3624
  setHiddenRecent(x => new Set(x).add(r.question))
3130
3625
  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
3626
  }
3627
+ const currentDs = useRef(ds?.id)
3628
+ currentDs.current = ds?.id
3132
3629
  const run = async (confirmScan = false) => {
3630
+ const forId = ds.id
3133
3631
  setRunning(true); setError(null)
3134
3632
  try {
3135
- setResult(await call('runQuery', { sql, datasourceId: ds.id, title: '', confirmScan }))
3633
+ const r = await call('runQuery', { sql, datasourceId: forId, title: '', confirmScan })
3634
+ if (currentDs.current === forId) setResult(r)
3136
3635
  } 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)
3636
+ if (e.code === 'nl2sql/scan-guard' && currentDs.current === forId && await confirmDialog({ title: t('sql.confirmScan'), message: e.message })) { setRunning(false); return run(true) }
3637
+ if (currentDs.current === forId) setError(e.message)
3139
3638
  }
3140
3639
  setRunning(false)
3141
3640
  }
3142
3641
  const explain = async () => {
3642
+ const forId = ds.id
3143
3643
  setRunning(true); setError(null)
3144
- try { setPlan(await call('explain', { sql, datasourceId: ds.id })) } catch (e) { setError(e.message) }
3644
+ 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
3645
  setRunning(false)
3146
3646
  }
3147
3647
  const submit = () => { if (question.trim()) { onAsk(question.trim()); setQuestion('') } }
3148
3648
  const [uploading, setUploading] = useState(false)
3149
3649
  const [sqlOpen, setSqlOpen] = useState(false)
3150
- const tableCount = ds?.tableCount ?? sugg.value?.tableCount
3650
+ const tableCount = ds?.tableCount ?? data?.tableCount
3151
3651
  return h('div', null,
3152
3652
  h(Onboarding, { t, ds, onNavigate, onAsk, examples }),
3653
+ ...extend.askPanels.map((C, k) => h(C, { key: `ap${k}`, t, ds, onAsk, onNavigate })),
3153
3654
  h('div', { className: 'n2s-hero' },
3154
3655
  h('h2', null, t('ask.heroTitle')),
3155
3656
  h('p', null, ds ? (ds.schemaAnalyzedAt ? (tableCount ? fmt(t, 'ask.heroSub', { name: ds.name, tables: tableCount }) : fmt(t, 'ask.heroSubShort', { name: ds.name })) : fmt(t, 'ask.heroSubNoSchema', { name: ds.name })) : t('ask.hint'))),
@@ -3196,15 +3697,16 @@ window.__ModuleLoader__.load({
3196
3697
  result && h('div', { className: 'n2s-card', style: { marginTop: 12 } }, h(ResultView, { result, t, hideSql: true })))))
3197
3698
  }
3198
3699
 
3199
- /** "3 分钟前" style relative time. */
3700
+ /** "3 分钟前" style relative time in the UI language; future or older than a week → the date. */
3200
3701
  function relTime(t, at) {
3201
3702
  if (!at) return ''
3202
3703
  const d = (Date.now() - at) / 1000
3704
+ if (d < -60) return new Date(at).toLocaleDateString(uiLocale())
3203
3705
  if (d < 60) return t('time.justNow')
3204
3706
  if (d < 3600) return fmt(t, 'time.minAgo', { n: Math.floor(d / 60) })
3205
3707
  if (d < 86400) return fmt(t, 'time.hourAgo', { n: Math.floor(d / 3600) })
3206
3708
  if (d < 86400 * 7) return fmt(t, 'time.dayAgo', { n: Math.floor(d / 86400) })
3207
- return new Date(at).toLocaleDateString()
3709
+ return new Date(at).toLocaleDateString(uiLocale())
3208
3710
  }
3209
3711
 
3210
3712
  function PlanTable({ plan }) {
@@ -3290,6 +3792,7 @@ window.__ModuleLoader__.load({
3290
3792
  }
3291
3793
  function KnowledgeEditor({ kind, item, t, fields, schema, noteKeys, onCancel, onSave }) {
3292
3794
  const [form, setForm] = useState(() => ({ ...item, ...(Array.isArray(item?.synonyms) ? { synonyms: item.synonyms.join(',') } : {}) }))
3795
+ const [busy, runBusy] = useBusy()
3293
3796
  return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
3294
3797
  h('div', { className: 'n2s-form' }, fields.map(([key, label, type]) => h('label', { key, className: `n2s-field ${type === 'textarea' || type?.startsWith('code') ? 'full' : ''}` },
3295
3798
  h('span', null, t(label)),
@@ -3300,19 +3803,19 @@ window.__ModuleLoader__.load({
3300
3803
  h('datalist', { id: 'n2s-notekeys' }, noteKeys.slice(0, 2000).map(k2 => h('option', { key: k2, value: k2 }))),
3301
3804
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
3302
3805
  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'))))
3806
+ 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
3807
  }
3305
3808
 
3306
3809
  function KnowledgeTab({ t, ds }) {
3307
3810
  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])
3811
+ const schema = useAsync(() => (ds?.schemaAnalyzedAt ? call('analyzeSchema', { id: ds.id }) : null), [ds?.id, ds?.schemaAnalyzedAt], [ds?.id])
3309
3812
  const [section, setSection] = useState('glossary')
3310
3813
  const [editing, setEditing] = useState(null)
3311
3814
  const [error, setError] = useState(null)
3312
3815
  const [drafts, setDrafts] = useState(null)
3313
3816
  const noteKeys = useMemo(() => (schema.value?.tables ?? []).flatMap(tb => [tb.name, ...tb.columns.map(c => `${tb.name}.${c.name}`)]), [schema.value])
3314
3817
  const allDatasources = useStore(s2 => s2.datasources)
3315
- const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value])
3818
+ const sgCount = useAsync(() => (ds ? call('suggestionCount', { datasourceId: ds.id }) : null), [ds?.id, kb.value], [ds?.id])
3316
3819
  if (!ds) return null
3317
3820
  const k = kb.value ?? { glossary: [], metrics: [], notes: {}, examples: [] }
3318
3821
  const save = async (kind, item) => {
@@ -3321,7 +3824,7 @@ window.__ModuleLoader__.load({
3321
3824
  }
3322
3825
  const remove = async (kind, id) => {
3323
3826
  if (!await confirmDialog({ title: t('kb.deleteConfirm'), danger: true })) return
3324
- await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id }).catch(e => setError(e.message))
3827
+ try { await call('deleteKnowledgeItem', { datasourceId: ds.id, kind, id }) } catch (e) { setError(e.message) }
3325
3828
  kb.reload()
3326
3829
  }
3327
3830
  const FIELDS = {
@@ -3346,7 +3849,7 @@ window.__ModuleLoader__.load({
3346
3849
  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
3850
  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
3851
  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'))
3852
+ 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
3853
  if (section === 'note') {
3351
3854
  const entries = Object.entries(k.notes).sort(([a], [b]) => a.localeCompare(b))
3352
3855
  list = h(React.Fragment, null,
@@ -3371,7 +3874,7 @@ window.__ModuleLoader__.load({
3371
3874
  }
3372
3875
  function HistoryTab({ t, datasources, onOpenSession }) {
3373
3876
  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])
3877
+ const list = useAsync(() => call('listHistory', { ...filters, limit: 300 }), [filters.status, filters.source, filters.datasourceId, filters.search], [])
3375
3878
  const [open, setOpen] = useState(null)
3376
3879
  const [rerun, setRerun] = useState(null)
3377
3880
  const [removed, setRemoved] = useState(() => new Set())
@@ -3397,13 +3900,7 @@ window.__ModuleLoader__.load({
3397
3900
  }, [rows])
3398
3901
  const [search, setSearch] = useState('')
3399
3902
  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
- }
3903
+ const rel = (at) => relTime(t, at)
3407
3904
  const statusDot = (st) => h('span', { className: `n2s-statusdot ${st === 'error' ? 'err' : st === 'blocked' ? 'warn' : 'ok'}` }, t(`hist.status.${st ?? 'ok'}`))
3408
3905
  const oneLine = (sql) => sql.replace(/\s+/g, ' ').trim()
3409
3906
  return h('div', null,
@@ -3418,7 +3915,7 @@ window.__ModuleLoader__.load({
3418
3915
  h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
3419
3916
  { label: t('hist.export'), icon: Icon.download(14), disabled: rows.length === 0, onClick: exportCsv },
3420
3917
  { 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() } } },
3918
+ { 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
3919
  ] })),
3423
3920
  list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
3424
3921
  list.loading && !list.value && h('div', { className: 'n2s-skel', style: { height: 240 } }),
@@ -3432,7 +3929,7 @@ window.__ModuleLoader__.load({
3432
3929
  h('span', { className: 'n2s-hist-what' },
3433
3930
  h('span', { className: `n2s-hist-title ${r.title ? '' : 'none'}` }, r.title || r.error?.slice(0, 80) || t('hist.untitled')),
3434
3931
  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 ?? ''}`)),
3932
+ 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
3933
  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
3934
  h('span', { className: 'n2s-hist-chev' }, Icon.chev(12))),
3438
3935
  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 +3939,7 @@ window.__ModuleLoader__.load({
3442
3939
  h('pre', { className: 'n2s-code', style: { maxHeight: 220, paddingRight: 44 } }, prettySql(r.sql)),
3443
3940
  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
3941
  h('div', { className: 'n2s-row' },
3445
- h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString()),
3942
+ h('span', { className: 'n2s-faint n2s-small n2s-grow' }, new Date(r.at).toLocaleString(uiLocale())),
3446
3943
  r.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onOpenSession(r.sessionId) }, Icon.external(13), t('hist.openSession')),
3447
3944
  h('button', {
3448
3945
  type: 'button', className: 'n2s-btn small',
@@ -3455,6 +3952,15 @@ window.__ModuleLoader__.load({
3455
3952
  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
3953
  })))
3457
3954
  }
3955
+
3956
+ const HIST_SOURCES = ['agent', 'ui', 'dashboard', 'alert', 'eval', 'larkbot', 'telegrambot', 'wecombot', 'dingtalkbot']
3957
+ /** Label of a history `source`; unknown sources (a new bot platform…) fall back to a generic label. */
3958
+ function sourceLabel(t, source) {
3959
+ if (!source) return t('hist.source.unknown')
3960
+ if (HIST_SOURCES.includes(source)) return t(`hist.source.${source}`)
3961
+ const bot = /^(.+)bot$/.exec(source)
3962
+ return bot ? fmt(t, 'hist.source.genericBot', { platform: bot[1] }) : source
3963
+ }
3458
3964
  function OverviewGrid({ t, results, pinned }) {
3459
3965
  const [sending, setSending] = useState(false)
3460
3966
  const ok = results.filter(r => !r.error && r.resultId)
@@ -3532,7 +4038,7 @@ window.__ModuleLoader__.load({
3532
4038
  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
4039
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
3534
4040
  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')}`)),
4041
+ 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
4042
  h('div', { className: 'n2s-list2-side' },
3537
4043
  statusDot(r.status),
3538
4044
  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 +4054,32 @@ window.__ModuleLoader__.load({
3548
4054
  }
3549
4055
  function FeedbackForm({ t, onCancel, onSubmit }) {
3550
4056
  const [comment, setComment] = useState('')
4057
+ const [busy, runBusy] = useBusy()
3551
4058
  const reasons = ['fb.reason.wrongTable', 'fb.reason.wrongMetric', 'fb.reason.wrongFilter', 'fb.reason.wrongTime', 'fb.reason.chart']
3552
4059
  return h('div', { className: 'n2s-card', style: { padding: 10 } },
3553
4060
  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)))),
4061
+ 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
4062
  h('textarea', { className: 'n2s-textarea', style: { minHeight: 56 }, value: comment, onChange: e => setComment(e.target.value), placeholder: t('fb.commentPh') }),
3556
4063
  h('div', { className: 'n2s-row', style: { marginTop: 8, justifyContent: 'flex-end' } },
3557
4064
  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'))))
4065
+ 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
4066
  }
3560
4067
 
3561
4068
  function FeedbackSection({ t, ds }) {
3562
4069
  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])
4070
+ const list = useAsync(() => call('listFeedback', { datasourceId: ds?.id, ...(status ? { status } : {}) }), [ds?.id, status], [ds?.id])
4071
+ const stats = useAsync(() => call('feedbackStats', { datasourceId: ds?.id }), [ds?.id, list.value], [ds?.id])
3565
4072
  const [fixing, setFixing] = useState(null)
3566
4073
  const [error, setError] = useState(null)
3567
- const resolve = async (payload) => {
4074
+ const [busy, runBusy] = useBusy()
4075
+ const resolve = (payload) => runBusy(async () => {
3568
4076
  setError(null)
3569
4077
  try { await call('resolveFeedback', payload); setFixing(null); list.reload() } catch (e) { setError(e.message) }
4078
+ })
4079
+ const remove = async (f) => {
4080
+ if (!await confirmDialog({ title: t('fb.deleteConfirm'), message: f.question || f.title || '', confirmLabel: t('ds.delete'), danger: true })) return
4081
+ try { await call('deleteFeedback', { id: f.id }) } catch (e) { notifyError(e) }
4082
+ list.reload()
3570
4083
  }
3571
4084
  const st = stats.value
3572
4085
  return h('div', null,
@@ -3588,13 +4101,13 @@ window.__ModuleLoader__.load({
3588
4101
  h('span', { className: 'n2s-muted n2s-small' }, new Date(f.at).toLocaleString())),
3589
4102
  f.comment && h('div', { className: 'n2s-small', style: { marginTop: 4 } }, f.comment),
3590
4103
  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}`)),
4104
+ f.resolution && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 4 } }, `${colon(t('fb.resolution'))}${f.resolution}`)),
3592
4105
  h('div', { style: { display: 'grid', gap: 4 } },
3593
4106
  f.sessionId && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => fbCtx.openSession?.(f.sessionId) }, t('hist.openSession')),
3594
4107
  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
4108
  f.status === 'open' && h('button', { type: 'button', className: 'n2s-btn small', onClick: () => resolve({ id: f.id, status: 'ignored' }) }, t('fb.ignore')),
3596
4109
  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')))),
4110
+ h('button', { type: 'button', className: 'n2s-btn small ghost danger', onClick: () => remove(f) }, t('ds.delete')))),
3598
4111
  fixing?.id === f.id && h('div', { className: 'n2s-ask', style: { marginTop: 10 } },
3599
4112
  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
4113
  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 +4153,16 @@ window.__ModuleLoader__.load({
3640
4153
  area('maskedColumns', 'policy.maskedColumns', 'users.phone\\n*.email')),
3641
4154
  h('div', null,
3642
4155
  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 => {
4156
+ h('div', { className: 'n2s-list', role: 'group', 'aria-label': t('policy.tablesLabel'), style: { maxHeight: 320 } }, tables.map(tb => {
3644
4157
  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))
4158
+ const editable = form.mode !== 'all'
4159
+ return h('div', {
4160
+ key: tb.name, className: 'n2s-item', style: { cursor: editable ? 'pointer' : 'default', opacity: ok ? 1 : 0.5 },
4161
+ role: 'checkbox', 'aria-checked': ok, 'aria-disabled': editable ? undefined : true, tabIndex: editable ? 0 : -1, 'aria-label': tb.name,
4162
+ onClick: () => editable && toggleTable(tb.name),
4163
+ onKeyDown: (e) => { if (editable && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); toggleTable(tb.name) } },
4164
+ },
4165
+ h('span', { 'aria-hidden': true }, ok ? '✓' : '✕'), h('span', { className: 'name n2s-mono' }, tb.name), roleBadge(t, tb.role))
3647
4166
  })),
3648
4167
  form.mode !== 'all' && tables.length > 0 && h('div', { className: 'n2s-muted n2s-small', style: { marginTop: 6 } }, t('policy.clickHint')))),
3649
4168
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
@@ -3662,8 +4181,16 @@ window.__ModuleLoader__.load({
3662
4181
  const next = patch === null ? null : { kind: 'time', column: time?.column ?? cand.value?.time[0]?.column ?? 'created_at', range: 'last30d', ...time, ...patch }
3663
4182
  onChange([...(next ? [next] : []), ...ins])
3664
4183
  }
3665
- const setIn = (column, values) => onChange([...(time ? [time] : []), ...ins.filter(f => f.column !== column), ...(values.length ? [{ kind: 'in', column, values }] : [])])
4184
+ // keep: an emptied free-text field keeps its tag (with no values) instead of disappearing.
4185
+ const setIn = (column, values, keep) => {
4186
+ const others = ins.filter(f => f.column !== column)
4187
+ const at = ins.findIndex(f => f.column === column)
4188
+ const mine = values.length || keep ? [{ kind: 'in', column, values }] : []
4189
+ const next = at < 0 ? [...others, ...mine] : [...others.slice(0, at), ...mine, ...others.slice(at)]
4190
+ onChange([...(time ? [time] : []), ...next])
4191
+ }
3666
4192
  const c = cand.value ?? { time: [], dims: [] }
4193
+ const activeN = filters.filter(f => f.kind !== 'in' || f.values.length > 0).length
3667
4194
  return h('div', { className: 'n2s-filterbar', role: 'group', 'aria-label': t('filter.title') },
3668
4195
  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
4196
  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 +4207,7 @@ window.__ModuleLoader__.load({
3680
4207
  h('span', { className: 'n2s-muted' }, dim?.label ?? f.column),
3681
4208
  dim?.values?.length
3682
4209
  ? 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)) }),
4210
+ : h(FilterValuesInput, { key: f.column, t, label: dim?.label ?? f.column, values: f.values, onCommit: (values) => setIn(f.column, values, true) }),
3684
4211
  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
4212
  }),
3686
4213
  c.dims.filter(d => !ins.some(f => f.column === d.column)).length > 0 && h(Dropdown, {
@@ -3689,9 +4216,39 @@ window.__ModuleLoader__.load({
3689
4216
  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
4217
  }),
3691
4218
  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 })),
4219
+ activeN > 0 && h('span', { className: 'n2s-faint n2s-small', title: t('filter.hint') }, fmt(t, 'filter.activeN', { count: activeN })),
3693
4220
  filters.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => onChange([]) }, t('filter.clear')))
3694
4221
  }
4222
+
4223
+ /**
4224
+ * Free-text values of an "in" filter: typed into a local draft (commas allowed,
4225
+ * clearing does not remove the tag) and committed on Enter, blur or after a pause.
4226
+ */
4227
+ function FilterValuesInput({ t, label, values, onCommit }) {
4228
+ const joined = values.join(', ')
4229
+ const [draft, setDraft] = useState(joined)
4230
+ const committed = useRef(joined)
4231
+ // Adopt outside changes (clear all, another board) when not mid-edit.
4232
+ useEffect(() => { if (joined !== committed.current) { committed.current = joined; setDraft(joined) } }, [joined])
4233
+ const parse = (s) => s.split(/[,,]/).map(x => x.trim()).filter(Boolean)
4234
+ const commit = (s) => {
4235
+ const next = parse(s)
4236
+ if (next.join('\u0000') === parse(committed.current).join('\u0000')) return
4237
+ committed.current = next.join(', ')
4238
+ onCommit(next)
4239
+ }
4240
+ useEffect(() => {
4241
+ if (draft === committed.current) return
4242
+ const id = setTimeout(() => commit(draft), 800)
4243
+ return () => clearTimeout(id)
4244
+ }, [draft])
4245
+ return h('input', {
4246
+ className: 'n2s-input', style: { width: 120, height: 26, border: 0 }, 'aria-label': label, placeholder: t('filter.valuesPh'), value: draft,
4247
+ onChange: e => setDraft(e.target.value),
4248
+ onBlur: () => commit(draft),
4249
+ onKeyDown: (e) => { if (e.key === 'Enter' && !e.nativeEvent?.isComposing) { e.preventDefault(); commit(draft) } },
4250
+ })
4251
+ }
3695
4252
  // ------------------------------------------------------------ IM push
3696
4253
 
3697
4254
  function makeReplyAction(ctx) {
@@ -4073,6 +4630,11 @@ window.__ModuleLoader__.load({
4073
4630
  }, [])
4074
4631
  const [state, setState] = useState(null)
4075
4632
  const ref = useRef(null)
4633
+ useFocusTrap(ref)
4634
+ const closeTimer = useRef(null)
4635
+ useEffect(() => () => clearTimeout(closeTimer.current), [])
4636
+ const busyRef = useRef(false)
4637
+ busyRef.current = Boolean(state?.busy)
4076
4638
  useEffect(() => {
4077
4639
  const def = channels.find(c => c.isDefault) ?? channels[0]
4078
4640
  if (def && !form.channelId) setForm(f => ({ ...f, channelId: def.id }))
@@ -4080,11 +4642,14 @@ window.__ModuleLoader__.load({
4080
4642
  useEffect(() => {
4081
4643
  const prev = document.activeElement
4082
4644
  ref.current?.querySelector('select, textarea, input, button')?.focus()
4083
- const onKey = (e) => { if (e.key === 'Escape') onClose() }
4645
+ // Escape belongs to a confirm/prompt dialog on top, if any.
4646
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) onClose() }
4084
4647
  document.addEventListener('keydown', onKey)
4085
4648
  return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
4086
4649
  }, [])
4087
4650
  const send = async () => {
4651
+ if (busyRef.current) return
4652
+ busyRef.current = true
4088
4653
  setState({ busy: true })
4089
4654
  try {
4090
4655
  const res = initial.dashboard
@@ -4092,7 +4657,7 @@ window.__ModuleLoader__.load({
4092
4657
  : 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
4658
  setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
4094
4659
  refreshChannels()
4095
- setTimeout(onClose, 1200)
4660
+ closeTimer.current = setTimeout(onClose, 1200)
4096
4661
  } catch (e) {
4097
4662
  setState({ error: e.message })
4098
4663
  }
@@ -4159,6 +4724,7 @@ window.__ModuleLoader__.load({
4159
4724
  /** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
4160
4725
  function Modal({ label, labelledBy, onClose, width, children, className }) {
4161
4726
  const box = useRef(null)
4727
+ useFocusTrap(box)
4162
4728
  useEffect(() => {
4163
4729
  const prev = document.activeElement
4164
4730
  setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
@@ -4184,13 +4750,13 @@ window.__ModuleLoader__.load({
4184
4750
  h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
4185
4751
  p && h(React.Fragment, null,
4186
4752
  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) },
4753
+ h('ul', { className: 'n2s-kindcards', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
4754
+ (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
4755
  h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
4190
4756
  k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
4191
4757
  h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
4192
4758
  h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
4193
- k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl'))))))))
4759
+ k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl')))))))))
4194
4760
  }
4195
4761
 
4196
4762
  // ---------------------------------------------------------------- step 2
@@ -4427,7 +4993,7 @@ window.__ModuleLoader__.load({
4427
4993
 
4428
4994
  // ----------------------------------------------------------------- list
4429
4995
 
4430
- const shortTime = (ts) => new Date(ts).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
4996
+ const shortTime = (ts) => new Date(ts).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
4431
4997
 
4432
4998
  function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
4433
4999
  const kind = findKind(catalog, c.type)?.kind
@@ -4491,7 +5057,7 @@ window.__ModuleLoader__.load({
4491
5057
  const channels = useChannels()
4492
5058
  const bots = useBots()
4493
5059
  const { catalog } = useCatalog(t)
4494
- const deliveries = useAsync(() => call('listDeliveries'), [channels])
5060
+ const deliveries = useAsync(() => call('listDeliveries'), [channels], [])
4495
5061
  const [editing, setEditing] = useState(null)
4496
5062
  // Pick up host upgrades (catalog, new channel shape) whenever the page opens.
4497
5063
  useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
@@ -4617,7 +5183,12 @@ window.__ModuleLoader__.load({
4617
5183
  useEffect(() => { if (!saved.loading && saved.value) { setFilters(saved.value); setFiltersFor(boardId) } }, [saved.value, saved.loading, boardId])
4618
5184
  const filtersReady = filters !== null && filtersFor === boardId
4619
5185
  const [sendingDash, setSendingDash] = useState(false)
4620
- const changeFilters = (next) => { setFilters(next); call('saveDashboardFilters', { filters: next }).catch(() => {}) }
5186
+ // Saves are serialized so an older save can never land after a newer one.
5187
+ const saveChain = useRef(Promise.resolve())
5188
+ const changeFilters = (next) => {
5189
+ setFilters(next)
5190
+ saveChain.current = saveChain.current.then(() => call('saveDashboardFilters', { filters: next })).catch((e) => notifyError(e))
5191
+ }
4621
5192
  const otherBoards = (boards.value?.dashboards ?? []).filter(d => d.id !== boards.value?.current)
4622
5193
  // Per-widget load status → header summary ("updated 10:42 · 2 from cache").
4623
5194
  const [loads, setLoads] = useState({})
@@ -4680,7 +5251,7 @@ window.__ModuleLoader__.load({
4680
5251
  h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4681
5252
  (mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
4682
5253
  ['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' ? '、' : ', ') })),
5254
+ 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
5255
  h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
4685
5256
  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
5257
  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,
@@ -4689,7 +5260,9 @@ window.__ModuleLoader__.load({
4689
5260
  h(Dropdown, { label: t('dash.export'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, Icon.download(13), h('span', { className: 'n2s-hide-narrow' }, t('dash.export')), Icon.chev(10)) }, items: [
4690
5261
  { label: t('result.excel'), icon: Icon.download(14), onClick: () => exportExcel({ dashboardId: boards.value?.current }) },
4691
5262
  { label: t('dash.snapshot'), icon: Icon.pin(14), onClick: async () => { try { const s = await call('snapshotDashboard', { dashboardId: boards.value?.current }); notify(fmt(t, 'dash.snapshotDone', { n: s.widgets.length }), 'ok') } catch (e) { notifyError(e) } } },
5263
+ ...extItems(extend.dashboardMenu, { t, boardId: boards.value?.current, widgets, filters: filters ?? [] }),
4692
5264
  ] }),
5265
+ ...extend.dashboardBar.map((C, i) => h(C, { key: `x${i}`, t, boardId: boards.value?.current, widgets, filters: filters ?? [] })),
4693
5266
  h(Dropdown, { label: t('dash.auto'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, Icon.refresh(13), h('span', { className: 'n2s-hide-narrow' }, auto ? fmt(t, 'dash.autoEvery', { min: auto / 60 }) : t('dash.autoShort')), Icon.chev(10)) },
4694
5267
  items: [0, 60, 300, 900].map(v => ({ label: v === 0 ? t('dash.autoOff') : fmt(t, 'dash.autoEvery', { min: v / 60 }), checked: auto === v, onClick: () => setAuto(v) })) }),
4695
5268
  h('button', { type: 'button', className: 'n2s-btn small', onClick: () => setTick(x => x + 1) }, t('dash.refresh')),
@@ -4701,7 +5274,7 @@ window.__ModuleLoader__.load({
4701
5274
  !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
5275
  widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
4703
5276
  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,
5277
+ 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
5278
  onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
4706
5279
  onMove: (delta) => move(w, delta),
4707
5280
  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 +5329,32 @@ window.__ModuleLoader__.load({
4756
5329
  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
5330
  h('span', { className: 'n2s-wf-num' }, `${it.delta >= 0 ? '+' : ''}${formatCompact(it.delta)}`),
4758
5331
  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' ? '、' : ', ')}`))
5332
+ 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
5333
  }
4761
5334
 
4762
5335
  // ------------------------------------------------------------ alerts
4763
5336
 
4764
5337
  function AlertForm({ t, initial, datasources, ds, onDone, onCancel }) {
4765
5338
  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 ?? {}) } }))
5339
+ 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
5340
  const k = useAsync(() => (form.datasourceId ? call('getKnowledge', { datasourceId: form.datasourceId }) : null), [form.datasourceId])
4768
5341
  const [error, setError] = useState(null)
4769
5342
  const [test, setTest] = useState(null)
5343
+ const [busy, runBusy] = useBusy()
5344
+ // Channels may load after the form opens: adopt the default once, unless the user already chose.
5345
+ const channelTouched = useRef(Boolean(initial) || Boolean(form.channelId))
5346
+ useEffect(() => {
5347
+ if (channelTouched.current) return
5348
+ const def = channels.find(c => c.isDefault)
5349
+ if (def) { channelTouched.current = true; setForm(f => ({ ...f, channelId: def.id })) }
5350
+ }, [channels])
4770
5351
  const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
4771
5352
  const setC = (patch) => setForm(f => ({ ...f, condition: { ...f.condition, ...patch } }))
4772
5353
  const metrics = k.value?.metrics ?? []
4773
- const save = async () => {
5354
+ const save = () => runBusy(async () => {
4774
5355
  setError(null)
4775
5356
  try { await call('saveAlert', { ...form, cooldownMin: Number(form.cooldownMin) }); onDone() } catch (e) { setError(e.message) }
4776
- }
5357
+ })
4777
5358
  const tryIt = async () => {
4778
5359
  if (test?.busy) return
4779
5360
  setTest({ busy: true })
@@ -4802,24 +5383,24 @@ window.__ModuleLoader__.load({
4802
5383
  h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
4803
5384
  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
5385
  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 })) })),
5386
+ 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
5387
  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}`),
5388
+ 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
5389
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
4809
5390
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
4810
5391
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
4811
5392
  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'))))
5393
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
4813
5394
  }
4814
5395
 
4815
5396
  function AlertsTab({ t, ds, datasources }) {
4816
5397
  const list = useAsync(() => call('listAlerts'), [])
4817
- const events = useAsync(() => call('listAlertEvents', {}), [list.value])
5398
+ const events = useAsync(() => call('listAlertEvents', {}), [list.value], [])
4818
5399
  const [editing, setEditing] = useState(null)
4819
5400
  const [notice, setNotice] = useState(null)
4820
5401
  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
5402
  const alerts = list.value ?? []
4822
- const sched = useAsync(() => call('schedulerStatus'), [list.value])
5403
+ const sched = useAsync(() => call('schedulerStatus'), [list.value], [])
4823
5404
  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
5405
  return h('div', null,
4825
5406
  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 +5451,7 @@ window.__ModuleLoader__.load({
4870
5451
  const id = setInterval(() => { runs.reload(); set.reload() }, 3000)
4871
5452
  return () => clearInterval(id)
4872
5453
  }, [latest?.status, latest?.id])
4873
- const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done])
5454
+ const detail = useAsync(() => (open ? call('getEvalRun', { id: open }) : null), [open, latest?.done], [open])
4874
5455
  const start = async () => { setError(null); try { await call('runEval', { datasourceId: ds.id, mode }); runs.reload() } catch (e) { setError(e.message) } }
4875
5456
  const cases = set.value ?? []
4876
5457
  return h('div', null,
@@ -4895,7 +5476,7 @@ window.__ModuleLoader__.load({
4895
5476
  c.status !== 'pass' && c.actualSql && h('details', { style: { marginTop: 6 } }, h('summary', { className: 'n2s-small n2s-muted', style: { cursor: 'pointer' } }, t('ev.compareSql')),
4896
5477
  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
5478
  !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() } }),
5479
+ 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
5480
  h('span', { className: 'n2s-grow n2s-ellipsis' }, c.question),
4900
5481
  c.lastStatus && h('span', { className: `n2s-statusdot ${c.lastStatus === 'pass' ? 'ok' : c.lastStatus === 'fail' ? 'warn' : 'err'}` }, t(`ev.status.${c.lastStatus}`))))))
4901
5482
  }
@@ -4903,16 +5484,16 @@ window.__ModuleLoader__.load({
4903
5484
  // ---------------------------------------------------- schema changes
4904
5485
 
4905
5486
  function SchemaChanges({ t, ds }) {
4906
- const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt])
5487
+ const list = useAsync(() => call('listSchemaChanges', { datasourceId: ds.id }), [ds.id, ds.schemaAnalyzedAt], [ds.id])
4907
5488
  const open = (list.value ?? []).filter(c => c.status === 'open')
4908
5489
  if (!open.length) return null
4909
5490
  const kindLabel = (k) => t(`sc.${k}`)
4910
5491
  return h('div', { className: 'n2s-notice warn', style: { marginBottom: 14 } },
4911
5492
  open.slice(0, 3).map(c => h('div', { key: c.id, style: { marginBottom: 6 } },
4912
5493
  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' ? '、' : ', ')))))
5494
+ 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'))),
5495
+ 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)}`))),
5496
+ 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
5497
  }
4917
5498
 
4918
5499
  // -------------------------------------------------- knowledge io
@@ -4920,9 +5501,26 @@ window.__ModuleLoader__.load({
4920
5501
  function KnowledgeIO({ t, ds, datasources, onImported }) {
4921
5502
  const fileRef = useRef(null)
4922
5503
  const [state, setState] = useState(null)
5504
+ const box = useRef(null)
5505
+ useFocusTrap(box, Boolean(state))
5506
+ // A dry run belongs to the data source it was computed for.
5507
+ useEffect(() => { setState(null) }, [ds?.id])
5508
+ useEffect(() => {
5509
+ if (!state) return
5510
+ const prev = document.activeElement
5511
+ setTimeout(() => box.current?.querySelector('button.primary, button')?.focus(), 0)
5512
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog && !busyRef.current) setState(null) }
5513
+ document.addEventListener('keydown', onKey)
5514
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
5515
+ }, [Boolean(state)])
5516
+ const [busy, runBusy] = useBusy()
5517
+ const busyRef = useRef(false)
5518
+ busyRef.current = busy
4923
5519
  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' }))
5520
+ try {
5521
+ const data = await call('exportKnowledge', all ? {} : { datasourceId: ds.id })
5522
+ download(`knowledge-${all ? 'all' : safeName(ds.name)}-${new Date().toISOString().slice(0, 10)}.json`, new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }))
5523
+ } catch (e) { notifyError(e) }
4926
5524
  }
4927
5525
  const onFile = async (e) => {
4928
5526
  const f = e.target.files?.[0]
@@ -4932,13 +5530,14 @@ window.__ModuleLoader__.load({
4932
5530
  const text = await f.text()
4933
5531
  const doc = JSON.parse(text)
4934
5532
  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 })
5533
+ const dsId = ds.id
5534
+ const dry = await call('importKnowledge', { data: doc, targetDatasourceId: single ? dsId : undefined, dryRun: true })
5535
+ setState({ doc, single, dry, dsId, dsName: ds.name })
4937
5536
  } catch (err) { setState({ error: err.message }) }
4938
5537
  }
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
- }
5538
+ const apply = (mode) => runBusy(async () => {
5539
+ 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 }) }
5540
+ })
4942
5541
  return h(React.Fragment, null,
4943
5542
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile }),
4944
5543
  h(Dropdown, { label: t('kb.io'), trigger: { className: 'n2s-btn', content: h(React.Fragment, null, t('kb.io'), Icon.chev(10)) }, items: [
@@ -4948,17 +5547,17 @@ window.__ModuleLoader__.load({
4948
5547
  { label: t('kb.import'), icon: Icon.up(14), onClick: () => fileRef.current?.click() },
4949
5548
  ] }),
4950
5549
  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 },
5550
+ h('div', { ref: box, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('kb.import') },
4952
5551
  h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('kb.import')),
4953
5552
  state.error && h('div', { className: 'n2s-notice err' }, state.error),
4954
5553
  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')),
5554
+ h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 8 } }, state.single ? fmt(t, 'kb.importInto', { name: state.dsName }) : t('kb.importByName')),
4956
5555
  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
5556
  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
5557
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
4959
5558
  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')))),
5559
+ 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')),
5560
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => apply('merge') }, busy ? h('span', { className: 'n2s-spin' }) : null, t('kb.merge')))),
4962
5561
  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
5562
  }
4964
5563
 
@@ -4981,7 +5580,7 @@ window.__ModuleLoader__.load({
4981
5580
  if (s.kind === 'example') return h('pre', { className: 'n2s-code', style: { marginTop: 6, maxHeight: 140 } }, prettySql(it.sql ?? ''))
4982
5581
  if (s.kind === 'metric') return h('div', null,
4983
5582
  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(' · ')))
5583
+ (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
5584
  if (s.kind === 'note') return h('div', { className: 'n2s-small' }, it.text)
4986
5585
  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
5586
  }
@@ -4999,14 +5598,17 @@ window.__ModuleLoader__.load({
4999
5598
  const [busy, setBusy] = useState(null)
5000
5599
  const items = list.value?.suggestions ?? []
5001
5600
  const done = () => { list.reload(); onChanged?.() }
5601
+ const inFlight = useRef(new Set())
5002
5602
  const accept = async (s, item) => {
5603
+ if (inFlight.current.has(s.id)) return
5604
+ inFlight.current.add(s.id)
5003
5605
  setBusy(s.id)
5004
5606
  try {
5005
5607
  await call('acceptSuggestion', { datasourceId: ds.id, id: s.id, ...(item ? { item } : {}) })
5006
5608
  setEditing(null)
5007
5609
  notify(t('sg.accepted'), 'ok')
5008
5610
  done()
5009
- } catch (e) { notifyError(e) } finally { setBusy(null) }
5611
+ } catch (e) { notifyError(e) } finally { inFlight.current.delete(s.id); setBusy(null) }
5010
5612
  }
5011
5613
  const dismiss = async (s) => {
5012
5614
  if (!await confirmDialog({ title: t('sg.dismissConfirm'), confirmLabel: t('sg.dismiss') })) return
@@ -5029,8 +5631,8 @@ window.__ModuleLoader__.load({
5029
5631
  h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
5030
5632
  h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy === s.id, onClick: () => setEditing(s) }, t('sg.accept')),
5031
5633
  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) },
5634
+ { label: t('sg.acceptEdit'), icon: Icon.edit(14), disabled: busy === s.id, onClick: () => setEditing(s) },
5635
+ { label: t('sg.acceptDirect'), icon: Icon.check(14), disabled: busy === s.id, onClick: () => accept(s) },
5034
5636
  ] }),
5035
5637
  h('button', { type: 'button', className: 'n2s-btn small ghost', disabled: busy === s.id, onClick: () => dismiss(s) }, t('sg.dismiss')))),
5036
5638
  editing?.id === s.id && h('div', { style: { marginTop: 10 } },
@@ -5119,10 +5721,10 @@ window.__ModuleLoader__.load({
5119
5721
  seen.loading && !seen.value ? h('div', { className: 'n2s-skel', style: { height: 34 } }) :
5120
5722
  items.length === 0
5121
5723
  ? 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 => {
5724
+ : 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
5725
  const taken = x.memberId && !isChosen(x)
5124
5726
  return h('button', {
5125
- key: `${x.platform}:${x.id}`, type: 'button', role: 'listitem', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
5727
+ key: `${x.platform}:${x.id}`, type: 'button', className: 'n2s-pickchip', 'aria-pressed': isChosen(x), disabled: Boolean(taken),
5126
5728
  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
5729
  onClick: () => onPick(x),
5128
5730
  },
@@ -5300,8 +5902,8 @@ window.__ModuleLoader__.load({
5300
5902
  h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
5301
5903
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5302
5904
  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(';')}`)),
5905
+ 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'))),
5906
+ (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
5907
  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
5908
  h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
5307
5909
  { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
@@ -5319,7 +5921,7 @@ window.__ModuleLoader__.load({
5319
5921
  // ---------------------------------------------------- dashboards bar
5320
5922
 
5321
5923
  function DashboardSwitcher({ t, value, onChange }) {
5322
- const list = useAsync(() => call('listDashboards'), [value?.id])
5924
+ const list = useAsync(() => call('listDashboards'), [value?.id], [])
5323
5925
  const v = list.value
5324
5926
  if (!v) return null
5325
5927
  const cur = v.dashboards.find(d => d.id === v.current)
@@ -5328,7 +5930,7 @@ window.__ModuleLoader__.load({
5328
5930
  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
5931
  h(Dropdown, { label: t('dash.manage'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, '+', Icon.chev(10)) }, items: [
5330
5932
  { 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 })) } },
5933
+ { 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
5934
  { 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
5935
  { divider: true },
5334
5936
  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 +5942,7 @@ window.__ModuleLoader__.load({
5340
5942
  const v = st.value
5341
5943
  return h('div', { className: 'n2s-row' },
5342
5944
  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')))
5945
+ 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
5946
  }
5345
5947
 
5346
5948
  function ChannelSelect({ t, value, onChange }) {
@@ -5348,8 +5950,25 @@ window.__ModuleLoader__.load({
5348
5950
  }
5349
5951
 
5350
5952
  // Questions started from the page bind their new session to the chosen
5351
- // data source as soon as the session appears in the composer.
5953
+ // data source as soon as the session appears in the composer. Only the
5954
+ // session our own startSession created may claim an ask: the one whose id
5955
+ // startSession returned (when the host returns it), otherwise a session the
5956
+ // composer had not seen before the ask was made, within PENDING_ASK_MS.
5352
5957
  const pendingAsks = []
5958
+ const seenSessions = new Set()
5959
+ const PENDING_ASK_MS = 60000
5960
+ function addPendingAsk(datasourceId, question) {
5961
+ const pending = { datasourceId, question, at: Date.now(), seen: new Set(seenSessions), sessionId: null }
5962
+ pendingAsks.push(pending)
5963
+ setTimeout(() => { const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1) }, PENDING_ASK_MS)
5964
+ return pending
5965
+ }
5966
+ /** Remove and return the pending ask that belongs to this session, if any. */
5967
+ function claimPendingAsk(sessionId) {
5968
+ const now = Date.now()
5969
+ const i = pendingAsks.findIndex(p => now - p.at < PENDING_ASK_MS && (p.sessionId ? p.sessionId === sessionId : !p.seen.has(sessionId)))
5970
+ return i >= 0 ? pendingAsks.splice(i, 1)[0] : null
5971
+ }
5353
5972
 
5354
5973
  // ------------------------------------------------ composer selector
5355
5974
 
@@ -5366,8 +5985,15 @@ window.__ModuleLoader__.load({
5366
5985
  if (!sessionId) return
5367
5986
  let cancelled = false
5368
5987
  ;(async () => {
5369
- const pending = pendingAsks.shift()
5370
- if (pending) await call('bindSession', { sessionId, datasourceId: pending.datasourceId }).catch(() => {})
5988
+ const fresh = pendingAsks.length > 0 && !seenSessions.has(sessionId)
5989
+ seenSessions.add(sessionId)
5990
+ if (fresh) {
5991
+ // A session already bound to a data source, or running another preset, is not ours.
5992
+ const info = await call('sessionInfo', { sessionId }).catch(() => null)
5993
+ const candidate = info && !info.boundDatasourceId && (info.preset === PRESET || info.preset === null || info.preset === undefined)
5994
+ const pending = candidate ? claimPendingAsk(sessionId) : null
5995
+ if (pending) await call('bindSession', { sessionId, datasourceId: pending.datasourceId }).catch(() => {})
5996
+ }
5371
5997
  if (!cancelled) await loadSessionInfo(sessionId)
5372
5998
  })()
5373
5999
  if (!store.get().loaded) refreshDatasources()
@@ -5502,7 +6128,13 @@ window.__ModuleLoader__.load({
5502
6128
  // where the user was.
5503
6129
 
5504
6130
  const PRIMARY_TABS = ['ask', 'dashboard', 'schema', 'knowledge', 'history']
5505
- const ADMIN_SECTIONS = ['sources', 'members', 'automation', 'push', 'settings']
6131
+ const BASE_ADMIN_SECTIONS = ['sources', 'members', 'automation', 'push', 'settings']
6132
+ // Feature sections (extend.adminSections) go before 设置.
6133
+ const adminSections = () => [...BASE_ADMIN_SECTIONS.slice(0, -1), ...extend.adminSections.map(x => x.id), 'settings']
6134
+ // Nav groups: feature sections are grouped by purpose so the list stays scannable.
6135
+ const NAV_GROUP_OF = { sources: 'core', members: 'core', automation: 'core', push: 'core', health: 'insight', reconcile: 'insight', goals: 'insight', digests: 'insight', subscriptions: 'insight', bitable: 'connect', mcp: 'connect', settings: 'end' }
6136
+ const navGroupOf = (k) => NAV_GROUP_OF[k] ?? extend.adminSections.find(x => x.id === k)?.group ?? 'insight'
6137
+ const NAV_GROUP_ORDER = ['core', 'insight', 'connect', 'end']
5506
6138
  const NAV_KEY = 'nl2sql:nav'
5507
6139
 
5508
6140
  function loadNav() {
@@ -5519,17 +6151,21 @@ window.__ModuleLoader__.load({
5519
6151
  }
5520
6152
 
5521
6153
  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])
6154
+ const counts = useAsync(() => Promise.all([call('listAlerts').catch(() => []), call('listReports').catch(() => []), call('listChannels').catch(() => []), call('listUsers').catch(() => [])]), [section], [])
5523
6155
  const [alerts, reports, channels, users] = counts.value ?? []
5524
6156
  const badge = { sources: datasources.length, members: users?.length, automation: (alerts?.length ?? 0) + (reports?.length ?? 0), push: channels?.length }
5525
6157
  const firing = (alerts ?? []).filter(a => a.state === 'firing').length
5526
6158
  const autoSub = sub === 'reports' ? 'reports' : 'alerts'
5527
6159
  return h('div', { className: 'n2s-admin' },
5528
6160
  h('nav', { className: 'n2s-adminnav', 'aria-label': t('tab.admin') },
5529
- ADMIN_SECTIONS.map(k => h('button', { key: k, type: 'button', className: 'n2s-adminnav-item', 'aria-current': section === k ? 'page' : undefined, onClick: () => onNavigate({ section: k, sub: null }) },
5530
- h('span', { className: 'ico', 'aria-hidden': true }, AdminIcons[k]()),
5531
- h('span', { className: 'n2s-grow' }, t(`admin.${k}`)),
5532
- k === 'automation' && firing > 0 ? h('span', { className: 'n2s-navbadge err', title: t('al.firing') }, firing) : badge[k] ? h('span', { className: 'n2s-navbadge' }, badge[k]) : null))),
6161
+ NAV_GROUP_ORDER.flatMap(g => {
6162
+ const keys = adminSections().filter(k => navGroupOf(k) === g)
6163
+ if (!keys.length) return []
6164
+ return [g !== 'core' && g !== 'end' && h('div', { key: `g:${g}`, className: 'n2s-adminnav-group', role: 'presentation' }, t(`admin.group.${g}`)), g === 'end' && h('div', { key: 'g:end', className: 'n2s-adminnav-sep', role: 'presentation' }), ...keys.map(k => h('button', { key: k, type: 'button', className: 'n2s-adminnav-item', 'aria-current': section === k ? 'page' : undefined, onClick: () => onNavigate({ section: k, sub: null }) },
6165
+ h('span', { className: 'ico', 'aria-hidden': true }, (AdminIcons[k] ?? extend.adminSections.find(x => x.id === k)?.icon ?? (() => Icon.chart(15)))()),
6166
+ h('span', { className: 'n2s-grow' }, extend.adminSections.find(x => x.id === k)?.label(t) ?? t(`admin.${k}`)),
6167
+ k === 'automation' && firing > 0 ? h('span', { className: 'n2s-navbadge err', title: t('al.firing') }, firing) : badge[k] ? h('span', { className: 'n2s-navbadge' }, badge[k]) : null))].filter(Boolean)
6168
+ })),
5533
6169
  h('div', { className: 'n2s-adminbody' },
5534
6170
  section === 'sources' ? h(SourcesTab, { t, datasources }) :
5535
6171
  section === 'members' ? h(MembersTab, { t, datasources }) :
@@ -5540,6 +6176,7 @@ window.__ModuleLoader__.load({
5540
6176
  k === 'reports' && reports?.length ? h('span', { className: 'n2s-faint', style: { marginLeft: 6 } }, reports.length) : null))),
5541
6177
  autoSub === 'alerts' ? h(AlertsTab, { t, ds, datasources }) : h(ReportsTab, { t, ds, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id), onOpenSchedules: () => ctx.layout.selectPanel('schedules') })) :
5542
6178
  section === 'push' ? h(PushTab, { t }) :
6179
+ extend.adminSections.some(x => x.id === section) ? h(extend.adminSections.find(x => x.id === section).Component, { t, ds, datasources, onNavigate }) :
5543
6180
  h(SettingsPage, { t, datasources, onNavigate })))
5544
6181
  }
5545
6182
 
@@ -5551,32 +6188,49 @@ window.__ModuleLoader__.load({
5551
6188
  const { datasources, loaded, error } = useStore(s => s)
5552
6189
  const [nav, setNavState] = useState(loadNav)
5553
6190
  const setNav = useCallback((patch) => setNavState(n => { const next = { ...n, ...patch }; try { localStorage.setItem(NAV_KEY, JSON.stringify(next)) } catch {} return next }), [])
6191
+ // Leaving 管理 → 设置 with unsaved settings asks first (SettingsPage sets fbCtx.settingsDirty).
6192
+ const navRef = useRef(nav)
6193
+ navRef.current = nav
6194
+ const guardedNav = useCallback(async (patch) => {
6195
+ const cur = navRef.current
6196
+ const next = { ...cur, ...patch }
6197
+ const inSettings = cur.tab === 'admin' && cur.section === 'settings'
6198
+ const leaving = inSettings && (next.tab !== 'admin' || next.section !== 'settings')
6199
+ if (leaving && fbCtx.settingsDirty) {
6200
+ const ok = await confirmDialog({ title: t('set.leaveTitle'), message: t('set.leaveMsg'), confirmLabel: t('set.leaveOk'), danger: true })
6201
+ if (!ok) return false
6202
+ fbCtx.settingsDirty = false
6203
+ }
6204
+ setNav(patch)
6205
+ return true
6206
+ }, [])
5554
6207
  const [selectedId, setSelectedId] = useState(null)
5555
6208
  useEffect(() => { refreshDatasources() }, [])
5556
6209
  const ds = datasources.find(d => d.id === selectedId) ?? datasources.find(d => d.current) ?? datasources[0]
5557
6210
  const needsSource = loaded && datasources.length === 0 && ['schema', 'knowledge'].includes(nav.tab)
5558
6211
  const ask = useCallback((question) => {
5559
6212
  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)
6213
+ const pending = addPendingAsk(ds.id, question)
5563
6214
  try {
5564
- ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
6215
+ const r = ctx.uiWorkspace.startSession(undefined, { agentPreset: PRESET, prompt: question, whenDraftExists: 'replace' })
6216
+ // Hosts that return the new session id (directly or as a promise) pin the ask to it.
6217
+ const adopt = (id) => { if (typeof id === 'string' && id) pending.sessionId = id }
6218
+ if (r && typeof r.then === 'function') r.then(adopt, () => {}); else adopt(r)
5565
6219
  } catch (e) {
6220
+ const i = pendingAsks.indexOf(pending); if (i >= 0) pendingAsks.splice(i, 1)
5566
6221
  notifyError(e)
5567
6222
  }
5568
6223
  }, [ds?.id])
5569
6224
  fbCtx.openSession = (id) => ctx.uiWorkspace.openSession(id)
5570
- fbCtx.navigate = (patch) => setNav(patch)
6225
+ fbCtx.navigate = (patch) => { guardedNav(patch) }
5571
6226
  const tabs = [...PRIMARY_TABS, 'admin']
5572
6227
  const onTabKey = (e) => {
5573
6228
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
5574
6229
  const i = tabs.indexOf(nav.tab)
5575
6230
  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)
6231
+ guardedNav({ tab: next }).then(ok => { if (ok) setTimeout(() => document.getElementById(`n2s-tab-${next}`)?.focus(), 0) })
5578
6232
  }
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 }) },
6233
+ 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
6234
  k === 'admin' && AdminIcons.settings(), t(`tab.${k}`))
5581
6235
  const status = !ds ? 'off' : ds.lastTestOk === false ? 'err' : ''
5582
6236
  const showPicker = datasources.length > 0 && nav.tab !== 'admin'
@@ -5591,20 +6245,19 @@ window.__ModuleLoader__.load({
5591
6245
  h('span', { className: 'name' }, ds?.name ?? '—'),
5592
6246
  ds && h('span', { className: 'meta' }, typeLabel(t, ds.type)),
5593
6247
  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() } },
6248
+ 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
6249
  datasources.map(d => h('option', { key: d.id, value: d.id }, `${d.name} · ${typeLabel(t, d.type)}${d.type === 'sqlite' ? '' : ` · ${d.database}`}`))))),
5596
6250
  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
6251
  h('div', { className: 'n2s-body', role: 'tabpanel', 'aria-labelledby': `n2s-tab-${nav.tab}` },
5598
6252
  error && h('div', { className: 'n2s-notice err', style: { marginBottom: 12 } }, error),
5599
6253
  !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 }) :
6254
+ needsSource ? h(EmptySourceHint, { t, onGo: () => guardedNav({ tab: 'admin', section: 'sources' }) }) :
6255
+ nav.tab === 'ask' ? h(AskTab, { t, ds, datasources, onAsk: ask, presetReady: true, onNavigate: guardedNav }) :
6256
+ nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: paren(tb.comment) })) }) :
6257
+ nav.tab === 'knowledge' ? h(KnowledgeTab, { key: `kb:${ds?.id ?? ''}`, t, ds }) :
5604
6258
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
5605
6259
  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 })))
6260
+ h(AdminArea, { t, ctx, section: adminSections().includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
5608
6261
  }
5609
6262
  }
5610
6263
 
@@ -5706,7 +6359,7 @@ window.__ModuleLoader__.load({
5706
6359
  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
6360
  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
6361
  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))))
6362
+ ? 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
6363
  : t('set.noOverrides'))) :
5711
6364
  group === 'query' ? h(React.Fragment, null,
5712
6365
  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 +6463,17 @@ window.__ModuleLoader__.load({
5810
6463
  const retention = form.retention ?? defaults.retention ?? {}
5811
6464
  const fileRef = useRef(null)
5812
6465
  const [importState, setImportState] = useState(null)
6466
+ const importBox = useRef(null)
6467
+ useFocusTrap(importBox, Boolean(importState))
6468
+ const [importing, runImport] = useBusy()
6469
+ useEffect(() => {
6470
+ if (!importState) return
6471
+ const prev = document.activeElement
6472
+ setTimeout(() => importBox.current?.querySelector('button.primary')?.focus(), 0)
6473
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) setImportState(null) }
6474
+ document.addEventListener('keydown', onKey)
6475
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
6476
+ }, [Boolean(importState)])
5813
6477
  const exportAll = async () => {
5814
6478
  try {
5815
6479
  const doc = await call('exportConfig')
@@ -5827,7 +6491,7 @@ window.__ModuleLoader__.load({
5827
6491
  setImportState({ doc, dry })
5828
6492
  } catch (err) { notifyError(err) }
5829
6493
  }
5830
- const doImport = async (mode) => {
6494
+ const doImport = (mode) => runImport(async () => {
5831
6495
  if (mode === 'replace' && !await confirmDialog({ title: t('set.importReplaceConfirm'), message: t('set.importReplaceMsg'), danger: true })) return
5832
6496
  try {
5833
6497
  const r = await call('importConfig', { data: importState.doc, mode })
@@ -5837,7 +6501,7 @@ window.__ModuleLoader__.load({
5837
6501
  const needs = (r.report?.datasources?.needsSecret ?? r.needsSecret ?? []).length
5838
6502
  notify(needs ? fmt(t, 'set.importedNeedsSecret', { n: needs }) : t('set.imported'), 'ok', 6000)
5839
6503
  } catch (err) { notifyError(err) }
5840
- }
6504
+ })
5841
6505
  const purge = async (kind) => {
5842
6506
  if (!await confirmDialog({ title: fmt(t, 'set.purgeConfirm', { kind: t(`set.rk.${kind}`) }), danger: true })) return
5843
6507
  try { await call('purgeRecords', { kind }); st.reload(); notify(t('set.purged'), 'ok') } catch (e) { notifyError(e) }
@@ -5863,17 +6527,17 @@ window.__ModuleLoader__.load({
5863
6527
  h('button', { type: 'button', className: 'n2s-btn', onClick: () => fileRef.current?.click() }, Icon.up(13), t('set.import')),
5864
6528
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: onFile })),
5865
6529
  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) } },
6530
+ h('div', { ref: importBox, className: 'n2s-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('set.import') },
5867
6531
  h('div', { className: 'n2s-modal-title', style: { marginBottom: 10 } }, t('set.import')),
5868
6532
  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
6533
  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
6534
  h('tbody', null, Object.entries(importState.dry?.report ?? {}).map(([k, r]) => h('tr', { key: k },
5871
6535
  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(';')))))),
6536
+ 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
6537
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
5874
6538
  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))
6539
+ h('button', { type: 'button', className: 'n2s-btn danger', disabled: importing, onClick: () => doImport('replace') }, t('kb.replace')),
6540
+ 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
6541
  }
5878
6542
 
5879
6543
  function AboutSection({ t }) {
@@ -5888,6 +6552,1307 @@ window.__ModuleLoader__.load({
5888
6552
  row('docs', 'github.com/hedeqiang/dsh-nl2sql'))))
5889
6553
  }
5890
6554
 
6555
+ // ------------------------------------------------------------ data freshness + quality (health)
6556
+ // - resultPanels: a warning bar under results whose referenced tables are stale
6557
+ // (server resolves the tables with policy.js referencedTables via freshnessFor({ sql })).
6558
+ // - schemaPanels: tables with lag (green / amber / red) and a refresh button.
6559
+ // - adminSections 'health': freshness table with per-table thresholds, quality patrol
6560
+ // results, run now, settings (channels, thresholds, schedule).
6561
+ // - toolViews: conversation view of the nl2sql_freshness agent tool.
6562
+
6563
+ /** Shared freshness cache: key → { at, promise, value }; bumping `rev` re-renders subscribers. */
6564
+ const fqCache = (() => {
6565
+ let rev = 0
6566
+ const map = new Map()
6567
+ const subs = new Set()
6568
+ const TTL = 5 * 60000
6569
+ return {
6570
+ get(datasourceId, sql) {
6571
+ const key = `${datasourceId}\u0000${sql ?? ''}`
6572
+ const hit = map.get(key)
6573
+ if (hit && Date.now() - hit.at < TTL) return hit.promise
6574
+ const promise = call('freshnessFor', { datasourceId, ...(sql ? { sql } : {}) })
6575
+ map.set(key, { at: Date.now(), promise })
6576
+ promise.catch(() => map.delete(key))
6577
+ return promise
6578
+ },
6579
+ async refresh(datasourceId) {
6580
+ const v = await call('freshnessFor', { datasourceId, refresh: true })
6581
+ for (const k of [...map.keys()]) if (k.startsWith(`${datasourceId}\u0000`)) map.delete(k)
6582
+ map.set(`${datasourceId}\u0000`, { at: Date.now(), promise: Promise.resolve(v) })
6583
+ rev++
6584
+ for (const f of subs) f()
6585
+ return v
6586
+ },
6587
+ subscribe(f) { subs.add(f); return () => subs.delete(f) },
6588
+ rev: () => rev,
6589
+ }
6590
+ })()
6591
+
6592
+ const fqStatusClass = { ok: 'ok', warn: 'warn', stale: 'err', error: 'err', empty: 'off', off: 'off', unknown: 'off' }
6593
+ const fqPad = (n) => String(n).padStart(2, '0')
6594
+
6595
+ function fqLag(t, min) {
6596
+ if (min === null || min === undefined || !Number.isFinite(min)) return '—'
6597
+ const m = Math.max(0, Math.round(min))
6598
+ if (m < 60) return fmt(t, 'fq.lagMin', { n: m })
6599
+ if (m < 48 * 60) return fmt(t, 'fq.lagHour', { n: Math.round(m / 60) })
6600
+ return fmt(t, 'fq.lagDay', { n: Math.round(m / 1440) })
6601
+ }
6602
+
6603
+ /** 「昨天 17:55」 / "yesterday 17:55" relative to now. */
6604
+ function fqWhen(t, ms) {
6605
+ if (!Number.isFinite(ms)) return '—'
6606
+ const d = new Date(ms)
6607
+ const now = new Date()
6608
+ const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
6609
+ const diff = Math.round((day(now) - day(d)) / 86400000)
6610
+ const hm = `${fqPad(d.getHours())}:${fqPad(d.getMinutes())}`
6611
+ if (diff === 0) return fmt(t, 'fq.today', { time: hm })
6612
+ if (diff === 1) return fmt(t, 'fq.yesterday', { time: hm })
6613
+ return `${d.getFullYear() === now.getFullYear() ? '' : `${d.getFullYear()}-`}${fqPad(d.getMonth() + 1)}-${fqPad(d.getDate())} ${hm}`
6614
+ }
6615
+
6616
+ const fqTime = (at) => (at ? new Date(at).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—')
6617
+
6618
+ function useFqRev() {
6619
+ return useSyncExternalStore(fqCache.subscribe, fqCache.rev, fqCache.rev)
6620
+ }
6621
+
6622
+ // ---- result warning bar
6623
+
6624
+ function FreshnessResultPanel({ t, result }) {
6625
+ const rev = useFqRev()
6626
+ const dsId = result?.datasourceId
6627
+ const sql = result?.sql
6628
+ const [view, setView] = useState(null)
6629
+ useEffect(() => {
6630
+ if (!dsId || !sql || result?.error) return
6631
+ let alive = true
6632
+ fqCache.get(dsId, sql).then(v => alive && setView(v), () => {})
6633
+ return () => { alive = false }
6634
+ }, [dsId, sql, rev])
6635
+ const stale = (view?.tables ?? []).filter(x => x.stale)
6636
+ if (!stale.length) return null
6637
+ return h('div', { className: 'n2s-fq-bar', role: 'status' },
6638
+ h('span', { 'aria-hidden': true }, '⚠️'),
6639
+ h('span', null, joinClauses(stale.slice(0, 3).map(x => fmt(t, 'fq.barItem', { table: x.table, when: fqWhen(t, x.latestAt), lag: fqLag(t, x.lagMinutes) })))),
6640
+ stale.length > 3 && h('span', { className: 'n2s-faint' }, fmt(t, 'fq.barMore', { n: stale.length - 3 })))
6641
+ }
6642
+ extend.resultPanels.push(FreshnessResultPanel)
6643
+
6644
+ // ---- schema panel
6645
+
6646
+ function FqDot({ status }) {
6647
+ return h('span', { className: `n2s-fq-dot ${fqStatusClass[status] ?? 'off'}`, 'aria-hidden': true })
6648
+ }
6649
+
6650
+ function FreshnessSchemaPanel({ t, ds }) {
6651
+ const rev = useFqRev()
6652
+ const [state, setState] = useState({ loading: true })
6653
+ const [open, setOpen] = useState(null)
6654
+ const [busy, setBusy] = useState(false)
6655
+ useEffect(() => {
6656
+ if (!ds?.id) return
6657
+ let alive = true
6658
+ setState(s => ({ ...s, loading: true }))
6659
+ fqCache.get(ds.id).then(v => alive && setState({ value: v }), e => alive && setState({ error: e }))
6660
+ return () => { alive = false }
6661
+ }, [ds?.id, rev])
6662
+ const v = state.value
6663
+ if (!ds || state.error || !v || !v.tables?.length) return null
6664
+ const stale = v.tables.filter(x => x.stale).length
6665
+ const warn = v.tables.filter(x => x.status === 'warn').length
6666
+ const expanded = open ?? stale > 0
6667
+ const refresh = async () => {
6668
+ setBusy(true)
6669
+ try { await fqCache.refresh(ds.id) } catch (e) { notifyError(e) }
6670
+ setBusy(false)
6671
+ }
6672
+ return h('div', { className: 'n2s-fq-panel' },
6673
+ h('div', { className: 'n2s-row' },
6674
+ h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': expanded, onClick: () => setOpen(!expanded) }, Icon.chev(10), t('fq.title')),
6675
+ h('span', { className: `n2s-badge ${stale ? 'err' : warn ? 'warn' : 'ok'}` }, stale ? fmt(t, 'fq.staleCount', { n: stale }) : warn ? fmt(t, 'fq.warnCount', { n: warn }) : t('fq.allFresh')),
6676
+ h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'fq.checkedAt', { time: fqTime(v.checkedAt) })),
6677
+ h('span', { className: 'n2s-grow' }),
6678
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: busy, onClick: refresh }, busy ? h('span', { className: 'n2s-spin' }) : Icon.refresh(13), t('fq.refresh'))),
6679
+ expanded && h('div', { className: 'n2s-fq-list' }, v.tables.map(x => h('div', { key: x.table, className: 'n2s-fq-item', title: x.error ?? `${x.column} · ${x.latestLocal ?? '—'}` },
6680
+ h(FqDot, { status: x.status }),
6681
+ h('span', { className: 'n2s-ellipsis n2s-mono' }, x.table),
6682
+ h('span', { className: 'n2s-grow' }),
6683
+ h('span', { className: 'n2s-faint n2s-small' }, x.error ? t('fq.st.error') : x.latestAt === null ? t('fq.st.empty') : fqLag(t, x.lagMinutes))))))
6684
+ }
6685
+ extend.schemaPanels.push(FreshnessSchemaPanel)
6686
+
6687
+ // ---- conversation tool view
6688
+
6689
+ function makeFreshnessToolView(ctx) {
6690
+ return function Nl2SqlFreshnessView(props) {
6691
+ const t = useMemo(() => ctx.locale.bind(NS), [])
6692
+ setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
6693
+ const block = props.block
6694
+ const settled = props.phase === 'result'
6695
+ const f = settled ? block.meta?.freshness : undefined
6696
+ if (!settled) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-spin n2s-muted' }), h('span', { className: 'n2s-muted' }, t('fq.running'))))
6697
+ if (block.isError || !f) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-notice err' }, block.content?.map(c => (c.type === 'text' ? c.text : '')).join('\n') || t('fq.failed')))
6698
+ return h('div', { className: 'n2s-toolcard' },
6699
+ h('div', { className: 'n2s-row', style: { marginBottom: 6 } }, h('span', { style: { fontWeight: 500 } }, t('fq.title')), f.datasource && h('span', { className: 'n2s-badge' }, f.datasource)),
6700
+ (f.warnings ?? []).map((w, i) => h('div', { key: i, className: 'n2s-fq-bar' }, w)),
6701
+ h('div', { className: 'n2s-fq-list' }, (f.tables ?? []).map(x => h('div', { key: x.table, className: 'n2s-fq-item' },
6702
+ h(FqDot, { status: x.status ?? 'unknown' }),
6703
+ h('span', { className: 'n2s-mono n2s-ellipsis' }, x.table),
6704
+ h('span', { className: 'n2s-grow' }),
6705
+ h('span', { className: 'n2s-faint n2s-small' }, x.note && !x.latestAt ? t('fq.noTime') : x.latestAt ? `${x.latestAt} · ${fqLag(t, x.lagMinutes)}` : t('fq.st.empty'))))))
6706
+ }
6707
+ }
6708
+ if (extend.toolViews) extend.toolViews.push({ key: 'nl2sql_freshness', make: makeFreshnessToolView })
6709
+
6710
+ // ---- admin section
6711
+
6712
+ function FqScore({ score }) {
6713
+ const cls = score === null || score === undefined ? 'off' : score >= 90 ? 'ok' : score >= 70 ? 'warn' : 'err'
6714
+ return h('span', { className: `n2s-fq-score ${cls}` }, score === null || score === undefined ? '—' : score)
6715
+ }
6716
+
6717
+ function HealthSettings({ t, onSaved }) {
6718
+ const st = useAsync(() => call('healthSettings'), [])
6719
+ const [form, setForm] = useState(null)
6720
+ const [busy, runBusy] = useBusy()
6721
+ useEffect(() => { if (st.value) setForm(structuredClone(st.value)) }, [st.value])
6722
+ if (!form) return h('div', { className: 'n2s-skel', style: { height: 120 } })
6723
+ const setF = (k, v) => setForm(f => ({ ...f, freshness: { ...f.freshness, [k]: v } }))
6724
+ const setQ = (k, v) => setForm(f => ({ ...f, quality: { ...f.quality, [k]: v } }))
6725
+ const save = () => runBusy(async () => {
6726
+ try {
6727
+ const { overrides: _o, ...freshness } = form.freshness
6728
+ const out = await call('saveHealthSettings', { freshness: { ...freshness, intervalMin: Number(freshness.intervalMin), staleHours: Number(freshness.staleHours), channelId: freshness.channelId || null }, quality: { ...form.quality, dropPoints: Number(form.quality.dropPoints), channelId: form.quality.channelId || null } })
6729
+ setForm(structuredClone(out))
6730
+ notify(t('fq.saved'), 'ok')
6731
+ onSaved?.()
6732
+ } catch (e) { notifyError(e) }
6733
+ })
6734
+ return h('div', { className: 'n2s-card' },
6735
+ h('div', { className: 'n2s-form' },
6736
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(form.freshness.enabled), onChange: e => setF('enabled', e.target.checked) }), t('fq.set.fEnabled')),
6737
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(form.quality.enabled), onChange: e => setQ('enabled', e.target.checked) }), t('fq.set.qEnabled')),
6738
+ h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.interval')), h('input', { className: 'n2s-input', type: 'number', min: 5, max: 1440, value: form.freshness.intervalMin, onChange: e => setF('intervalMin', e.target.value) })),
6739
+ h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.time')), h('input', { className: 'n2s-input', type: 'time', value: form.quality.time, onChange: e => setQ('time', e.target.value) })),
6740
+ h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.staleHours')), h('input', { className: 'n2s-input', type: 'number', min: 0.25, step: 0.25, value: form.freshness.staleHours, onChange: e => setF('staleHours', e.target.value) })),
6741
+ h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.drop')), h('input', { className: 'n2s-input', type: 'number', min: 1, max: 100, value: form.quality.dropPoints, onChange: e => setQ('dropPoints', e.target.value) })),
6742
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-fq-fch', className: 'n2s-flabel' }, t('fq.set.fChannel')), h(ChannelPicker, { t, id: 'n2s-fq-fch', value: form.freshness.channelId ?? '', noneLabel: t('fq.set.noPush'), onChange: v => setF('channelId', v) })),
6743
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-fq-qch', className: 'n2s-flabel' }, t('fq.set.qChannel')), h(ChannelPicker, { t, id: 'n2s-fq-qch', value: form.quality.channelId ?? '', noneLabel: t('fq.set.noPush'), onChange: v => setQ('channelId', v) }))),
6744
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 10 } }, t('fq.set.hint')),
6745
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
6746
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
6747
+ }
6748
+
6749
+ function HealthFreshnessTable({ t, dsId }) {
6750
+ const rev = useFqRev()
6751
+ const fr = useAsync(() => (dsId ? fqCache.get(dsId) : null), [dsId, rev])
6752
+ const settings = useAsync(() => call('healthSettings'), [dsId, rev])
6753
+ const events = useAsync(() => (dsId ? call('freshnessEvents', { datasourceId: dsId, limit: 10 }) : []), [dsId, rev])
6754
+ const [busy, setBusy] = useState(false)
6755
+ const refresh = async () => {
6756
+ setBusy(true)
6757
+ try { await fqCache.refresh(dsId) } catch (e) { notifyError(e) }
6758
+ setBusy(false)
6759
+ }
6760
+ const setOverride = async (table, value) => {
6761
+ const cur = structuredClone(settings.value?.freshness?.overrides ?? {})
6762
+ cur[dsId] ??= {}
6763
+ if (value === '' || value === null) delete cur[dsId][table]
6764
+ else cur[dsId][table] = Number(value)
6765
+ try { await call('saveHealthSettings', { freshness: { overrides: cur } }); await fqCache.refresh(dsId) } catch (e) { notifyError(e) }
6766
+ }
6767
+ const v = fr.value
6768
+ const overrides = settings.value?.freshness?.overrides?.[dsId] ?? {}
6769
+ return h('div', null,
6770
+ h('div', { className: 'n2s-row', style: { marginBottom: 8 } },
6771
+ h('div', { className: 'n2s-setsub', style: { margin: 0 } }, t('fq.title')),
6772
+ v && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'fq.checkedAt', { time: fqTime(v.checkedAt) })),
6773
+ h('span', { className: 'n2s-grow' }),
6774
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: busy || !dsId, onClick: refresh }, busy ? h('span', { className: 'n2s-spin' }) : Icon.refresh(13), t('fq.refresh'))),
6775
+ fr.error && h('div', { className: 'n2s-notice err' }, fr.error.message),
6776
+ v && !v.analyzed && h('div', { className: 'n2s-notice' }, t('fq.notAnalyzed')),
6777
+ v && v.analyzed && !v.tables.length && h('div', { className: 'n2s-notice' }, t('fq.noTables')),
6778
+ v && v.tables.length > 0 && h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
6779
+ h('thead', null, h('tr', null, [t('fq.col.table'), t('fq.col.column'), t('fq.col.latest'), t('fq.col.lag'), t('fq.col.threshold')].map((x, i) => h('th', { key: i, className: i === 3 ? 'num' : undefined }, x)))),
6780
+ h('tbody', null, v.tables.map(x => h('tr', { key: x.table },
6781
+ h('td', null, h('span', { className: 'n2s-row', style: { flexWrap: 'nowrap' } }, h(FqDot, { status: x.status }), h('span', { className: 'n2s-mono' }, x.table))),
6782
+ h('td', { className: 'n2s-mono n2s-muted' }, x.column),
6783
+ h('td', { title: x.error ?? '' }, x.error ? h('span', { className: 'n2s-badge err' }, t('fq.st.error')) : x.latestAt === null ? t('fq.st.empty') : fqWhen(t, x.latestAt)),
6784
+ h('td', { className: 'num' }, fqLag(t, x.lagMinutes)),
6785
+ h('td', null, h('input', { className: 'n2s-input', type: 'number', min: 0, step: 0.5, style: { width: 90, height: 28 }, 'aria-label': fmt(t, 'fq.thresholdFor', { table: x.table }), placeholder: String(settings.value?.freshness?.staleHours ?? 24), defaultValue: overrides[x.table] ?? '', onBlur: e => { const val = e.target.value; if (String(overrides[x.table] ?? '') !== val) setOverride(x.table, val) } }))))))),
6786
+ (events.value ?? []).length > 0 && h('div', { style: { marginTop: 10 } },
6787
+ h('div', { className: 'n2s-faint n2s-small', style: { marginBottom: 4 } }, t('fq.events')),
6788
+ events.value.map(e => h('div', { key: e.id, className: 'n2s-small n2s-muted' }, `${fqTime(e.at)} · ${e.kind === 'stale' ? '⚠️' : '✅'} ${e.table} ${e.kind === 'stale' ? fmt(t, 'fq.evStale', { lag: fqLag(t, e.lagMinutes) }) : t('fq.evRecovered')}${e.pushed ? ` · ${t('fq.pushed')}` : ''}`))))
6789
+ }
6790
+
6791
+ function HealthQuality({ t, dsId }) {
6792
+ const res = useAsync(() => (dsId ? call('qualityResults', { datasourceId: dsId }) : null), [dsId])
6793
+ const [busy, setBusy] = useState(false)
6794
+ const [open, setOpen] = useState({})
6795
+ const run = async () => {
6796
+ setBusy(true)
6797
+ try { const r = await call('qualityScan', { datasourceId: dsId }); notify(fmt(t, 'fq.q.done', { score: r.score ?? '—' }), 'ok'); res.reload() } catch (e) { notifyError(e) }
6798
+ setBusy(false)
6799
+ }
6800
+ const latest = res.value?.latest
6801
+ const lang = uiLang === 'zh' ? 'zh' : 'en'
6802
+ return h('div', null,
6803
+ h('div', { className: 'n2s-row', style: { margin: '22px 0 8px' } },
6804
+ h('div', { className: 'n2s-setsub', style: { margin: 0 } }, t('fq.q.title')),
6805
+ latest && h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'fq.q.lastRun', { time: fqTime(latest.at) })),
6806
+ h('span', { className: 'n2s-grow' }),
6807
+ h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy || !dsId, onClick: run }, busy ? h('span', { className: 'n2s-spin' }) : Icon.spark(13), busy ? t('fq.q.running') : t('fq.q.run'))),
6808
+ res.error && h('div', { className: 'n2s-notice err' }, res.error.message),
6809
+ !res.loading && !latest && h('div', { className: 'n2s-notice' }, t('fq.q.empty')),
6810
+ latest && h('div', { className: 'n2s-fq-summary' },
6811
+ h(FqScore, { score: latest.score }),
6812
+ h('div', { className: 'n2s-grow' },
6813
+ h('div', null, latest.summary?.[lang] ?? ''),
6814
+ res.value.history.length > 1 && h('div', { className: 'n2s-faint n2s-small' }, colon(t('fq.q.history')), res.value.history.slice(0, 10).reverse().map(x => x.score ?? '—').join(' → ')))),
6815
+ latest && h('div', { className: 'n2s-table-wrap', style: { marginTop: 8 } }, h('table', { className: 'n2s-table' },
6816
+ h('thead', null, h('tr', null, [t('fq.col.table'), t('fq.q.score'), t('fq.q.rows'), t('fq.q.issues')].map((x, i) => h('th', { key: i, className: i === 1 || i === 2 ? 'num' : undefined }, x)))),
6817
+ h('tbody', null, latest.tables.map(r => {
6818
+ const issues = (r.issues ?? []).filter(i => i.deduct > 0 || r.score === null)
6819
+ const shown = open[r.table] ? issues : issues.slice(0, 2)
6820
+ return h('tr', { key: r.table },
6821
+ h('td', { className: 'n2s-mono' }, r.table),
6822
+ h('td', { className: 'num' }, h(FqScore, { score: r.score })),
6823
+ h('td', { className: 'num' }, r.rowCount ?? '—'),
6824
+ h('td', null,
6825
+ issues.length ? shown.map((i, k) => h('div', { key: k, className: 'n2s-small' }, i[lang])) : h('span', { className: 'n2s-faint n2s-small' }, t('fq.q.noIssues')),
6826
+ issues.length > 2 && h('button', { type: 'button', className: 'n2s-link n2s-small', onClick: () => setOpen(o => ({ ...o, [r.table]: !o[r.table] })) }, open[r.table] ? t('fq.less') : fmt(t, 'fq.more', { n: issues.length - 2 }))))
6827
+ })))))
6828
+ }
6829
+
6830
+ function HealthSection({ t, ds, datasources }) {
6831
+ const [dsId, setDsId] = useState(ds?.id ?? datasources?.[0]?.id ?? '')
6832
+ useEffect(() => { if (!dsId && (ds?.id || datasources?.[0]?.id)) setDsId(ds?.id ?? datasources[0].id) }, [ds?.id, datasources?.length])
6833
+ const status = useAsync(() => call('healthStatus'), [])
6834
+ return h('div', null,
6835
+ h(SectionHead, { title: t('fq.admin.title'), desc: t('fq.admin.desc') },
6836
+ (datasources ?? []).length > 1 && h('select', { className: 'n2s-select inline', 'aria-label': t('ds.select'), value: dsId, onChange: e => setDsId(e.target.value) }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
6837
+ status.value && h('div', { className: 'n2s-faint n2s-small', style: { marginBottom: 10 } },
6838
+ status.value.freshness.enabled ? fmt(t, 'fq.admin.nextF', { time: fqTime(status.value.freshness.nextAt) }) : t('fq.admin.fOff'),
6839
+ ' · ',
6840
+ status.value.quality.enabled ? fmt(t, 'fq.admin.nextQ', { time: status.value.quality.time }) : t('fq.admin.qOff')),
6841
+ !dsId ? h('div', { className: 'n2s-empty' }, h('h3', null, t('fq.admin.noDs'))) : h(React.Fragment, null,
6842
+ h(HealthFreshnessTable, { key: `f-${dsId}`, t, dsId }),
6843
+ h(HealthQuality, { key: `q-${dsId}`, t, dsId })),
6844
+ h('div', { className: 'n2s-setsub' }, t('fq.set.title')),
6845
+ h(HealthSettings, { t, onSaved: () => status.reload() }))
6846
+ }
6847
+ extend.adminSections.push({ id: 'health', label: (t) => t('fq.admin.nav'), icon: () => svg(15, [h('path', { key: 1, d: 'M1.5 8.5h3l1.5-4 3 7 1.5-3h4' })]), Component: HealthSection })
6848
+ // ------------------------------------------------------------ reconciliation (对账)
6849
+ // Admin section 'reconcile': saved jobs (two queries, key mapping, compare columns,
6850
+ // schedule, channel), dry run with summary + difference tables (ResultView), run history.
6851
+ // Also the conversation view of the nl2sql_reconcile agent tool.
6852
+
6853
+ const rcPct = (r) => (r === null || r === undefined ? '—' : `${(r * 100).toFixed(r === 1 || r === 0 ? 0 : 2)}%`)
6854
+ const rcTime = (at) => new Date(at).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
6855
+
6856
+ function rcBlank(ds) {
6857
+ return {
6858
+ name: '', left: { datasourceId: ds?.id ?? '', sql: '' }, right: { datasourceId: ds?.id ?? '', sql: '' },
6859
+ keys: [{ left: '', right: '' }], compare: [], options: { trim: true, ignoreCase: false },
6860
+ schedule: { time: '09:00', enabled: false }, channelId: '', notifyOn: 'diff',
6861
+ }
6862
+ }
6863
+
6864
+ /** Definition as sent to the server: empty rows dropped, numbers parsed. */
6865
+ function rcPayload(form) {
6866
+ return {
6867
+ ...(form.id ? { id: form.id } : {}),
6868
+ name: form.name, left: form.left, right: form.right,
6869
+ keys: form.keys.filter(k => k.left.trim()).map(k => ({ left: k.left.trim(), right: (k.right || k.left).trim() })),
6870
+ compare: form.compare.filter(c => c.left.trim()).map(c => ({ left: c.left.trim(), right: (c.right || c.left).trim(), tolerance: c.tolerance === '' || c.tolerance === undefined ? 0 : Number(c.tolerance), relative: Boolean(c.relative) })),
6871
+ options: form.options, schedule: form.schedule, channelId: form.channelId || null, notifyOn: form.notifyOn,
6872
+ }
6873
+ }
6874
+
6875
+ /** Summary numbers + warnings + the four detail tables (ResultView each). */
6876
+ function ReconcileOutcome({ t, out }) {
6877
+ const parts = ['summary', 'mismatches', 'onlyLeft', 'onlyRight']
6878
+ const results = out.results ?? []
6879
+ const firstDiff = results.findIndex((r, i) => i > 0 && r.rowCount > 0)
6880
+ const [tab, setTab] = useState(firstDiff > 0 ? firstDiff : 0)
6881
+ const s = out.summary
6882
+ if (!s) return null
6883
+ const lname = out.left?.label ?? out.left?.datasourceName
6884
+ const rname = out.right?.label ?? out.right?.datasourceName
6885
+ const stat = (label, value, bad) => h('div', { className: `n2s-rc-stat${bad ? ' bad' : ''}` }, h('div', { className: 'n2s-rc-stat-v' }, value), h('div', { className: 'n2s-rc-stat-l' }, label))
6886
+ const label = (i) => (i === 0 ? t('rc.tab.summary') : i === 1 ? t('rc.tab.mismatches') : i === 2 ? fmt(t, 'rc.tab.only', { side: lname }) : fmt(t, 'rc.tab.only', { side: rname }))
6887
+ const cur = results[tab]
6888
+ return h('div', { className: 'n2s-rc-out' },
6889
+ h('div', { className: `n2s-notice ${s.hasDiff ? 'warn' : 'ok'}`, role: 'status' }, s.hasDiff ? fmt(t, 'rc.hasDiff', { rate: rcPct(s.matchRate) }) : fmt(t, 'rc.noDiff', { rate: rcPct(s.matchRate) })),
6890
+ h('div', { className: 'n2s-rc-stats' },
6891
+ stat(t('rc.matchRate'), rcPct(s.matchRate), s.matchRate < 1),
6892
+ stat(fmt(t, 'rc.rowsOf', { side: lname }), `${formatNumber(s.leftRows)}${s.truncated?.left ? '+' : ''}`),
6893
+ stat(fmt(t, 'rc.rowsOf', { side: rname }), `${formatNumber(s.rightRows)}${s.truncated?.right ? '+' : ''}`),
6894
+ stat(t('rc.matched'), formatNumber(s.matched)),
6895
+ stat(fmt(t, 'rc.onlyIn', { side: lname }), formatNumber(s.onlyLeft), s.onlyLeft > 0),
6896
+ stat(fmt(t, 'rc.onlyIn', { side: rname }), formatNumber(s.onlyRight), s.onlyRight > 0),
6897
+ s.columns?.length > 0 && stat(t('rc.mismatched'), formatNumber(s.mismatchedRows), s.mismatchedRows > 0)),
6898
+ s.columns?.length > 0 && h('div', { className: 'n2s-table-wrap', style: { marginTop: 8 } }, h('table', { className: 'n2s-table' },
6899
+ h('thead', null, h('tr', null, [t('rc.col'), t('rc.mismatchRows'), fmt(t, 'rc.totalOf', { side: lname }), fmt(t, 'rc.totalOf', { side: rname }), t('rc.diff')].map((x, i) => h('th', { key: i }, x)))),
6900
+ h('tbody', null, s.columns.map((c, i) => h('tr', { key: i },
6901
+ h('td', null, c.label, c.tolerance ? h('span', { className: 'n2s-faint n2s-small' }, ` ±${c.relative ? `${+(c.tolerance * 100).toFixed(4)}%` : c.tolerance}`) : null),
6902
+ h('td', { className: c.mismatches ? 'n2s-rc-bad' : undefined }, formatNumber(c.mismatches)),
6903
+ h('td', null, c.leftTotal === undefined ? '—' : formatNumber(c.leftTotal)),
6904
+ h('td', null, c.rightTotal === undefined ? '—' : formatNumber(c.rightTotal)),
6905
+ h('td', { className: c.diff ? 'n2s-rc-bad' : undefined }, c.diff === undefined ? '—' : formatNumber(c.diff))))))),
6906
+ (out.warnings ?? []).map((w, i) => h('div', { key: `w${i}`, className: 'n2s-notice warn', style: { marginTop: 8 } }, w)),
6907
+ results.length > 0 && h('div', { className: 'n2s-seg', role: 'tablist', style: { marginTop: 12 } }, results.map((r, i) => h('button', { key: parts[i] ?? i, type: 'button', role: 'tab', 'aria-pressed': tab === i, 'aria-selected': tab === i, onClick: () => setTab(i) }, label(i), i > 0 ? ` (${formatNumber(r.rowCount)}${r.truncated ? '+' : ''})` : ''))),
6908
+ cur && h('div', { style: { marginTop: 8 } }, cur.rows ? h(ResultView, { key: cur.resultId, t, result: cur, compact: true, onPinned: { hidden: true } }) : h('div', { className: 'n2s-faint' }, t('result.expired'))))
6909
+ }
6910
+
6911
+ function RcSidePane({ t, title, side, datasources, onChange }) {
6912
+ return h('div', { className: 'n2s-rc-pane' },
6913
+ h('div', { className: 'n2s-rc-pane-title' }, title),
6914
+ h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')),
6915
+ h('select', { className: 'n2s-select', value: side.datasourceId, onChange: e => onChange({ ...side, datasourceId: e.target.value }) },
6916
+ h('option', { value: '' }, '—'), datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
6917
+ h('label', { className: 'n2s-field' }, h('span', null, 'SQL'),
6918
+ h('textarea', { className: 'n2s-textarea n2s-mono', spellCheck: false, rows: 6, value: side.sql, onChange: e => onChange({ ...side, sql: e.target.value }), placeholder: 'SELECT order_no, amount FROM orders WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY' })))
6919
+ }
6920
+
6921
+ function ReconcileEditor({ t, initial, datasources, ds, onDone, onCancel }) {
6922
+ const [form, setForm] = useState(() => {
6923
+ const b = rcBlank(ds)
6924
+ if (!initial) return b
6925
+ return { ...b, ...initial, channelId: initial.channelId ?? '', keys: initial.keys?.length ? initial.keys : b.keys, compare: (initial.compare ?? []).map(c => ({ ...c, tolerance: c.tolerance ?? 0 })), options: { ...b.options, ...(initial.options ?? {}) }, schedule: { ...b.schedule, ...(initial.schedule ?? {}) } }
6926
+ })
6927
+ const [error, setError] = useState(null)
6928
+ const [dry, setDry] = useState(null)
6929
+ const [busy, runBusy] = useBusy()
6930
+ const patch = (p) => setForm(f => ({ ...f, ...p }))
6931
+ const setList = (key, i, p) => setForm(f => ({ ...f, [key]: f[key].map((x, j) => (j === i ? { ...x, ...p } : x)) }))
6932
+ const addRow = (key, row) => setForm(f => ({ ...f, [key]: [...f[key], row] }))
6933
+ const delRow = (key, i) => setForm(f => ({ ...f, [key]: f[key].filter((_, j) => j !== i) }))
6934
+ const save = () => runBusy(async () => {
6935
+ setError(null)
6936
+ try { await call('saveReconcile', rcPayload(form)); notify(t('rc.saved'), 'ok'); onDone() } catch (e) { setError(e.message) }
6937
+ })
6938
+ const dryRun = async () => {
6939
+ if (dry?.busy) return
6940
+ setDry({ busy: true })
6941
+ try { setDry({ out: await call('runReconcile', { definition: { ...rcPayload(form), name: form.name || t('rc.dryRun') } }) }) } catch (e) { setDry({ error: e.message }) }
6942
+ }
6943
+ const colInput = (value, onChange, ph, label) => h('input', { className: 'n2s-input', value, placeholder: ph, 'aria-label': label, onChange: e => onChange(e.target.value) })
6944
+ return h('div', { className: 'n2s-card n2s-rc-editor', style: { marginBottom: 12 } },
6945
+ h('label', { className: 'n2s-field' }, h('span', null, t('rc.name')), h('input', { className: 'n2s-input', value: form.name, onChange: e => patch({ name: e.target.value }), placeholder: t('rc.namePh') })),
6946
+ h('div', { className: 'n2s-rc-panes' },
6947
+ h(RcSidePane, { t, title: t('rc.left'), side: form.left, datasources, onChange: (left) => patch({ left }) }),
6948
+ h(RcSidePane, { t, title: t('rc.right'), side: form.right, datasources, onChange: (right) => patch({ right }) })),
6949
+ h('div', { className: 'n2s-field full' }, h('span', null, t('rc.keys')), h('span', { className: 'n2s-faint n2s-small' }, t('rc.keysHint')),
6950
+ form.keys.map((k, i) => h('div', { key: i, className: 'n2s-rc-maprow' },
6951
+ colInput(k.left, v => setList('keys', i, { left: v }), t('rc.leftCol'), `${t('rc.keys')} ${i + 1} ${t('rc.left')}`),
6952
+ h('span', { className: 'n2s-muted', 'aria-hidden': true }, '⇄'),
6953
+ colInput(k.right, v => setList('keys', i, { right: v }), k.left || t('rc.rightCol'), `${t('rc.keys')} ${i + 1} ${t('rc.right')}`),
6954
+ h('button', { type: 'button', className: 'n2s-iconbtn', disabled: form.keys.length <= 1, title: t('ds.delete'), 'aria-label': t('ds.delete'), onClick: () => delRow('keys', i) }, Icon.trash(14)))),
6955
+ h('div', null, h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => addRow('keys', { left: '', right: '' }) }, '+ ', t('rc.addKey')))),
6956
+ h('div', { className: 'n2s-field full' }, h('span', null, t('rc.compare')), h('span', { className: 'n2s-faint n2s-small' }, t('rc.compareHint')),
6957
+ form.compare.map((c, i) => h('div', { key: i, className: 'n2s-rc-maprow' },
6958
+ colInput(c.left, v => setList('compare', i, { left: v }), t('rc.leftCol'), `${t('rc.compare')} ${i + 1} ${t('rc.left')}`),
6959
+ h('span', { className: 'n2s-muted', 'aria-hidden': true }, '⇄'),
6960
+ colInput(c.right, v => setList('compare', i, { right: v }), c.left || t('rc.rightCol'), `${t('rc.compare')} ${i + 1} ${t('rc.right')}`),
6961
+ h('input', { className: 'n2s-input n2s-rc-tol', type: 'number', min: 0, step: 'any', value: c.tolerance, 'aria-label': t('rc.tolerance'), title: t('rc.tolerance'), placeholder: t('rc.tolerance'), onChange: e => setList('compare', i, { tolerance: e.target.value }) }),
6962
+ h('select', { className: 'n2s-select inline', value: c.relative ? 'rel' : 'abs', 'aria-label': t('rc.tolMode'), onChange: e => setList('compare', i, { relative: e.target.value === 'rel' }) },
6963
+ h('option', { value: 'abs' }, t('rc.tolAbs')), h('option', { value: 'rel' }, t('rc.tolRel'))),
6964
+ h('button', { type: 'button', className: 'n2s-iconbtn', title: t('ds.delete'), 'aria-label': t('ds.delete'), onClick: () => delRow('compare', i) }, Icon.trash(14)))),
6965
+ h('div', null, h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => addRow('compare', { left: '', right: '', tolerance: 0, relative: false }) }, '+ ', t('rc.addCompare')))),
6966
+ h('div', { className: 'n2s-row', style: { gap: 16, marginTop: 4 } },
6967
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.options.trim, onChange: e => patch({ options: { ...form.options, trim: e.target.checked } }) }), t('rc.trim')),
6968
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.options.ignoreCase, onChange: e => patch({ options: { ...form.options, ignoreCase: e.target.checked } }) }), t('rc.ignoreCase'))),
6969
+ h('div', { className: 'n2s-form', style: { marginTop: 10 } },
6970
+ h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.schedule.enabled, onChange: e => patch({ schedule: { ...form.schedule, enabled: e.target.checked } }) }), t('rc.daily')),
6971
+ h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.schedule.time, disabled: !form.schedule.enabled, onChange: e => patch({ schedule: { ...form.schedule, time: e.target.value } }) })),
6972
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-rc-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-rc-channel', value: form.channelId ?? '', noneLabel: t('rc.noPush'), onChange: (v) => patch({ channelId: v }) })),
6973
+ h('label', { className: 'n2s-field' }, h('span', null, t('rc.notifyOn')), h('select', { className: 'n2s-select', value: form.notifyOn, disabled: !form.channelId, onChange: e => patch({ notifyOn: e.target.value }) },
6974
+ h('option', { value: 'diff' }, t('rc.notify.diff')), h('option', { value: 'always' }, t('rc.notify.always'))))),
6975
+ error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
6976
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
6977
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
6978
+ h('button', { type: 'button', className: 'n2s-btn', disabled: dry?.busy, onClick: dryRun }, dry?.busy ? h('span', { className: 'n2s-spin' }) : null, t('rc.dryRun')),
6979
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))),
6980
+ dry?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, dry.error),
6981
+ dry?.out && h('div', { className: 'n2s-rc-dry' }, h('div', { className: 'n2s-rc-pane-title' }, t('rc.dryResult')), h(ReconcileOutcome, { t, out: dry.out })))
6982
+ }
6983
+
6984
+ function RcStatus({ t, run }) {
6985
+ if (!run) return h('span', { className: 'n2s-statusdot' }, t('rc.never'))
6986
+ if (run.status === 'error') return h('span', { className: 'n2s-statusdot err', title: run.error }, t('rc.status.error'))
6987
+ return h('span', { className: `n2s-statusdot ${run.status === 'diff' ? 'warn' : 'ok'}` }, run.status === 'diff' ? t('rc.status.diff') : t('rc.status.ok'))
6988
+ }
6989
+
6990
+ function rcDiffText(t, run) {
6991
+ if (!run || run.status === 'error') return ''
6992
+ const s = run.summary ?? run
6993
+ return joinList([
6994
+ `${t('rc.matchRate')} ${rcPct(s.matchRate)}`,
6995
+ s.onlyLeft ? fmt(t, 'rc.short.onlyLeft', { n: s.onlyLeft }) : null,
6996
+ s.onlyRight ? fmt(t, 'rc.short.onlyRight', { n: s.onlyRight }) : null,
6997
+ s.mismatchedRows ? fmt(t, 'rc.short.mismatch', { n: s.mismatchedRows }) : null,
6998
+ s.complete === false ? t('rc.incomplete') : null,
6999
+ ], ' · ')
7000
+ }
7001
+
7002
+ function ReconcileSection({ t, ds, datasources }) {
7003
+ const list = useAsync(() => call('listReconciles'), [])
7004
+ const [editing, setEditing] = useState(null)
7005
+ const [historyOf, setHistoryOf] = useState(null)
7006
+ const [shown, setShown] = useState(null)
7007
+ const runs = useAsync(() => call('reconcileRuns', { id: historyOf ?? undefined, limit: 50 }), [historyOf, list.value])
7008
+ const [running, setRunning] = useState(null)
7009
+ const jobs = list.value ?? []
7010
+ const runNow = async (job) => {
7011
+ if (running) return
7012
+ setRunning(job.id)
7013
+ try {
7014
+ const out = await call('runReconcile', { id: job.id })
7015
+ if (out.ok === false) notify(out.error, 'err')
7016
+ else { notify(`${out.status === 'diff' ? '⚠️ ' : '✓ '}${rcDiffText(t, out)}${out.pushed ? ` · ${t('rc.pushed')}` : ''}`, out.status === 'diff' ? 'info' : 'ok'); setShown(out) }
7017
+ } catch (e) { notifyError(e) } finally { setRunning(null); list.reload() }
7018
+ }
7019
+ const showRun = async (r) => {
7020
+ if (!r.resultIds?.length) return setShown({ ...r, results: [] })
7021
+ const results = await Promise.all(r.resultIds.map(id => call('getResult', { resultId: id }).catch(() => null)))
7022
+ setShown({ ...r, results: results.map((x, i) => x ?? { resultId: r.resultIds[i], title: '', rowCount: 0 }) })
7023
+ }
7024
+ return h('div', { className: 'n2s-rc' },
7025
+ h(SectionHead, { title: t('rc.title'), desc: t('rc.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => { setShown(null); setEditing({}) } }, '+ ', t('rc.add'))),
7026
+ editing && h(ReconcileEditor, { key: editing.id ?? 'new', t, ds, datasources: datasources ?? [], initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
7027
+ list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
7028
+ !list.loading && !list.error && jobs.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('rc.empty')), h('div', null, t('rc.emptyHint')), h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => setEditing({}) }, t('rc.add'))),
7029
+ jobs.length > 0 && h('div', { className: 'n2s-list2' }, jobs.map(j => h('div', { key: j.id, className: 'n2s-list2-row' },
7030
+ h('span', { className: 'n2s-tile', 'aria-hidden': true }, '⇄'),
7031
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
7032
+ h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, j.name),
7033
+ j.schedule?.enabled ? h('span', { className: 'n2s-badge' }, fmt(t, 'rc.dailyAt', { time: j.schedule.time })) : h('span', { className: 'n2s-badge' }, t('rc.manual')),
7034
+ j.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), j.channelName)),
7035
+ h('div', { className: 'n2s-list2-sub' }, `${j.leftName} ⇄ ${j.rightName} · ${colon(t('rc.keys'))}${joinList(j.keys.map(k => (k.left === k.right ? k.left : `${k.left}/${k.right}`)))}`),
7036
+ j.lastRun && h('div', { className: 'n2s-list2-sub' }, `${rcTime(j.lastRun.at)} · `, j.lastRun.status === 'error' ? h('span', { style: { color: 'var(--n2s-err)' } }, j.lastRun.error) : rcDiffText(t, j.lastRun))),
7037
+ h('div', { className: 'n2s-list2-side' }, h(RcStatus, { t, run: j.lastRun })),
7038
+ h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
7039
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: Boolean(running), onClick: () => runNow(j) }, running === j.id ? h('span', { className: 'n2s-spin' }) : null, t('rc.runNow')),
7040
+ h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
7041
+ { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => { setShown(null); setEditing(j) } },
7042
+ { label: t('rc.history'), icon: Icon.table(14), onClick: () => setHistoryOf(j.id) },
7043
+ { label: j.schedule?.enabled ? t('rep.pause') : t('rep.resume'), icon: Icon.refresh(14), onClick: async () => { try { await call('saveReconcile', { id: j.id, schedule: { ...j.schedule, enabled: !j.schedule?.enabled } }) } catch (e) { notifyError(e) } list.reload() } },
7044
+ { divider: true },
7045
+ { label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'rc.deleteConfirm', { name: j.name }), danger: true })) { try { await call('deleteReconcile', { id: j.id }) } catch (e) { notifyError(e) } list.reload() } } },
7046
+ ] }))))),
7047
+ shown && h('div', { className: 'n2s-section' },
7048
+ h('div', { className: 'n2s-row' }, h('h2', { className: 'n2s-grow' }, fmt(t, 'rc.runOf', { name: shown.title ?? shown.name ?? '' }), shown.at ? h('span', { className: 'n2s-faint n2s-small' }, ` · ${rcTime(shown.at)}`) : null),
7049
+ h('button', { type: 'button', className: 'n2s-iconbtn', title: t('rc.close'), 'aria-label': t('rc.close'), onClick: () => setShown(null) }, Icon.close(14))),
7050
+ shown.error ? h('div', { className: 'n2s-notice err' }, shown.error) : h(ReconcileOutcome, { key: shown.runId ?? shown.id ?? shown.at, t, out: shown })),
7051
+ (runs.value ?? []).length > 0 && h('div', { className: 'n2s-section' },
7052
+ h('div', { className: 'n2s-row' }, h('h2', { className: 'n2s-grow' }, historyOf ? fmt(t, 'rc.historyOf', { name: jobs.find(j => j.id === historyOf)?.name ?? '' }) : t('rc.history')),
7053
+ historyOf && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => setHistoryOf(null) }, t('rc.allRuns'))),
7054
+ h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-rc-runs' },
7055
+ h('thead', null, h('tr', null, [t('im.col.time'), t('rc.name'), t('im.col.status'), t('rc.detail'), t('rc.push')].map((x, i) => h('th', { key: i }, x)))),
7056
+ h('tbody', null, runs.value.map(r => h('tr', { key: r.id, className: 'n2s-rc-run', tabIndex: 0, onClick: () => showRun(r), onKeyDown: (e) => { if (e.key === 'Enter') showRun(r) } },
7057
+ h('td', { className: 'n2s-muted' }, rcTime(r.at)),
7058
+ h('td', null, r.name, r.source === 'schedule' ? h('span', { className: 'n2s-faint n2s-small' }, ` · ${t('rc.scheduled')}`) : null),
7059
+ h('td', null, h(RcStatus, { t, run: r })),
7060
+ h('td', { style: { whiteSpace: 'normal' } }, r.status === 'error' ? r.error : rcDiffText(t, r)),
7061
+ h('td', null, r.pushed ? h('span', { className: 'n2s-statusdot ok' }, t('im.sentOk')) : r.pushError ? h('span', { className: 'n2s-statusdot err', title: r.pushError }, t('im.lastFailed')) : h('span', { className: 'n2s-faint' }, '—')))))))))
7062
+ }
7063
+
7064
+ extend.adminSections.push({ id: 'reconcile', label: (t) => t('rc.title'), icon: () => svg(15, [h('path', { key: 1, d: 'M2.5 5h10M10 2.5 12.5 5 10 7.5M13.5 11h-10M6 8.5 3.5 11 6 13.5' })]), Component: ReconcileSection })
7065
+
7066
+ /** Conversation view of nl2sql_reconcile: summary + difference tables. */
7067
+ function makeReconcileToolView(ctx) {
7068
+ return function Nl2SqlReconcileView(props) {
7069
+ const t = useMemo(() => ctx.locale.bind(NS), [])
7070
+ setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
7071
+ const block = props.block
7072
+ const settled = props.phase === 'result'
7073
+ const meta = settled ? block.meta : undefined
7074
+ const args = parseArgs(settled ? block.call?.argsRaw : block?.argsRaw)
7075
+ const [results, setResults] = useState(null)
7076
+ useEffect(() => {
7077
+ if (!settled || block.isError || !meta?.results) return
7078
+ let alive = true
7079
+ Promise.all(meta.results.map(r => call('getResult', { resultId: r.resultId }).then(full => full ?? { ...r, rows: null }).catch(() => ({ ...r, rows: null }))))
7080
+ .then(list => alive && setResults(list))
7081
+ return () => { alive = false }
7082
+ }, [settled, meta])
7083
+ const title = args.title || t('rc.tool')
7084
+ if (!settled) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-spin n2s-muted' }), h('span', { className: 'n2s-muted' }, t('rc.toolRunning')), h('span', null, title)))
7085
+ if (block.isError) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-badge err' }, t('tool.failed')), h('span', null, title)), h('div', { className: 'n2s-notice err', style: { marginTop: 8 } }, block.content?.map(c => (c.type === 'text' ? c.text : '')).join('\n')))
7086
+ return h('div', { className: 'n2s-toolcard' },
7087
+ h('div', { className: 'n2s-row', style: { marginBottom: 8 } }, h('span', { style: { fontWeight: 500 } }, meta?.title ?? title), meta?.left && h('span', { className: 'n2s-badge' }, `${meta.left.datasourceName} ⇄ ${meta.right.datasourceName}`)),
7088
+ !meta?.summary ? null : !results ? h('span', { className: 'n2s-spin n2s-muted' }) : h(ReconcileOutcome, { t, out: { ...meta, results } }))
7089
+ }
7090
+ }
7091
+ if (extend.toolViews) extend.toolViews.push({ key: 'nl2sql_reconcile', make: makeReconcileToolView })
7092
+ // ------------------------------------------------------------ chat subscriptions (admin section)
7093
+
7094
+ /** Subscriptions and alerts created from IM chats, grouped by chat, with pause / resume / delete. */
7095
+ function ChatSubscriptionsSection({ t }) {
7096
+ const list = useAsync(() => call('listChatSubscriptions'), [])
7097
+ const [notice, setNotice] = useState(null)
7098
+ const groups = list.value ?? []
7099
+ const when = (ms) => (ms ? new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—')
7100
+ const act = async (item, action) => {
7101
+ setNotice(null)
7102
+ if (action === 'delete' && !(await confirmDialog({ title: fmt(t, 'sub.deleteConfirm', { name: item.title }), danger: true }))) return
7103
+ try { await call('setChatSubscription', { id: item.id, action }); setNotice({ ok: true, text: t(`sub.done.${action}`) }) } catch (e) { setNotice({ ok: false, text: e.message }) }
7104
+ list.reload()
7105
+ }
7106
+ const statusDot = (s) => h('span', { className: `n2s-statusdot ${s === 'active' ? 'ok' : s === 'suspended' ? 'err' : ''}` }, t(`sub.status.${s}`))
7107
+ return h('div', { className: 'n2s-sub' },
7108
+ h(SectionHead, { title: t('sub.title'), desc: t('sub.desc') },
7109
+ h('button', { type: 'button', className: 'n2s-btn', onClick: () => list.reload() }, Icon.refresh(14), ' ', t('sub.refresh'))),
7110
+ notice && h('div', { className: `n2s-notice ${notice.ok ? 'ok' : 'err'}`, role: 'status', style: { marginBottom: 10 } }, notice.text),
7111
+ list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
7112
+ !list.loading && !list.error && groups.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('sub.empty')), h('div', null, t('sub.emptyHint'))),
7113
+ groups.map(g => h('div', { key: g.key, className: 'n2s-card n2s-sub-group' },
7114
+ h('div', { className: 'n2s-sub-head' },
7115
+ h('span', { className: 'n2s-sub-chat' }, g.chatName || g.chatId),
7116
+ h('span', { className: 'n2s-badge' }, Icon.send(11), ' ', g.channelName ?? g.channelId),
7117
+ h('span', { className: 'n2s-faint n2s-small' }, g.platform)),
7118
+ h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
7119
+ h('thead', null, h('tr', null, [t('sub.col.item'), t('sub.col.creator'), t('sub.col.schedule'), t('sub.col.last'), t('sub.col.status'), ''].map((x, i) => h('th', { key: i }, x)))),
7120
+ h('tbody', null, g.items.map(it => h('tr', { key: it.id },
7121
+ h('td', null,
7122
+ h('span', { className: 'n2s-badge n2s-sub-kind' }, t(`sub.kind.${it.kind}`)), ' ', it.title,
7123
+ h('div', { className: 'n2s-faint n2s-small' }, joinList([it.datasourceName, it.conditionText, it.metric]))),
7124
+ h('td', null, it.createdBy?.name ?? '—'),
7125
+ h('td', null, it.schedule, it.nextAt ? h('div', { className: 'n2s-faint n2s-small' }, `${colon(t('sub.next'))}${when(it.nextAt)}`) : null),
7126
+ h('td', null, when(it.lastDeliveredAt)),
7127
+ h('td', null, statusDot(it.status), it.lastError && h('div', { className: 'n2s-small n2s-sub-err' }, it.lastError)),
7128
+ h('td', null, h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
7129
+ it.status === 'active'
7130
+ ? h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(it, 'pause') }, t('sub.pause'))
7131
+ : h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(it, 'resume') }, t('sub.resume')),
7132
+ h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-label': t('sub.delete'), title: t('sub.delete'), onClick: () => act(it, 'delete') }, Icon.trash(13))))))))))))
7133
+ }
7134
+
7135
+ extend.adminSections.push({ id: 'subscriptions', label: (t) => t('sub.nav'), icon: () => svg(15, [h('path', { key: 1, d: 'M4 6.5a4 4 0 0 1 8 0c0 3 1.5 4.5 1.5 4.5h-11S4 9.5 4 6.5M6.5 13.5a1.6 1.6 0 0 0 3 0' })]), Component: ChatSubscriptionsSection })
7136
+ // ------------------------------------------------------------ proactive (daily digest, goals, forecast)
7137
+ // Admin sections 'digests' (每日简报) and 'goals' (目标); goal chips in the dashboard bar;
7138
+ // "预测未来 N 天" in the result menu + forecast panel under the result; nl2sql_forecast tool view.
7139
+
7140
+ // ---- forecast
7141
+
7142
+ /** resultId → horizon requested from the result menu (shared by the menu item and the panel). */
7143
+ const proForecastStore = createStore({})
7144
+
7145
+ function proForecastOption(f, el, t) {
7146
+ const label = cssVar(el, '--dsw-alias-label-secondary', '#888')
7147
+ const text = cssVar(el, '--dsw-alias-label-primary', '#222')
7148
+ const border = cssVar(el, '--dsw-alias-border-l1', 'rgba(128,128,128,.25)')
7149
+ const surface = cssVar(el, '--dsw-alias-bg-layer-1', '#fff')
7150
+ const color = '#3b82f6'
7151
+ const fcColor = '#f59e0b'
7152
+ const hist = f.history ?? []
7153
+ const fc = f.forecast ?? []
7154
+ const cats = [...hist.map(p => String(p.x)), ...fc.map(p => String(p.x))]
7155
+ const n = hist.length
7156
+ const pad = (k) => new Array(k).fill(null)
7157
+ const histData = hist.map(p => p.y)
7158
+ // The dashed forecast starts at the last observation so the two lines join.
7159
+ const fcData = [...pad(Math.max(0, n - 1)), n ? hist[n - 1].y : null, ...fc.map(p => p.value)]
7160
+ const lower = [...pad(n), ...fc.map(p => p.lower)]
7161
+ const width = [...pad(n), ...fc.map(p => p.upper - p.lower)]
7162
+ const axisCommon = { axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: label, fontSize: 11 }, splitLine: { lineStyle: { color: border, type: [3, 3] } } }
7163
+ return {
7164
+ color: [color, fcColor],
7165
+ backgroundColor: 'transparent',
7166
+ textStyle: { color: text, fontFamily: 'inherit' },
7167
+ animationDuration: 300,
7168
+ grid: { left: 4, right: 8, top: 36, bottom: 4, containLabel: true },
7169
+ legend: { top: 0, left: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 10, itemGap: 16, textStyle: { color: label, fontSize: 12 }, data: [f.measure, t('pro.fc.forecast'), t('pro.fc.band')] },
7170
+ tooltip: {
7171
+ trigger: 'axis', confine: true, backgroundColor: surface, borderColor: border, borderWidth: 1, padding: [8, 10], textStyle: { color: text, fontSize: 12 },
7172
+ formatter: (ps) => {
7173
+ const i = ps[0]?.dataIndex ?? 0
7174
+ const rows = [escapeHtml(cats[i])]
7175
+ if (i < n) rows.push(`${escapeHtml(f.measure)}: ${formatNumber(hist[i].y)}`)
7176
+ else { const p = fc[i - n]; rows.push(`${escapeHtml(t('pro.fc.forecast'))}: ${formatNumber(p.value)}`, `95%: ${formatNumber(p.lower)} ~ ${formatNumber(p.upper)}`) }
7177
+ return rows.join('<br/>')
7178
+ },
7179
+ },
7180
+ xAxis: { type: 'category', data: cats, boundaryGap: false, ...axisCommon, axisLine: { show: true, lineStyle: { color: border } }, axisLabel: { color: label, fontSize: 11, hideOverlap: true }, splitLine: { show: false } },
7181
+ yAxis: { type: 'value', scale: true, ...axisCommon, axisLabel: { color: label, fontSize: 11, formatter: (v) => formatNumber(v) } },
7182
+ dataZoom: cats.length > 60 ? [{ type: 'inside', startValue: Math.max(0, cats.length - 60) }] : undefined,
7183
+ series: [
7184
+ { name: f.measure, type: 'line', data: histData, smooth: 0.3, showSymbol: false, symbol: 'circle', lineStyle: { width: 2, color }, itemStyle: { color }, areaStyle: { opacity: 0.06 } },
7185
+ { name: t('pro.fc.forecast'), type: 'line', data: fcData, smooth: 0.3, showSymbol: false, symbol: 'circle', lineStyle: { width: 2, type: 'dashed', color: fcColor }, itemStyle: { color: fcColor } },
7186
+ { name: 'lower', type: 'line', data: lower, stack: 'band', stackStrategy: 'all', symbol: 'none', lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
7187
+ { name: t('pro.fc.band'), type: 'line', data: width, stack: 'band', stackStrategy: 'all', symbol: 'none', lineStyle: { opacity: 0 }, itemStyle: { color: fcColor }, areaStyle: { color: fcColor, opacity: 0.16 }, silent: true },
7188
+ ],
7189
+ }
7190
+ }
7191
+
7192
+ function ForecastChart({ t, forecast }) {
7193
+ const ref = useRef(null)
7194
+ const inst = useRef(null)
7195
+ const [error, setError] = useState(null)
7196
+ useEffect(() => {
7197
+ let disposed = false
7198
+ let observer
7199
+ loadEcharts().then((echarts) => {
7200
+ if (disposed || !ref.current) return
7201
+ inst.current = echarts.init(ref.current, null, { renderer: 'canvas' })
7202
+ observer = new ResizeObserver(() => inst.current?.resize())
7203
+ observer.observe(ref.current)
7204
+ try { inst.current.setOption(proForecastOption(forecast, ref.current, t), true) } catch (e) { setError(e.message) }
7205
+ }, (e) => setError(e.message))
7206
+ return () => { disposed = true; observer?.disconnect(); inst.current?.dispose(); inst.current = null }
7207
+ }, [forecast])
7208
+ return h('div', { style: { position: 'relative' } },
7209
+ error && h('div', { className: 'n2s-notice err' }, error),
7210
+ h('div', { ref, className: 'n2s-chart', role: 'img', 'aria-label': t('pro.fc.title') }))
7211
+ }
7212
+
7213
+ /** Forecast summary + chart + table, shared by the result panel and the tool view. */
7214
+ function ForecastView({ t, f, onClose, onHorizon }) {
7215
+ const [showTable, setShowTable] = useState(false)
7216
+ const method = f.method === 'holt-winters' ? t('pro.fc.hw') : t('pro.fc.holt')
7217
+ return h('div', { className: 'n2s-pro-fc' },
7218
+ h('div', { className: 'n2s-row', style: { gap: 8, marginBottom: 6 } },
7219
+ h('span', { className: 'n2s-pro-fc-title' }, Icon.spark(13), fmt(t, 'pro.fc.heading', { n: f.horizon })),
7220
+ h('span', { className: 'n2s-badge' }, method),
7221
+ f.filled > 0 && h('span', { className: 'n2s-badge warn', title: t('pro.fc.filledHint') }, fmt(t, 'pro.fc.filled', { n: f.filled })),
7222
+ h('span', { className: 'n2s-grow' }),
7223
+ onHorizon && h(Dropdown, { label: t('pro.fc.horizon'), trigger: { className: 'n2s-btn small ghost', content: h(React.Fragment, null, fmt(t, 'pro.fc.nDays', { n: f.horizon }), Icon.chev(10)) }, items: [7, 14, 30].map(n => ({ label: fmt(t, 'pro.fc.nDays', { n }), checked: f.horizon === n, onClick: () => onHorizon(n) })) }),
7224
+ h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showTable, onClick: () => setShowTable(v => !v) }, Icon.table(13), t('result.data')),
7225
+ onClose && h('button', { type: 'button', className: 'n2s-iconbtn', title: t('pro.close'), 'aria-label': t('pro.close'), onClick: onClose }, Icon.close())),
7226
+ h(ForecastChart, { t, forecast: f }),
7227
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('pro.fc.note')),
7228
+ showTable && h('div', { className: 'n2s-table-wrap', style: { marginTop: 8 } }, h('table', { className: 'n2s-table' },
7229
+ h('thead', null, h('tr', null, [f.x, t('pro.fc.forecast'), t('pro.fc.lower'), t('pro.fc.upper')].map((c, i) => h('th', { key: i, className: i ? 'num' : undefined }, c)))),
7230
+ h('tbody', null, (f.forecast ?? []).map(p => h('tr', { key: p.x }, h('td', null, p.x), h('td', { className: 'num' }, formatNumber(p.value)), h('td', { className: 'num' }, formatNumber(p.lower)), h('td', { className: 'num' }, formatNumber(p.upper))))))))
7231
+ }
7232
+
7233
+ /** Can this result be forecast? Time column + exactly one numeric measure, no series split. */
7234
+ function proForecastable(result) {
7235
+ if (!result?.columns || !result.rows || result.rows.length < 3 || result.chart?.series) return false
7236
+ const time = result.columns.some(c => c.kind === 'time')
7237
+ const nums = result.columns.filter(c => c.kind === 'number')
7238
+ const ys = result.chart?.y?.length ? result.chart.y : nums.map(c => c.name)
7239
+ return time && ys.filter(y => nums.some(c => c.name === y)).length === 1
7240
+ }
7241
+
7242
+ function ForecastPanel({ t, result }) {
7243
+ const get = () => proForecastStore.get()[result?.resultId]
7244
+ const horizon = useSyncExternalStore(proForecastStore.subscribe, get, get)
7245
+ const fc = useAsync(() => (horizon ? call('forecast', { resultId: result.resultId, horizon }) : null), [result?.resultId, horizon])
7246
+ if (!horizon) return null
7247
+ const close = () => proForecastStore.set({ [result.resultId]: 0 })
7248
+ if (fc.error) return h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, fc.error.message, ' ', h('button', { type: 'button', className: 'n2s-link', onClick: close }, t('pro.close')))
7249
+ if (!fc.value) return h('div', { className: 'n2s-pro-fc' }, h('span', { className: 'n2s-spin n2s-muted' }), ' ', h('span', { className: 'n2s-muted n2s-small' }, t('pro.fc.running')))
7250
+ return h(ForecastView, { t, f: fc.value, onClose: close, onHorizon: (n) => proForecastStore.set({ [result.resultId]: n }) })
7251
+ }
7252
+
7253
+ extend.resultMenu.push(({ t, result }) => (proForecastable(result) ? [
7254
+ { divider: true },
7255
+ { label: fmt(t, 'pro.fc.menu', { n: 7 }), icon: Icon.spark(14), onClick: () => proForecastStore.set({ [result.resultId]: 7 }) },
7256
+ ] : []))
7257
+ extend.resultPanels.push(ForecastPanel)
7258
+
7259
+ function makeForecastToolView(ctx) {
7260
+ return function Nl2SqlForecastView(props) {
7261
+ const t = useMemo(() => ctx.locale.bind(NS), [])
7262
+ setUiLang(useSyncExternalStore(ctx.locale.subscribe.bind(ctx.locale), () => ctx.locale.getSnapshot(), () => ctx.locale.getSnapshot()))
7263
+ const block = props.block
7264
+ const settled = props.phase === 'result'
7265
+ const f = settled ? block.meta?.forecast : undefined
7266
+ if (!settled) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-spin n2s-muted' }), h('span', { className: 'n2s-muted' }, t('pro.fc.running'))))
7267
+ if (block.isError || !f) return h('div', { className: 'n2s-toolcard' }, h('div', { className: 'n2s-notice err' }, block.content?.map(c => (c.type === 'text' ? c.text : '')).join('\n') || t('tool.failed')))
7268
+ return h('div', { className: 'n2s-toolcard' }, f.title && h('div', { style: { fontWeight: 500, marginBottom: 6 } }, f.title), h(ForecastView, { t, f }))
7269
+ }
7270
+ }
7271
+ if (extend.toolViews) extend.toolViews.push({ key: 'nl2sql_forecast', make: makeForecastToolView })
7272
+
7273
+ // ---- goals
7274
+
7275
+ const proStatusClass = { 'on-track': 'ok', achieved: 'ok', 'at-risk': 'warn', missed: 'err', error: 'err' }
7276
+ const proPct = (v) => (v === null || v === undefined || !Number.isFinite(v) ? '—' : `${Math.round(v * 100)}%`)
7277
+
7278
+ function GoalBar({ g }) {
7279
+ const w = Math.max(0, Math.min(100, (g.pct ?? 0) * 100))
7280
+ const pw = Math.max(0, Math.min(100, (g.projectedPct ?? 0) * 100))
7281
+ const tick = Math.max(0, Math.min(100, (g.elapsedPct ?? 0) * 100))
7282
+ return h('span', { className: `n2s-pro-bar ${proStatusClass[g.status] ?? ''}`, 'aria-hidden': true },
7283
+ h('span', { className: 'proj', style: { width: `${pw}%` } }),
7284
+ h('span', { className: 'cur', style: { width: `${w}%` } }),
7285
+ g.direction !== 'down' && h('span', { className: 'tick', style: { left: `${tick}%` } }))
7286
+ }
7287
+
7288
+ /** Goal chips in the dashboard bar: goals tied to this dashboard or to its data sources. */
7289
+ function GoalChips({ t, boardId }) {
7290
+ const list = useAsync(() => (boardId ? call('goalProgress', { dashboardId: boardId }) : []), [boardId])
7291
+ const goals = list.value ?? []
7292
+ if (!goals.length) return null
7293
+ return h('div', { className: 'n2s-pro-chips', role: 'list', 'aria-label': t('pro.goals.title') }, goals.slice(0, 4).map(g => h('span', {
7294
+ key: g.id, role: 'listitem', className: 'n2s-pro-chip',
7295
+ title: g.error ?? `${g.name} · ${g.metric}\n${t('pro.goals.actual')} ${formatNumber(g.actual)} / ${formatNumber(g.target)} (${proPct(g.pct)})\n${t('pro.goals.projected')} ${formatNumber(g.projected)} (${proPct(g.projectedPct)})\n${t(`pro.st.${g.status}`)}`,
7296
+ },
7297
+ h('span', { className: 'n2s-ellipsis', style: { maxWidth: 90 } }, g.name),
7298
+ g.error ? h('span', { className: 'n2s-statusdot err' }, t('pro.st.error')) : h(React.Fragment, null, h(GoalBar, { g }), h('span', { className: `n2s-pro-pct ${proStatusClass[g.status] ?? ''}` }, proPct(g.pct))))))
7299
+ }
7300
+ extend.dashboardBar.push(GoalChips)
7301
+
7302
+ function useMetrics(datasourceId) {
7303
+ const k = useAsync(() => (datasourceId ? call('getKnowledge', { datasourceId }) : null), [datasourceId])
7304
+ return k.value?.metrics ?? []
7305
+ }
7306
+
7307
+ function GoalForm({ t, initial, ds, datasources, onDone, onCancel }) {
7308
+ const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id ?? datasources[0]?.id ?? '', metric: '', period: 'month', target: '', direction: 'up', dashboardId: '', ...(initial ?? {}) }))
7309
+ const metrics = useMetrics(form.datasourceId)
7310
+ const boards = useAsync(() => call('listDashboards'), [])
7311
+ const [error, setError] = useState(null)
7312
+ const [busy, run] = useBusy()
7313
+ const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
7314
+ const save = () => run(async () => {
7315
+ setError(null)
7316
+ try { await call('saveGoal', { ...form, target: Number(form.target), dashboardId: form.dashboardId || null }); onDone() } catch (e) { setError(e.message) }
7317
+ })
7318
+ return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
7319
+ h('div', { className: 'n2s-form' },
7320
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name'), placeholder: t('pro.goals.namePh') })),
7321
+ h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')), h('select', { className: 'n2s-select', value: form.datasourceId, onChange: set('datasourceId') }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
7322
+ h('label', { className: 'n2s-field' }, h('span', null, t('al.metric')), metrics.length ? h('select', { className: 'n2s-select', value: form.metric, onChange: set('metric') }, h('option', { value: '' }, '—'), metrics.map(m => h('option', { key: m.id, value: m.name }, m.name))) : h('div', { className: 'n2s-faint n2s-small' }, t('al.noMetrics'))),
7323
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.period')), h('select', { className: 'n2s-select', value: form.period, onChange: set('period') }, ['month', 'quarter', 'year'].map(p => h('option', { key: p, value: p }, t(`pro.period.${p}`))))),
7324
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.target')), h('input', { className: 'n2s-input', type: 'number', value: form.target, onChange: set('target') })),
7325
+ h('div', { className: 'n2s-field' }, h('span', null, t('pro.goals.direction')), h('div', { className: 'n2s-seg' }, ['up', 'down'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.direction === v, onClick: () => setForm(f => ({ ...f, direction: v })) }, t(`pro.dir.${v}`))))),
7326
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.goals.dashboard')), h('select', { className: 'n2s-select', value: form.dashboardId ?? '', onChange: set('dashboardId') }, h('option', { value: '' }, t('pro.goals.anyBoard')), (boards.value?.dashboards ?? []).map(b => h('option', { key: b.id, value: b.id }, b.name))))),
7327
+ error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
7328
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
7329
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7330
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
7331
+ }
7332
+
7333
+ function GoalPushSettings({ t }) {
7334
+ const cfg = useAsync(() => call('getGoalPush'), [])
7335
+ const [busy, run] = useBusy()
7336
+ const v = cfg.value
7337
+ if (!v) return null
7338
+ const save = (patch) => run(async () => { try { await call('saveGoalPush', patch); cfg.reload() } catch (e) { notifyError(e) } })
7339
+ return h('div', { className: 'n2s-section' }, h('h2', null, t('pro.goals.weekly')),
7340
+ h('div', { className: 'n2s-row', style: { gap: 10, flexWrap: 'wrap' } },
7341
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(v.enabled), disabled: busy, onChange: e => save({ enabled: e.target.checked }) }), t('pro.goals.weeklyOn')),
7342
+ h('select', { className: 'n2s-select inline', 'aria-label': t('pro.weekday'), value: v.weekday, onChange: e => save({ weekday: Number(e.target.value) }) }, [1, 2, 3, 4, 5, 6, 0].map(d => h('option', { key: d, value: d }, t(`pro.wd.${d}`)))),
7343
+ h('input', { className: 'n2s-input', type: 'time', 'aria-label': t('rep.time'), style: { width: 110 }, value: v.time, onChange: e => save({ time: e.target.value }) }),
7344
+ h('div', { style: { minWidth: 200 } }, h(ChannelPicker, { t, value: v.channelId ?? '', noneLabel: t('al.noPush'), label: t('rep.push'), onChange: (id) => save({ channelId: id || null }) })),
7345
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: !v.channelId || busy, onClick: () => run(async () => { try { await call('sendGoalReport'); notify(t('pro.sent'), 'ok') } catch (e) { notifyError(e) } }) }, Icon.send(13), t('pro.sendNow'))),
7346
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 6 } }, t('pro.goals.weeklyHint')))
7347
+ }
7348
+
7349
+ function GoalsSection({ t, ds, datasources }) {
7350
+ const list = useAsync(() => call('goalProgress', {}), [])
7351
+ const [editing, setEditing] = useState(null)
7352
+ const goals = list.value ?? []
7353
+ return h('div', null,
7354
+ h(SectionHead, { title: t('pro.goals.title'), desc: t('pro.goals.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => setEditing({}) }, '+ ', t('pro.goals.add'))),
7355
+ editing && h(GoalForm, { key: editing.id ?? 'new', t, ds, datasources: datasources ?? [], initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
7356
+ list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
7357
+ list.loading && !list.value && h('span', { className: 'n2s-spin n2s-muted' }),
7358
+ !list.loading && goals.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('pro.goals.empty')), h('div', null, t('pro.goals.emptyHint'))),
7359
+ goals.length > 0 && h('div', { className: 'n2s-list2' }, goals.map(g => h('div', { key: g.id, className: 'n2s-list2-row' },
7360
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
7361
+ h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, g.name), h('span', { className: `n2s-statusdot ${proStatusClass[g.status] ?? ''}` }, t(`pro.st.${g.status}`))),
7362
+ h('div', { className: 'n2s-list2-sub' }, joinList([g.metric, t(`pro.period.${g.period}`), `${t(`pro.dir.${g.direction}`)} ${formatNumber(g.target)}`, g.datasourceName ?? null, g.periodStart && `${g.periodStart} ~ ${g.periodEnd}`], ' · ')),
7363
+ g.error ? h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, g.error)
7364
+ : h('div', { className: 'n2s-row', style: { gap: 8, marginTop: 6 } }, h(GoalBar, { g }),
7365
+ h('span', { className: 'n2s-small' }, `${formatCompact(g.actual)} / ${formatCompact(g.target)} (${proPct(g.pct)})`),
7366
+ h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'pro.goals.proj', { v: formatCompact(g.projected), p: proPct(g.projectedPct) }), g.weekdayAware ? ` · ${t('pro.goals.weekdayAware')}` : ''))),
7367
+ h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
7368
+ { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing({ id: g.id, name: g.name, datasourceId: g.datasourceId, metric: g.metric, period: g.period, target: g.target, direction: g.direction, dashboardId: g.dashboardId ?? '' }) },
7369
+ { divider: true },
7370
+ { label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'pro.deleteConfirm', { name: g.name }), danger: true })) { try { await call('deleteGoal', { id: g.id }) } catch (e) { notifyError(e) } list.reload() } } },
7371
+ ] })))),
7372
+ h(GoalPushSettings, { t }))
7373
+ }
7374
+ extend.adminSections.push({ id: 'goals', label: (t) => t('pro.goals.title'), icon: () => svg(15, [h('circle', { key: 1, cx: 8, cy: 8, r: 6 }), h('circle', { key: 2, cx: 8, cy: 8, r: 3 }), h('circle', { key: 3, cx: 8, cy: 8, r: .6 })]), Component: GoalsSection })
7375
+
7376
+ // ---- daily digest
7377
+
7378
+ function DigestForm({ t, initial, ds, datasources, onDone, onCancel }) {
7379
+ const [form, setForm] = useState(() => ({ name: '', datasourceId: ds?.id ?? datasources[0]?.id ?? '', source: initial?.dashboardId ? 'dashboard' : 'metrics', metrics: [], dashboardId: '', channelId: '', time: '09:00', language: uiLang, threshold: 30, enabled: true, ...(initial ?? {}) }))
7380
+ const metrics = useMetrics(form.datasourceId)
7381
+ const boards = useAsync(() => call('listDashboards'), [])
7382
+ const [error, setError] = useState(null)
7383
+ const [busy, run] = useBusy()
7384
+ const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
7385
+ const payload = () => ({ id: form.id, name: form.name, datasourceId: form.datasourceId, metrics: form.source === 'metrics' ? form.metrics : [], dashboardId: form.source === 'dashboard' ? form.dashboardId || null : null, channelId: form.channelId || null, time: form.time, language: form.language, threshold: Number(form.threshold), enabled: form.enabled })
7386
+ const save = () => run(async () => {
7387
+ setError(null)
7388
+ try { await call('saveDigest', payload()); onDone() } catch (e) { setError(e.message) }
7389
+ })
7390
+ const toggle = (name) => setForm(f => ({ ...f, metrics: f.metrics.includes(name) ? f.metrics.filter(x => x !== name) : [...f.metrics, name] }))
7391
+ return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
7392
+ h('div', { className: 'n2s-form' },
7393
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.dg.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name'), placeholder: t('pro.dg.namePh') })),
7394
+ h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')), h('select', { className: 'n2s-select', value: form.datasourceId, onChange: set('datasourceId') }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
7395
+ h('div', { className: 'n2s-field full' }, h('span', null, t('pro.dg.source')), h('div', { className: 'n2s-seg' }, ['metrics', 'dashboard'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.source === v, onClick: () => setForm(f => ({ ...f, source: v })) }, t(`pro.dg.src.${v}`))))),
7396
+ form.source === 'metrics'
7397
+ ? h('div', { className: 'n2s-field full' }, h('span', null, t('pro.dg.metrics')), metrics.length ? h('div', { className: 'n2s-chips' }, metrics.map(m => h('button', { key: m.id, type: 'button', className: 'n2s-chip', 'aria-pressed': form.metrics.includes(m.name), onClick: () => toggle(m.name) }, m.name))) : h('div', { className: 'n2s-faint n2s-small' }, t('al.noMetrics')))
7398
+ : h('label', { className: 'n2s-field full' }, h('span', null, t('pro.dg.dashboard')), h('select', { className: 'n2s-select', value: form.dashboardId ?? '', onChange: set('dashboardId') }, h('option', { value: '' }, '—'), (boards.value?.dashboards ?? []).map(b => h('option', { key: b.id, value: b.id }, b.name)))),
7399
+ h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.time, onChange: set('time') })),
7400
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-pro-dg-ch', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-pro-dg-ch', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => setForm(f => ({ ...f, channelId: v })) })),
7401
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.dg.language')), h('select', { className: 'n2s-select', value: form.language, onChange: set('language') }, h('option', { value: 'zh' }, '中文'), h('option', { value: 'en' }, 'English'))),
7402
+ h('label', { className: 'n2s-field' }, h('span', null, t('pro.dg.threshold')), h('input', { className: 'n2s-input', type: 'number', min: 1, max: 500, value: form.threshold, onChange: set('threshold') }))),
7403
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('pro.dg.hint')),
7404
+ error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
7405
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
7406
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7407
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
7408
+ }
7409
+
7410
+ function DigestPreview({ t, run }) {
7411
+ if (run.busy) return h('div', { className: 'n2s-notice', style: { marginTop: 10 } }, h('span', { className: 'n2s-spin' }), ' ', t('pro.dg.previewing'))
7412
+ if (run.error) return h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, run.error)
7413
+ return h('div', { className: 'n2s-pro-preview' },
7414
+ h('div', { style: { fontWeight: 500, marginBottom: 6 } }, run.title),
7415
+ h(MdPreview, { text: run.text }),
7416
+ (run.results ?? []).length > 0 && h('div', { className: 'n2s-pro-preview-charts' }, run.results.map(r => h(ResultView, { key: r.resultId, result: r, t, compact: true, hideSql: true, onPinned: { hidden: true } }))))
7417
+ }
7418
+
7419
+ function DigestsSection({ t, ds, datasources }) {
7420
+ const list = useAsync(() => call('listDigests'), [])
7421
+ const [editing, setEditing] = useState(null)
7422
+ const [preview, setPreview] = useState(null)
7423
+ const digests = list.value ?? []
7424
+ const runPreview = async (d) => {
7425
+ setPreview({ id: d.id, busy: true })
7426
+ try { setPreview({ ...(await call('runDigest', { id: d.id })), runId: undefined, id: d.id }) } catch (e) { setPreview({ id: d.id, error: e.message }) }
7427
+ }
7428
+ const act = async (fn, ok) => { try { await fn(); if (ok) notify(ok, 'ok') } catch (e) { notifyError(e) } list.reload() }
7429
+ return h('div', null,
7430
+ h(SectionHead, { title: t('pro.dg.title'), desc: t('pro.dg.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', disabled: !datasources?.length, onClick: () => setEditing({}) }, '+ ', t('pro.dg.add'))),
7431
+ editing && h(DigestForm, { key: editing.id ?? 'new', t, ds, datasources: datasources ?? [], initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
7432
+ list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
7433
+ !list.loading && digests.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('pro.dg.empty')), h('div', null, t('pro.dg.emptyHint'))),
7434
+ digests.length > 0 && h('div', { className: 'n2s-list2' }, digests.map(d => h(React.Fragment, { key: d.id },
7435
+ h('div', { className: 'n2s-list2-row' },
7436
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
7437
+ h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, d.name), !d.enabled && h('span', { className: 'n2s-badge' }, t('rep.status.paused')), d.channelName && h('span', { className: 'n2s-badge' }, Icon.send(11), d.channelName)),
7438
+ h('div', { className: 'n2s-list2-sub' }, joinList([d.dashboardId ? t('pro.dg.src.dashboard') : joinList(d.metrics), fmt(t, 'pro.dg.dailyAt', { time: d.time }), d.datasourceName], ' · ')),
7439
+ d.lastError && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, d.lastError)),
7440
+ h('div', { className: 'n2s-list2-side' }, d.lastRunAt ? h('span', { className: `n2s-statusdot ${d.lastStatus === 'ok' ? 'ok' : 'err'}` }, new Date(d.lastRunAt).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })) : h('span', { className: 'n2s-faint n2s-small' }, t('al.never'))),
7441
+ h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
7442
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: preview?.id === d.id && preview.busy, onClick: () => runPreview(d) }, t('pro.dg.preview')),
7443
+ h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
7444
+ { label: t('pro.sendNow'), icon: Icon.send(14), disabled: !d.channelId, onClick: () => act(() => call('runDigest', { id: d.id, send: true }), t('pro.sent')) },
7445
+ { label: d.enabled ? t('rep.pause') : t('rep.resume'), icon: Icon.refresh(14), onClick: () => act(() => call('saveDigest', { id: d.id, enabled: !d.enabled })) },
7446
+ { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing({ ...d, source: d.dashboardId ? 'dashboard' : 'metrics', channelId: d.channelId ?? '', dashboardId: d.dashboardId ?? '' }) },
7447
+ { divider: true },
7448
+ { label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'pro.deleteConfirm', { name: d.name }), danger: true })) act(() => call('deleteDigest', { id: d.id })) } },
7449
+ ] }))),
7450
+ preview?.id === d.id && h('div', { style: { padding: '0 12px 12px' } }, h(DigestPreview, { t, run: preview }), !preview.busy && h('button', { type: 'button', className: 'n2s-btn small ghost', style: { marginTop: 6 }, onClick: () => setPreview(null) }, t('pro.close')))))))
7451
+ }
7452
+ extend.adminSections.push({ id: 'digests', label: (t) => t('pro.dg.title'), icon: () => Icon.spark(15), Component: DigestsSection })
7453
+ // ------------------------------------------------- presentation (feature "present")
7454
+ // Dashboard menu: export PPT / long image, send the long image, presentation mode.
7455
+ // Result menu: export PPT. Admin section: Lark Bitable sync.
7456
+
7457
+ async function preDownload(t, method, payload, doneKey) {
7458
+ notify(t('pre.working'), 'info', 2500)
7459
+ try { const r = await call(method, payload); downloadBase64(r); if (doneKey) notify(t(doneKey), 'ok') } catch (e) { notifyError(e) }
7460
+ }
7461
+
7462
+ /** Pick an image-capable channel and send the dashboard long image. */
7463
+ function PreSendImageDialog({ t, boardId, onClose }) {
7464
+ const channels = useChannels().filter(c => c.type === 'lark_app' || c.type === 'telegram_bot')
7465
+ const [channelId, setChannelId] = useState('')
7466
+ const [busy, run] = useBusy()
7467
+ const [error, setError] = useState(null)
7468
+ useEffect(() => { if (!channelId && channels[0]) setChannelId(channels[0].id) }, [channels.length])
7469
+ const send = () => run(async () => {
7470
+ setError(null)
7471
+ try { const r = await call('sendDashboardImage', { dashboardId: boardId, channelId }); notify(fmt(t, 'pre.imageSent', { channel: r.channel }), 'ok'); onClose() } catch (e) { setError(e.message) }
7472
+ })
7473
+ return h(Modal, { label: t('pre.sendImage'), onClose, width: 460 },
7474
+ h('div', { className: 'n2s-modal-title' }, t('pre.sendImage')),
7475
+ h('div', { className: 'n2s-pre-dlg' },
7476
+ h('p', { className: 'n2s-muted n2s-small', style: { marginTop: 0 } }, t('pre.sendImageHint')),
7477
+ channels.length
7478
+ ? h('select', { className: 'n2s-select', 'aria-label': t('pre.channel'), value: channelId, onChange: e => setChannelId(e.target.value) }, channels.map(c => h('option', { key: c.id, value: c.id }, c.name)))
7479
+ : h('div', { className: 'n2s-notice warn' }, t('pre.noImageChannel')),
7480
+ error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error)),
7481
+ h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', gap: 8, marginTop: 16 } },
7482
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
7483
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy || !channelId, onClick: send }, busy ? h('span', { className: 'n2s-spin' }) : Icon.send(13), t('pre.send'))))
7484
+ }
7485
+
7486
+ // ------------------------------------------------------ presentation mode
7487
+
7488
+ const PRE_REFRESH = [60, 300, 900]
7489
+ const PRE_ROTATE = [0, 30, 60, 120, 300]
7490
+
7491
+ function PreWidget({ t, widget, filters, tick, onLoaded }) {
7492
+ const [state, setState] = useState({ busy: true })
7493
+ useEffect(() => {
7494
+ let alive = true
7495
+ setState(s => ({ ...s, busy: true }))
7496
+ call('runWidget', { id: widget.id, ...(filters ? { filters } : {}), fresh: tick > 0 }).then(
7497
+ r => { if (alive) { setState({ result: { ...r, title: '' } }); onLoaded?.() } },
7498
+ e => { if (alive) { setState({ error: e.message }); onLoaded?.() } })
7499
+ return () => { alive = false }
7500
+ }, [widget.id, tick, filters])
7501
+ const width = ['third', 'half', 'full'].includes(widget.width) ? widget.width : 'half'
7502
+ return h('section', { className: `n2s-pre-card w-${width}`, 'aria-label': widget.title },
7503
+ h('div', { className: 'n2s-pre-card-title' }, widget.title, state.busy && state.result ? h('span', { className: 'n2s-spin', style: { marginLeft: 8 } }) : null),
7504
+ state.error ? h('div', { className: 'n2s-notice err' }, state.error)
7505
+ : state.result ? h(ResultView, { result: state.result, t, compact: true, embedded: true, hideSql: true, onPinned: HIDDEN_PIN })
7506
+ : h('div', { className: 'n2s-skel', style: { height: 240 } }))
7507
+ }
7508
+
7509
+ function PresentationMode({ t, boardId, filters, onClose }) {
7510
+ const root = useRef(null)
7511
+ const boards = useAsync(() => call('listDashboards'), [])
7512
+ const list = boards.value?.dashboards ?? []
7513
+ const [current, setCurrent] = useState(boardId)
7514
+ const board = list.find(b => b.id === current)
7515
+ const widgets = useAsync(() => call('listDashboard', { dashboardId: current }), [current])
7516
+ const [refresh, setRefresh] = useState(() => { try { return Number(localStorage.getItem('nl2sql:preRefresh')) || 300 } catch { return 300 } })
7517
+ const [rotate, setRotate] = useState(0)
7518
+ const [tick, setTick] = useState(0)
7519
+ const [now, setNow] = useState(Date.now())
7520
+ const [lastAt, setLastAt] = useState(null)
7521
+ const [fs, setFs] = useState(false)
7522
+ // The opening board keeps the dashboard's current (unsaved) filters; other boards use their saved ones.
7523
+ const boardFilters = current === boardId ? filters : undefined
7524
+ const go = useCallback((delta) => {
7525
+ if (list.length < 2) return
7526
+ const i = Math.max(0, list.findIndex(b => b.id === current))
7527
+ setCurrent(list[(i + delta + list.length) % list.length].id)
7528
+ setLastAt(null)
7529
+ }, [list, current])
7530
+ useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id) }, [])
7531
+ useEffect(() => { const id = setInterval(() => setTick(x => x + 1), refresh * 1000); return () => clearInterval(id) }, [refresh])
7532
+ useEffect(() => { if (!rotate || list.length < 2) return; const id = setInterval(() => go(1), rotate * 1000); return () => clearInterval(id) }, [rotate, go, list.length])
7533
+ useEffect(() => {
7534
+ const onKey = (e) => {
7535
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
7536
+ else if (e.key === 'ArrowRight') { e.preventDefault(); go(1) }
7537
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1) }
7538
+ }
7539
+ document.addEventListener('keydown', onKey, true)
7540
+ return () => document.removeEventListener('keydown', onKey, true)
7541
+ }, [go, onClose])
7542
+ useEffect(() => {
7543
+ const el = root.current
7544
+ const prev = document.activeElement
7545
+ el?.focus()
7546
+ try { if (el?.requestFullscreen && !document.fullscreenElement) el.requestFullscreen().then(() => setFs(true), () => {}) } catch {}
7547
+ // Leaving browser fullscreen with Esc closes the overlay too.
7548
+ const onFs = () => { if (!document.fullscreenElement) { setFs(was => { if (was) onClose(); return false }) } }
7549
+ document.addEventListener('fullscreenchange', onFs)
7550
+ const overflow = document.body.style.overflow
7551
+ document.body.style.overflow = 'hidden'
7552
+ return () => {
7553
+ document.removeEventListener('fullscreenchange', onFs)
7554
+ document.body.style.overflow = overflow
7555
+ try { if (document.fullscreenElement) document.exitFullscreen?.() } catch {}
7556
+ prev?.focus?.()
7557
+ }
7558
+ }, [])
7559
+ const setRefreshSaved = (v) => { setRefresh(v); try { localStorage.setItem('nl2sql:preRefresh', String(v)) } catch {} }
7560
+ const items = widgets.value ?? []
7561
+ const clock = new Date(now)
7562
+ return ReactDOM.createPortal(h('div', { ref: root, className: 'n2s-pre', role: 'dialog', 'aria-modal': true, 'aria-label': t('pre.mode'), tabIndex: -1 },
7563
+ h('header', { className: 'n2s-pre-head' },
7564
+ h('div', { className: 'n2s-pre-titles' },
7565
+ h('h1', null, board?.name ?? ''),
7566
+ h('div', { className: 'n2s-pre-sub', role: 'status', 'aria-live': 'polite' },
7567
+ lastAt ? fmt(t, 'pre.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : t('pre.loading'),
7568
+ list.length > 1 ? ` · ${list.findIndex(b => b.id === current) + 1}/${list.length}` : '')),
7569
+ h('div', { className: 'n2s-pre-clock', 'aria-label': t('pre.clock') },
7570
+ h('div', { className: 'time' }, clock.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })),
7571
+ h('div', { className: 'date' }, clock.toLocaleDateString(uiLocale(), { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }))),
7572
+ h('div', { className: 'n2s-pre-ctl' },
7573
+ h('label', null, h('span', null, t('pre.refresh')), h('select', { value: refresh, onChange: e => setRefreshSaved(Number(e.target.value)) }, PRE_REFRESH.map(v => h('option', { key: v, value: v }, fmt(t, 'dash.autoEvery', { min: v / 60 }))))),
7574
+ list.length > 1 && h('label', null, h('span', null, t('pre.rotate')), h('select', { value: rotate, onChange: e => setRotate(Number(e.target.value)) }, PRE_ROTATE.map(v => h('option', { key: v, value: v }, v ? fmt(t, 'pre.everySec', { s: v }) : t('pre.rotateOff'))))),
7575
+ list.length > 1 && h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('pre.prev'), title: t('pre.prev'), onClick: () => go(-1) }, '←'),
7576
+ list.length > 1 && h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('pre.next'), title: t('pre.next'), onClick: () => go(1) }, '→'),
7577
+ h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('dash.refresh'), title: t('dash.refresh'), onClick: () => setTick(x => x + 1) }, Icon.refresh(16)),
7578
+ h('button', { type: 'button', className: 'n2s-pre-btn', 'aria-label': t('pre.exit'), title: t('pre.exitHint'), onClick: onClose }, Icon.close(16)))),
7579
+ widgets.error && h('div', { className: 'n2s-notice err' }, widgets.error.message),
7580
+ !widgets.loading && items.length === 0 && h('div', { className: 'n2s-pre-empty' }, t('dash.empty')),
7581
+ h('main', { className: 'n2s-pre-grid', key: current },
7582
+ items.map(w => h(PreWidget, { key: w.id, t, widget: w, filters: boardFilters, tick, onLoaded: () => setLastAt(Date.now()) })))), document.body)
7583
+ }
7584
+
7585
+ // ------------------------------------------------------------ menus
7586
+
7587
+ const preState = createStore({ present: null, sendImage: null })
7588
+ function PreLayer({ t }) {
7589
+ const s = useSyncExternalStore(preState.subscribe, preState.get, preState.get)
7590
+ return h(React.Fragment, null,
7591
+ s.present && h(PresentationMode, { t, boardId: s.present.boardId, filters: s.present.filters, onClose: () => preState.set({ present: null }) }),
7592
+ s.sendImage && h(PreSendImageDialog, { t, boardId: s.sendImage.boardId, onClose: () => preState.set({ sendImage: null }) }))
7593
+ }
7594
+ extend.dashboardBar.push(PreLayer)
7595
+
7596
+ extend.dashboardMenu.push(({ t, boardId, widgets, filters }) => [
7597
+ { divider: true },
7598
+ { label: t('pre.exportPpt'), icon: Icon.download(14), disabled: !widgets?.length, onClick: () => preDownload(t, 'exportDashboardPptx', { dashboardId: boardId, filters }, 'pre.exported') },
7599
+ { label: t('pre.exportImage'), icon: Icon.download(14), disabled: !widgets?.length, onClick: () => preDownload(t, 'exportDashboardImage', { dashboardId: boardId, filters, width: 1200 }, 'pre.exported') },
7600
+ { label: t('pre.sendImage'), icon: Icon.send(14), disabled: !widgets?.length, onClick: () => preState.set({ sendImage: { boardId } }) },
7601
+ { label: t('pre.mode'), icon: Icon.expand(14), onClick: () => preState.set({ present: { boardId, filters } }) },
7602
+ ])
7603
+
7604
+ extend.resultMenu.push(({ t, result }) => (result?.resultId ? [
7605
+ { label: t('pre.exportPpt'), icon: Icon.download(14), onClick: () => preDownload(t, 'exportResultsPptx', { resultIds: [result.resultId], title: result.title }, 'pre.exported') },
7606
+ ] : []))
7607
+
7608
+ // ------------------------------------------------------- Bitable sync admin
7609
+
7610
+ function PreBitableForm({ t, initial, datasources, onDone, onCancel }) {
7611
+ const channels = useChannels().filter(c => c.type === 'lark_app')
7612
+ const boards = useAsync(() => call('listDashboards'), [])
7613
+ const allWidgets = useAsync(async () => {
7614
+ const list = boards.value?.dashboards ?? []
7615
+ const per = await Promise.all(list.map(b => call('listDashboard', { dashboardId: b.id }).then(ws => ws.map(w => ({ ...w, board: b.name }))).catch(() => [])))
7616
+ return per.flat()
7617
+ }, [boards.value])
7618
+ const [form, setForm] = useState(() => ({
7619
+ name: '', channelId: '', appToken: '', tableId: '', mode: 'replace', schedule: { time: '08:00', enabled: false },
7620
+ kind: initial?.source?.sql ? 'sql' : 'widget', widgetId: initial?.source?.widgetId ?? '', datasourceId: initial?.source?.datasourceId ?? datasources[0]?.id ?? '', sql: initial?.source?.sql ?? '',
7621
+ ...(initial ?? {}),
7622
+ }))
7623
+ useEffect(() => { if (!form.channelId && channels[0]) setForm(f => ({ ...f, channelId: channels[0].id })) }, [channels.length])
7624
+ const [error, setError] = useState(null)
7625
+ const [busy, run] = useBusy()
7626
+ const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value }))
7627
+ // Paste a Bitable link: …/base/<app_token>?table=<table_id>
7628
+ const pasteLink = (e) => {
7629
+ const m = String(e.target.value).match(/\/base\/([A-Za-z0-9]+)(?:[^#]*[?&]table=(tbl[A-Za-z0-9]+))?/)
7630
+ if (m) { e.preventDefault(); setForm(f => ({ ...f, appToken: m[1], tableId: m[2] ?? f.tableId })) } else set('appToken')(e)
7631
+ }
7632
+ const save = () => run(async () => {
7633
+ setError(null)
7634
+ const source = form.kind === 'sql' ? { datasourceId: form.datasourceId, sql: form.sql } : { widgetId: form.widgetId }
7635
+ try { await call('saveBitableSync', { id: form.id, name: form.name, channelId: form.channelId, appToken: form.appToken, tableId: form.tableId, mode: form.mode, schedule: form.schedule, source }); onDone() } catch (e) { setError(e) }
7636
+ })
7637
+ const err = (field) => error?.field === field ? h('span', { className: 'n2s-small', style: { color: 'var(--n2s-err)' } }, error.message) : null
7638
+ return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
7639
+ h('div', { className: 'n2s-form' },
7640
+ h('label', { className: 'n2s-field' }, h('span', null, t('pre.bt.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name'), placeholder: t('pre.bt.namePh') })),
7641
+ h('label', { className: 'n2s-field' }, h('span', null, t('pre.bt.channel')),
7642
+ channels.length ? h('select', { className: 'n2s-select', value: form.channelId, onChange: set('channelId') }, channels.map(c => h('option', { key: c.id, value: c.id }, c.name))) : h('span', { className: 'n2s-small n2s-muted' }, t('pre.bt.noChannel')), err('channelId')),
7643
+ h('label', { className: 'n2s-field' }, h('span', null, 'app_token'), h('input', { className: 'n2s-input n2s-mono', value: form.appToken, onChange: pasteLink, placeholder: t('pre.bt.appTokenPh') }), err('appToken')),
7644
+ h('label', { className: 'n2s-field' }, h('span', null, 'table_id'), h('input', { className: 'n2s-input n2s-mono', value: form.tableId, onChange: set('tableId'), placeholder: 'tbl…' }), err('tableId')),
7645
+ h('div', { className: 'n2s-field full' }, h('span', null, t('pre.bt.source')), h('div', { className: 'n2s-seg' }, ['widget', 'sql'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.kind === v, onClick: () => setForm(f => ({ ...f, kind: v })) }, t(`pre.bt.src.${v}`))))),
7646
+ form.kind === 'widget'
7647
+ ? h('label', { className: 'n2s-field full' }, h('span', null, t('pre.bt.widget')), h('select', { className: 'n2s-select', value: form.widgetId, onChange: set('widgetId') }, h('option', { value: '' }, '—'), (allWidgets.value ?? []).map(w => h('option', { key: w.id, value: w.id }, `${w.board} · ${w.title}`))), err('source'))
7648
+ : h(React.Fragment, null,
7649
+ h('label', { className: 'n2s-field' }, h('span', null, t('ds.select')), h('select', { className: 'n2s-select', value: form.datasourceId, onChange: set('datasourceId') }, datasources.map(d => h('option', { key: d.id, value: d.id }, d.name)))),
7650
+ h('label', { className: 'n2s-field full' }, h('span', null, 'SQL'), h('textarea', { className: 'n2s-textarea n2s-mono', spellCheck: false, value: form.sql, onChange: set('sql'), placeholder: 'SELECT …' }), err('sql'))),
7651
+ h('div', { className: 'n2s-field' }, h('span', null, t('pre.bt.mode')), h('div', { className: 'n2s-seg' }, ['replace', 'append'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.mode === v, onClick: () => setForm(f => ({ ...f, mode: v })) }, t(`pre.bt.mode.${v}`)))),
7652
+ h('span', { className: 'n2s-faint n2s-small' }, t(`pre.bt.modeHint.${form.mode}`))),
7653
+ h('div', { className: 'n2s-field' }, h('span', null, t('pre.bt.schedule')),
7654
+ h('div', { className: 'n2s-row' },
7655
+ h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.schedule.enabled, onChange: e => setForm(f => ({ ...f, schedule: { ...f.schedule, enabled: e.target.checked } })) }), t('pre.bt.daily')),
7656
+ h('input', { className: 'n2s-input', type: 'time', 'aria-label': t('rep.time'), style: { width: 120 }, value: form.schedule.time, disabled: !form.schedule.enabled, onChange: e => setForm(f => ({ ...f, schedule: { ...f.schedule, time: e.target.value } })) })))),
7657
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('pre.bt.permHint')),
7658
+ error && !error.field && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error.message),
7659
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
7660
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7661
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: save }, busy ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
7662
+ }
7663
+
7664
+ function PreBitableSection({ t, datasources = [] }) {
7665
+ const list = useAsync(() => call('listBitableSyncs'), [])
7666
+ const [editing, setEditing] = useState(null)
7667
+ const [running, setRunning] = useState(null)
7668
+ const syncs = list.value ?? []
7669
+ const runNow = async (s) => {
7670
+ if (running) return
7671
+ setRunning(s.id)
7672
+ try { const r = await call('runBitableSync', { id: s.id }); notify(fmt(t, 'pre.bt.ran', { created: r.created, deleted: r.deleted }), 'ok') } catch (e) { notifyError(e) } finally { setRunning(null); list.reload() }
7673
+ }
7674
+ return h('div', null,
7675
+ h(SectionHead, { title: t('pre.bt.title'), desc: t('pre.bt.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('pre.bt.add'))),
7676
+ editing && h(PreBitableForm, { key: editing.id ?? 'new', t, datasources, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); list.reload() } }),
7677
+ list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
7678
+ !list.loading && !syncs.length && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('pre.bt.empty')), h('div', null, t('pre.bt.emptyHint'))),
7679
+ syncs.length > 0 && h('div', { className: 'n2s-list2' }, syncs.map(s => h('div', { key: s.id, className: 'n2s-list2-row' },
7680
+ h('span', { className: 'n2s-tile', 'aria-hidden': true }, Icon.table(16)),
7681
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
7682
+ h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, s.name), h('span', { className: 'n2s-badge' }, t(`pre.bt.mode.${s.mode}`)), s.schedule?.enabled && h('span', { className: 'n2s-badge blue' }, fmt(t, 'pre.bt.dailyAt', { time: s.schedule.time }))),
7683
+ h('div', { className: 'n2s-list2-sub' }, joinList([s.source?.widgetId ? `${t('pre.bt.src.widget')}${colon('')}${s.sourceLabel ?? '?'}` : `SQL · ${s.sourceLabel ?? ''}`, s.channelName ?? t('pre.bt.channelMissing'), `${s.appToken} / ${s.tableId}`], ' · ')),
7684
+ s.lastError && s.lastStatus === 'error' && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, s.lastError)),
7685
+ h('div', { className: 'n2s-list2-side' },
7686
+ h('span', { className: `n2s-statusdot ${s.lastStatus === 'error' ? 'err' : s.lastStatus === 'ok' ? 'ok' : ''}` }, s.lastStatus === 'ok' ? fmt(t, 'pre.bt.lastOk', { n: s.lastRows ?? 0 }) : s.lastStatus === 'error' ? t('pre.bt.lastErr') : t('pre.bt.never')),
7687
+ s.lastRunAt ? h('span', { className: 'n2s-faint n2s-small' }, new Date(s.lastRunAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })) : null),
7688
+ h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
7689
+ h('button', { type: 'button', className: 'n2s-btn small', disabled: Boolean(running), onClick: () => runNow(s) }, running === s.id ? h('span', { className: 'n2s-spin' }) : null, t('pre.bt.run')),
7690
+ h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
7691
+ { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(s) },
7692
+ { divider: true },
7693
+ { label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'pre.bt.deleteConfirm', { name: s.name }), danger: true })) { try { await call('deleteBitableSync', { id: s.id }) } catch (e) { notifyError(e) } list.reload() } } },
7694
+ ] }))))))
7695
+ }
7696
+
7697
+ extend.adminSections.push({ id: 'bitable', label: (t) => t('pre.bt.nav'), icon: () => Icon.table(15), Component: PreBitableSection })
7698
+ // ------------------------------------------------------------ MCP access (admin section)
7699
+ // Enable the local MCP server, manage bearer tokens (shown once) and their
7700
+ // scopes, and copy config snippets for Claude Desktop / Cursor / VS Code.
7701
+
7702
+ const MCP_PKG = '@hedeqiang/dsh-nl2sql'
7703
+
7704
+ /** Config snippets for a token (placeholder when the token value is no longer known). */
7705
+ function mcpSnippets(url, token) {
7706
+ const tok = token || '<YOUR_TOKEN>'
7707
+ return {
7708
+ claude: JSON.stringify({ mcpServers: { nl2sql: { command: 'npx', args: ['-y', '-p', MCP_PKG, 'nl2sql-mcp'], env: { NL2SQL_MCP_URL: url, NL2SQL_MCP_TOKEN: tok } } } }, null, 2),
7709
+ cursor: JSON.stringify({ mcpServers: { nl2sql: { url, headers: { Authorization: `Bearer ${tok}` } } } }, null, 2),
7710
+ vscode: JSON.stringify({ servers: { nl2sql: { type: 'http', url, headers: { Authorization: `Bearer ${tok}` } } } }, null, 2),
7711
+ }
7712
+ }
7713
+
7714
+ function McpCopy({ t, text, label }) {
7715
+ const [done, setDone] = useState(false)
7716
+ return h('button', {
7717
+ type: 'button', className: 'n2s-btn small', 'aria-label': label ?? t('mcp.copy'),
7718
+ onClick: async () => {
7719
+ try { await navigator.clipboard?.writeText(text); setDone(true); setTimeout(() => setDone(false), 1500) } catch (e) { notifyError(e) }
7720
+ },
7721
+ }, done ? Icon.check(13) : Icon.copy(13), ' ', done ? t('mcp.copied') : (label ?? t('mcp.copy')))
7722
+ }
7723
+
7724
+ function McpSnippets({ t, url, token }) {
7725
+ const [tab, setTab] = useState('claude')
7726
+ const snip = mcpSnippets(url, token)
7727
+ const where = { claude: 'mcp.where.claude', cursor: 'mcp.where.cursor', vscode: 'mcp.where.vscode' }
7728
+ return h('div', { className: 'n2s-mcp-snippets' },
7729
+ h('div', { className: 'n2s-seg', role: 'tablist' }, ['claude', 'cursor', 'vscode'].map(k => h('button', { key: k, type: 'button', role: 'tab', 'aria-selected': tab === k, 'aria-pressed': tab === k, onClick: () => setTab(k) }, t(`mcp.client.${k}`)))),
7730
+ h('div', { className: 'n2s-faint n2s-small', style: { margin: '6px 0' } }, t(where[tab])),
7731
+ h('div', { className: 'n2s-mcp-code' },
7732
+ h('pre', { className: 'n2s-code', 'data-snippet': tab }, snip[tab]),
7733
+ h('div', { className: 'n2s-mcp-codeact' }, h(McpCopy, { t, text: snip[tab] }))),
7734
+ !token && h('div', { className: 'n2s-faint n2s-small' }, t('mcp.placeholderHint')))
7735
+ }
7736
+
7737
+ function McpScopeLabel({ t, scope }) {
7738
+ if (scope.type === 'member') return h('span', null, h('span', { className: 'n2s-badge' }, t('mcp.scope.member')), ' ', scope.memberName ?? scope.memberId, scope.missing ? h('span', { className: 'n2s-badge warn' }, t('mcp.memberMissing')) : scope.disabled ? h('span', { className: 'n2s-badge warn' }, t('mcp.memberDisabled')) : null)
7739
+ if (scope.type === 'role') return h('span', null, h('span', { className: 'n2s-badge' }, t(`mcp.role.${scope.role}`)), ' ', scope.datasourceNames ? joinList(scope.datasourceNames) : t('mcp.allDs'), scope.rowRules?.length ? h('span', { className: 'n2s-faint' }, paren(fmt(t, 'mcp.rowRulesN', { n: scope.rowRules.length }))) : null)
7740
+ return h('span', { className: 'n2s-badge ok' }, t('mcp.scope.owner'))
7741
+ }
7742
+
7743
+ function McpTokenForm({ t, datasources, onCreated, onCancel }) {
7744
+ const users = useAsync(() => call('listUsers').catch(() => []), [])
7745
+ const [form, setForm] = useState({ name: '', type: 'owner', memberId: '', role: 'analyst', datasourceIds: [], rowColumn: '', rowValues: '' })
7746
+ const [busy, setBusy] = useState(false)
7747
+ const [err, setErr] = useState(null)
7748
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
7749
+ const members = (users.value ?? []).filter(u => !u.disabled)
7750
+ const submit = async () => {
7751
+ setErr(null)
7752
+ const scope = form.type === 'member' ? { memberId: form.memberId }
7753
+ : form.type === 'role' ? { role: form.role, datasourceIds: form.datasourceIds, ...(form.rowColumn.trim() && form.rowValues.trim() ? { rowRules: [{ column: form.rowColumn.trim(), values: form.rowValues }] } : {}) }
7754
+ : { kind: 'owner' }
7755
+ if (!form.name.trim()) return setErr(t('mcp.err.name'))
7756
+ if (form.type === 'member' && !form.memberId) return setErr(t('mcp.err.member'))
7757
+ if (form.type === 'role' && !form.datasourceIds.length) return setErr(t('mcp.err.ds'))
7758
+ setBusy(true)
7759
+ try { onCreated(await call('createMcpToken', { name: form.name.trim(), scope })) } catch (e) { setErr(e.message) }
7760
+ setBusy(false)
7761
+ }
7762
+ return h('div', { className: 'n2s-card n2s-mcp-form' },
7763
+ h('div', { className: 'n2s-sechead-title', style: { marginBottom: 10 } }, t('mcp.newToken')),
7764
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.tokenName')),
7765
+ h('input', { className: 'n2s-input', value: form.name, maxLength: 60, placeholder: t('mcp.tokenNamePh'), onChange: e => set('name', e.target.value) })),
7766
+ h('div', { className: 'n2s-field', role: 'group', 'aria-label': t('mcp.scope') },
7767
+ h('span', { className: 'n2s-flabel' }, t('mcp.scope')),
7768
+ h('div', { className: 'n2s-seg', role: 'radiogroup' }, ['owner', 'member', 'role'].map(k => h('button', { key: k, type: 'button', role: 'radio', 'aria-checked': form.type === k, 'aria-pressed': form.type === k, onClick: () => set('type', k) }, t(`mcp.scope.${k}`)))),
7769
+ h('span', { className: 'n2s-faint n2s-small' }, t(`mcp.scopeHint.${form.type}`))),
7770
+ form.type === 'member' && h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.member')),
7771
+ members.length
7772
+ ? h('select', { className: 'n2s-select', value: form.memberId, onChange: e => set('memberId', e.target.value) },
7773
+ h('option', { value: '' }, t('mcp.pickMember')),
7774
+ members.map(u => h('option', { key: u.id, value: u.id }, `${u.name} · ${t(`mcp.role.${u.role}`)}`)))
7775
+ : h('span', { className: 'n2s-faint n2s-small' }, t('mcp.noMembers'))),
7776
+ form.type === 'role' && h(React.Fragment, null,
7777
+ h('label', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.role')),
7778
+ h('select', { className: 'n2s-select', value: form.role, onChange: e => set('role', e.target.value) }, ['analyst', 'viewer', 'admin'].map(r => h('option', { key: r, value: r }, t(`mcp.role.${r}`))))),
7779
+ h('div', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.datasources')),
7780
+ h('div', { className: 'n2s-chips' }, (datasources ?? []).map(d => h('button', { key: d.id, type: 'button', className: 'n2s-chip', 'aria-pressed': form.datasourceIds.includes(d.id), onClick: () => set('datasourceIds', form.datasourceIds.includes(d.id) ? form.datasourceIds.filter(x => x !== d.id) : [...form.datasourceIds, d.id]) }, d.name)))),
7781
+ h('div', { className: 'n2s-field' }, h('span', { className: 'n2s-flabel' }, t('mcp.rowRule')),
7782
+ h('div', { className: 'n2s-row' },
7783
+ h('input', { className: 'n2s-input n2s-mono', style: { maxWidth: 180 }, 'aria-label': t('mcp.rowColumn'), placeholder: t('mcp.rowColumn'), value: form.rowColumn, onChange: e => set('rowColumn', e.target.value) }),
7784
+ h('input', { className: 'n2s-input', 'aria-label': t('mcp.rowValues'), placeholder: t('mcp.rowValues'), value: form.rowValues, onChange: e => set('rowValues', e.target.value) })),
7785
+ h('span', { className: 'n2s-faint n2s-small' }, t('mcp.rowRuleHint')))),
7786
+ err && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 8 } }, err),
7787
+ h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 12 } },
7788
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
7789
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: submit }, busy ? h('span', { className: 'n2s-spin' }) : null, t('mcp.create'))))
7790
+ }
7791
+
7792
+ function McpSection({ t, datasources }) {
7793
+ const st = useAsync(() => call('mcpStatus'), [])
7794
+ const [port, setPort] = useState('')
7795
+ const [busy, setBusy] = useState(false)
7796
+ const [creating, setCreating] = useState(false)
7797
+ const [fresh, setFresh] = useState(null) // { name, token } shown once
7798
+ useEffect(() => { if (st.value) setPort(String(st.value.port)) }, [st.value?.port])
7799
+ const s = st.value
7800
+ const when = (ms) => (ms ? new Date(ms).toLocaleString(uiLocale(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : t('mcp.never'))
7801
+ const save = async (patch) => {
7802
+ setBusy(true)
7803
+ try { await call('saveMcpSettings', patch); st.reload() } catch (e) { notifyError(e) }
7804
+ setBusy(false)
7805
+ }
7806
+ const revoke = async (tok) => {
7807
+ if (!(await confirmDialog({ title: fmt(t, 'mcp.revokeConfirm', { name: tok.name }), message: t('mcp.revokeHint'), confirmLabel: t('mcp.revoke'), danger: true }))) return
7808
+ try { await call('revokeMcpToken', { id: tok.id }); if (fresh?.id === tok.id) setFresh(null); notify(t('mcp.revoked'), 'ok'); st.reload() } catch (e) { notifyError(e) }
7809
+ }
7810
+ if (!s) return st.error ? h('div', { className: 'n2s-notice err' }, st.error.message) : h('div', { className: 'n2s-skel', style: { height: 200 } })
7811
+ const portDirty = port !== String(s.port)
7812
+ return h('div', { className: 'n2s-mcp' },
7813
+ h(SectionHead, { title: t('mcp.title'), desc: t('mcp.desc') }),
7814
+ h('div', { className: 'n2s-card n2s-mcp-server' },
7815
+ h('div', { className: 'n2s-row', style: { flexWrap: 'wrap', gap: 14 } },
7816
+ h('label', { className: 'n2s-switch' },
7817
+ h('input', { type: 'checkbox', role: 'switch', checked: s.enabled, disabled: busy, onChange: e => save({ enabled: e.target.checked }) }),
7818
+ h('span', null, s.enabled ? t('mcp.enabled') : t('mcp.disabled'))),
7819
+ h('span', { className: `n2s-statusdot ${s.running ? 'ok' : s.error ? 'err' : ''}`, 'data-mcp-status': s.running ? 'running' : 'stopped' }, s.running ? t('mcp.running') : t('mcp.stopped')),
7820
+ h('span', { className: 'n2s-grow' }),
7821
+ h('label', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-flabel' }, t('mcp.port')),
7822
+ h('input', { className: 'n2s-input n2s-mono', style: { width: 96 }, inputMode: 'numeric', value: port, 'aria-label': t('mcp.port'), onChange: e => setPort(e.target.value.replace(/\D/g, '').slice(0, 5)) }),
7823
+ portDirty && h('button', { type: 'button', className: 'n2s-btn small', disabled: busy || !port, onClick: () => save({ port: Number(port) }) }, t('mcp.applyPort')))),
7824
+ s.error && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 10 } }, s.error),
7825
+ h('div', { className: 'n2s-mcp-endpoint' },
7826
+ h('span', { className: 'n2s-flabel' }, t('mcp.endpoint')),
7827
+ h('code', { className: 'n2s-mono', 'data-mcp-url': true }, s.url),
7828
+ h(McpCopy, { t, text: s.url })),
7829
+ h('div', { className: 'n2s-faint n2s-small' }, t('mcp.localOnly'))),
7830
+ fresh && h('div', { className: 'n2s-card n2s-mcp-fresh', role: 'status' },
7831
+ h('div', { className: 'n2s-sechead-title' }, fmt(t, 'mcp.freshTitle', { name: fresh.name })),
7832
+ h('div', { className: 'n2s-notice warn', style: { margin: '8px 0' } }, t('mcp.freshHint')),
7833
+ h('div', { className: 'n2s-mcp-endpoint' }, h('code', { className: 'n2s-mono', 'data-mcp-token': true }, fresh.token), h(McpCopy, { t, text: fresh.token })),
7834
+ h(McpSnippets, { t, url: s.url, token: fresh.token }),
7835
+ h('div', { className: 'n2s-row', style: { justifyContent: 'flex-end', marginTop: 8 } }, h('button', { type: 'button', className: 'n2s-btn small', onClick: () => setFresh(null) }, t('mcp.done')))),
7836
+ h('div', { className: 'n2s-row', style: { margin: '18px 0 8px' } },
7837
+ h('div', { className: 'n2s-sechead-title n2s-grow' }, t('mcp.tokens')),
7838
+ !creating && h('button', { type: 'button', className: 'n2s-btn', onClick: () => setCreating(true) }, '+ ', t('mcp.newToken'))),
7839
+ creating && h(McpTokenForm, { t, datasources, onCancel: () => setCreating(false), onCreated: (tok) => { setCreating(false); setFresh(tok); st.reload() } }),
7840
+ s.tokens.length === 0 && !creating
7841
+ ? h('div', { className: 'n2s-empty' }, h('h3', null, t('mcp.noTokens')), h('div', null, t('mcp.noTokensHint')))
7842
+ : s.tokens.length > 0 && h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table n2s-mcp-tokens' },
7843
+ h('thead', null, h('tr', null, [t('mcp.col.name'), t('mcp.col.scope'), t('mcp.col.created'), t('mcp.col.lastUsed'), ''].map((x, i) => h('th', { key: i }, x)))),
7844
+ h('tbody', null, s.tokens.map(tok => h('tr', { key: tok.id, 'data-token-id': tok.id },
7845
+ h('td', null, h('div', null, tok.name), h('div', { className: 'n2s-faint n2s-small n2s-mono' }, `${tok.prefix}…`)),
7846
+ h('td', null, h(McpScopeLabel, { t, scope: tok.scope })),
7847
+ h('td', { className: 'n2s-small' }, when(tok.createdAt)),
7848
+ h('td', { className: 'n2s-small' }, when(tok.lastUsedAt)),
7849
+ h('td', { style: { textAlign: 'right' } }, h('button', { type: 'button', className: 'n2s-btn small danger', onClick: () => revoke(tok) }, t('mcp.revoke')))))))),
7850
+ h('div', { className: 'n2s-sechead-title', style: { margin: '18px 0 8px' } }, t('mcp.setup')),
7851
+ h('div', { className: 'n2s-card' }, h(McpSnippets, { t, url: s.url, token: null }),
7852
+ h('div', { className: 'n2s-faint n2s-small', style: { marginTop: 8 } }, t('mcp.docsHint'))))
7853
+ }
7854
+
7855
+ extend.adminSections.push({ id: 'mcp', label: (t) => t('mcp.nav'), icon: () => Icon.code(15), Component: McpSection })
5891
7856
  // ------------------------------------------------------------ apply
5892
7857
 
5893
7858
  return {
@@ -5902,6 +7867,9 @@ window.__ModuleLoader__.load({
5902
7867
  }, 'nl2sql: styles')
5903
7868
  ctx.effect(() => ctx.locale.register(NS, { zh, en, 'zh-CN': zh }), 'nl2sql: locale')
5904
7869
  const t = ctx.locale.bind(NS)
7870
+ // One feedback layer (toasts + confirm / prompt dialogs) for the page and every
7871
+ // tool view or reply action in conversations, mounted once outside the page.
7872
+ ctx.effect(() => mountFeedbackLayer(ctx), 'nl2sql: feedback layer')
5905
7873
  const Page = makePage(ctx)
5906
7874
  const PanelIcon = ({ size }) => Icon.db(size ?? 16)
5907
7875
  ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Page))
@@ -5910,6 +7878,7 @@ window.__ModuleLoader__.load({
5910
7878
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_query', locale: NS }, makeToolView(ctx, 'query')))
5911
7879
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_chart', locale: NS }, makeToolView(ctx, 'chart')))
5912
7880
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_overview', locale: NS }, makeOverviewToolView(ctx)))
7881
+ for (const v of extend.toolViews) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: v.key, locale: NS }, v.make(ctx)))
5913
7882
  ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({ name: 'conversation.chat.assistant-actions', id: 'nl2sql-send', order: 20, locale: NS }, makeReplyAction(ctx)))
5914
7883
  },
5915
7884
  }