@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.1

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
@@ -17,6 +17,129 @@ window.__ModuleLoader__.load({
17
17
  // ------------------------------------------------------------ locale
18
18
 
19
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": "已删除",
20
143
  'mb.pick.people': "从和机器人说过话的人中选择",
21
144
  'mb.pick.chats': "从机器人所在的群中选择",
22
145
  'mb.pick.refresh': "刷新",
@@ -1046,6 +1169,129 @@ window.__ModuleLoader__.load({
1046
1169
  'policy.tablesLabel': '可访问的表',
1047
1170
  }
1048
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",
1049
1295
  'mb.pick.people': "Pick someone who talked to a bot",
1050
1296
  'mb.pick.chats': "Pick a group the bot is in",
1051
1297
  'mb.pick.refresh': "Refresh",
@@ -1998,6 +2244,100 @@ window.__ModuleLoader__.load({
1998
2244
  .n2s-pickchip:disabled{opacity:.55;cursor:default}
1999
2245
  .n2s-pickchip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
2000
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)}
2001
2341
  /* ---- dashboard v2 ---- */
2002
2342
  .n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
2003
2343
  .n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
@@ -2179,6 +2519,21 @@ window.__ModuleLoader__.load({
2179
2519
  /** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
2180
2520
  let uiLang = 'zh'
2181
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
+
2182
2537
  /** Join a list for display in the UI language: 「a、b」 / "a, b". */
2183
2538
  const joinList = (items, sep) => (items ?? []).filter(x => x !== undefined && x !== null && x !== '').join(sep ?? (uiLang === 'zh' ? '、' : ', '))
2184
2539
  /** Label + colon in the UI language: 「标签:」 / "Label: ". */
@@ -2740,6 +3095,7 @@ window.__ModuleLoader__.load({
2740
3095
  isChart && chartInstance && { label: t('result.png'), icon: Icon.download(14), onClick: exportPng },
2741
3096
  !hidden && { divider: true },
2742
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 }),
2743
3099
  ]
2744
3100
  const chartPicker = result.rows.length > 0 && h(Dropdown, {
2745
3101
  label: t('result.chartType'),
@@ -2757,6 +3113,7 @@ window.__ModuleLoader__.load({
2757
3113
  h('span', { className: 'n2s-grow' }),
2758
3114
  chartPicker),
2759
3115
  body,
3116
+ ...extend.resultPanels.map((C, k) => h(C, { key: `rp${k}`, t, result })),
2760
3117
  h('div', { className: 'n2s-result-foot' },
2761
3118
  !hideSql && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': showSql, onClick: () => setShowSql(v => !v) }, Icon.code(14), t('result.sql')),
2762
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')),
@@ -3139,6 +3496,7 @@ window.__ModuleLoader__.load({
3139
3496
  const rels2 = tbl ? schema.relations.filter(r => r.from.table === tbl.name || r.to.table === tbl.name) : []
3140
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
3141
3498
  return h('div', null,
3499
+ schema && extend.schemaPanels.map((C, k) => h(C, { key: `sp${k}`, t, ds, schema, selected })),
3142
3500
  h('div', { className: 'n2s-toolbar' },
3143
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' }) })),
3144
3502
  h('span', { className: 'n2s-grow' }),
@@ -3292,6 +3650,7 @@ window.__ModuleLoader__.load({
3292
3650
  const tableCount = ds?.tableCount ?? data?.tableCount
3293
3651
  return h('div', null,
3294
3652
  h(Onboarding, { t, ds, onNavigate, onAsk, examples }),
3653
+ ...extend.askPanels.map((C, k) => h(C, { key: `ap${k}`, t, ds, onAsk, onNavigate })),
3295
3654
  h('div', { className: 'n2s-hero' },
3296
3655
  h('h2', null, t('ask.heroTitle')),
3297
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'))),
@@ -4901,7 +5260,9 @@ window.__ModuleLoader__.load({
4901
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: [
4902
5261
  { label: t('result.excel'), icon: Icon.download(14), onClick: () => exportExcel({ dashboardId: boards.value?.current }) },
4903
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 ?? [] }),
4904
5264
  ] }),
5265
+ ...extend.dashboardBar.map((C, i) => h(C, { key: `x${i}`, t, boardId: boards.value?.current, widgets, filters: filters ?? [] })),
4905
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)) },
4906
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) })) }),
4907
5268
  h('button', { type: 'button', className: 'n2s-btn small', onClick: () => setTick(x => x + 1) }, t('dash.refresh')),
@@ -5767,7 +6128,13 @@ window.__ModuleLoader__.load({
5767
6128
  // where the user was.
5768
6129
 
5769
6130
  const PRIMARY_TABS = ['ask', 'dashboard', 'schema', 'knowledge', 'history']
5770
- 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']
5771
6138
  const NAV_KEY = 'nl2sql:nav'
5772
6139
 
5773
6140
  function loadNav() {
@@ -5791,10 +6158,14 @@ window.__ModuleLoader__.load({
5791
6158
  const autoSub = sub === 'reports' ? 'reports' : 'alerts'
5792
6159
  return h('div', { className: 'n2s-admin' },
5793
6160
  h('nav', { className: 'n2s-adminnav', 'aria-label': t('tab.admin') },
5794
- 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 }) },
5795
- h('span', { className: 'ico', 'aria-hidden': true }, AdminIcons[k]()),
5796
- h('span', { className: 'n2s-grow' }, t(`admin.${k}`)),
5797
- 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
+ })),
5798
6169
  h('div', { className: 'n2s-adminbody' },
5799
6170
  section === 'sources' ? h(SourcesTab, { t, datasources }) :
5800
6171
  section === 'members' ? h(MembersTab, { t, datasources }) :
@@ -5805,6 +6176,7 @@ window.__ModuleLoader__.load({
5805
6176
  k === 'reports' && reports?.length ? h('span', { className: 'n2s-faint', style: { marginLeft: 6 } }, reports.length) : null))),
5806
6177
  autoSub === 'alerts' ? h(AlertsTab, { t, ds, datasources }) : h(ReportsTab, { t, ds, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id), onOpenSchedules: () => ctx.layout.selectPanel('schedules') })) :
5807
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 }) :
5808
6180
  h(SettingsPage, { t, datasources, onNavigate })))
5809
6181
  }
5810
6182
 
@@ -5885,7 +6257,7 @@ window.__ModuleLoader__.load({
5885
6257
  nav.tab === 'knowledge' ? h(KnowledgeTab, { key: `kb:${ds?.id ?? ''}`, t, ds }) :
5886
6258
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
5887
6259
  nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
5888
- h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
6260
+ h(AdminArea, { t, ctx, section: adminSections().includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: guardedNav, ds, datasources })))))
5889
6261
  }
5890
6262
  }
5891
6263
 
@@ -6180,6 +6552,1307 @@ window.__ModuleLoader__.load({
6180
6552
  row('docs', 'github.com/hedeqiang/dsh-nl2sql'))))
6181
6553
  }
6182
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 })
6183
7856
  // ------------------------------------------------------------ apply
6184
7857
 
6185
7858
  return {
@@ -6205,6 +7878,7 @@ window.__ModuleLoader__.load({
6205
7878
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_query', locale: NS }, makeToolView(ctx, 'query')))
6206
7879
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'nl2sql_chart', locale: NS }, makeToolView(ctx, 'chart')))
6207
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)))
6208
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)))
6209
7883
  },
6210
7884
  }