@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -15,6 +15,119 @@ window.__ModuleLoader__.load({
15
15
  // ------------------------------------------------------------ locale
16
16
 
17
17
  const zh = {
18
+ 'dash.sourceOf': "数据源",
19
+ 'dash.sourceMissing': "该图表的数据源已删除,无法刷新",
20
+ 'dash.sourceMissingShort': "数据源已删除",
21
+ 'dash.scope': "显示范围",
22
+ 'dash.scopeAll': "全部数据源",
23
+ 'dash.scopeCurrent': "仅 {name}",
24
+ 'dash.sources': "包含数据源:{list}",
25
+ 'dash.scopeEmpty': "这个看板里没有「{name}」的图表",
26
+ 'dash.scopeEmptyHint': "另有 {n} 个其他数据源的图表被隐藏。",
27
+ 'im.attachFull': "超过 {n} 行的结果附带完整数据 Excel",
28
+ 'im.rowsLimit': "消息中每个结果最多显示 {n} 行,超出部分会注明;完整数据可在页面导出 Excel",
29
+ 'im.sentFile': "已附带完整数据 Excel",
30
+ "settings.botDefaultRole": "未登记的用户",
31
+ "settings.larkDefaultRole": "未登记的用户",
32
+ "ch.savedTestFailedMsg": "已保存,但测试失败:{msg}",
33
+ "ch.p.lark": "飞书",
34
+ "ch.p.telegram": "Telegram",
35
+ "ch.p.wecom": "企业微信",
36
+ "ch.p.dingtalk": "钉钉",
37
+ "ch.p.lark.mark": "飞",
38
+ "ch.p.telegram.mark": "TG",
39
+ "ch.p.wecom.mark": "企",
40
+ "ch.p.dingtalk.mark": "钉",
41
+ "ch.k.lark_webhook": "在群设置里添加的自定义机器人,复制 Webhook 地址即可,只能推送。",
42
+ "ch.k.lark_app": "企业自建应用机器人:可发给群或个人、列出群聊,并能在飞书里 @机器人 提问。",
43
+ "ch.f.signSecret": "开启「签名校验」后填写;不填则不签名。",
44
+ "ch.f.receiveId": "群聊可点「选择群聊」自动填写。",
45
+ "ch.desc": "渠道是与一个 IM 平台的连接,用于推送查询结果、看板、定时报告和告警;支持的渠道还能作为机器人,在群里或私聊中直接提问。",
46
+ "ch.emptyHint": "添加飞书、Telegram、企业微信或钉钉渠道,把结果推送到群里,或开启机器人在 IM 里提问。",
47
+ "ch.addTitle": "添加渠道",
48
+ "ch.addSub": "选择平台和接入方式,下一步填写凭据。",
49
+ "ch.editTitle": "编辑渠道「{name}」",
50
+ "ch.back": "返回上一步",
51
+ "ch.step.pick": "选择平台",
52
+ "ch.step.config": "填写配置",
53
+ "ch.pickPlatform": "平台",
54
+ "ch.pickKind": "{platform} 的接入方式",
55
+ "ch.kindsN": "{n} 种接入方式",
56
+ "ch.noCatalog": "没有可用的渠道类型。",
57
+ "ch.unknownKind": "当前版本不支持渠道类型「{type}」。",
58
+ "ch.noPublicUrl": "无需公网地址",
59
+ "ch.cap.native": "原生图表",
60
+ "ch.cap.image": "图片图表",
61
+ "ch.cap.none": "仅表格",
62
+ "ch.cap.buttons": "按钮",
63
+ "ch.cap.bot": "机器人",
64
+ "ch.bot.state.off": "未启用",
65
+ "ch.bot.state.connecting": "连接中",
66
+ "ch.bot.state.connected": "已连接",
67
+ "ch.bot.state.error": "连接失败",
68
+ "ch.bot.state.stopped": "已停止",
69
+ "ch.secretKeep": "已设置,留空不修改",
70
+ "ch.secretSet": "已设置",
71
+ "ch.err.required": "必填",
72
+ "ch.err.pattern": "格式不正确",
73
+ "ch.err.url": "请输入以 http(s):// 开头的地址",
74
+ "ch.setupSteps": "配置步骤({n} 步)",
75
+ "ch.pickHint": "用当前填写的凭据列出机器人所在的群,不会保存。",
76
+ "ch.tablesOnly": "该渠道只能发送表格。",
77
+ "ch.cmImage": "图表会渲染成图片发送。",
78
+ "ch.botSection": "机器人",
79
+ "ch.botSectionHint": "启用后,群里 @机器人 或私聊即可提问;机器人在 Harness 中开一个数据问答会话作答并回复。成员权限见「成员与权限」。",
80
+ "ch.botEnable": "启用机器人",
81
+ "ch.testSavesFirst": "测试前会先保存。",
82
+ "ch.testConn": "测试连接",
83
+ "ch.testOk": "测试成功",
84
+ "ch.savedTestFailed": "已保存,但测试失败",
85
+ "ch.saved": "渠道已保存",
86
+ "ch.deleted": "渠道已删除",
87
+ "ch.defaultSet": "已设为默认渠道",
88
+ "ch.actionsFor": "「{name}」的操作",
89
+ "ch.botSettings": "机器人设置",
90
+ "ch.deleteBotWarn": "这个渠道的机器人也会停止。",
91
+ "ch.failedN": "{n} 条失败",
92
+ "ch.bots": "机器人",
93
+ "ch.botsHint": "在 IM 里 @机器人 或私聊提问,按下方成员权限作答。",
94
+ "ch.bots.empty": "还没有支持机器人的渠道",
95
+ "ch.bots.emptyHint": "添加飞书应用机器人、Telegram、企业微信或钉钉的机器人渠道后,就能在 IM 里直接提问,无需公网地址。",
96
+ "ch.bots.goAdd": "去添加渠道",
97
+ "ch.botToggle": "启用「{name}」的机器人",
98
+ "ch.botOn": "「{name}」的机器人已启用",
99
+ "ch.botOff": "「{name}」的机器人已停用",
100
+ "ch.restart": "重连",
101
+ "ch.convsN": "最近对话 {n}",
102
+ "ch.p2pOn": "允许私聊",
103
+ "ch.sum.on": "{on} / {total} 个已启用",
104
+ "ch.sum.err": "{n} 个连接失败",
105
+ "mb.kind2.person": "个人",
106
+ "mb.kind2.chat": "群聊",
107
+ "mb.ids.person": "IM 身份",
108
+ "mb.ids.chat": "群聊 ID",
109
+ "mb.ids.personHint": "用于识别在 IM 里提问的人;可以添加多个平台的身份。",
110
+ "mb.ids.chatHint": "这个群里的人 @机器人 时按群的权限作答。",
111
+ "mb.ids.add": "添加身份",
112
+ "mb.ids.platformN": "身份 {n} 的平台",
113
+ "mb.ids.idN": "身份 {n} 的 ID",
114
+ "mb.ids.emailN": "身份 {n} 的邮箱(可选)",
115
+ "mb.ids.emailPh": "邮箱(可选)",
116
+ "mb.ids.remove": "删除身份 {n}",
117
+ "mb.ids.empty": "请填写 ID 或邮箱,或删除这一行",
118
+ "mb.ids.needOne": "至少需要一个身份,用于识别成员",
119
+ "mb.ids.none": "未设置身份",
120
+ "mb.idHelp.lark.person": "飞书 open_id(ou_ 开头),可在开放平台 API 调试台或机器人收到的消息事件里找到;也可只填邮箱。",
121
+ "mb.idHelp.lark.chat": "飞书群 chat_id(oc_ 开头),可在「渠道」编辑应用机器人时用「选择群聊」查看。",
122
+ "mb.idHelp.telegram.person": "Telegram 数字用户 ID,可让对方给 @userinfobot 发消息获取。",
123
+ "mb.idHelp.telegram.chat": "Telegram 群 chat id(通常是 -100 开头的负数),可在「渠道」里用「选择群聊」查看。",
124
+ "mb.idHelp.wecom.person": "企业微信 userid(通讯录 → 成员详情里的「账号」)。",
125
+ "mb.idHelp.wecom.chat": "企业微信群聊 chatid,可在机器人收到的消息里找到。",
126
+ "mb.idHelp.dingtalk.person": "钉钉 userId(管理后台 → 通讯录 → 成员详情)。",
127
+ "mb.idHelp.dingtalk.chat": "钉钉群 openConversationId(cid 开头),可在机器人收到的消息里找到。",
128
+ "set.l.unknownUser": "未登记的用户",
129
+ "set.h.botDefaultRole": "不在「成员」列表中的人在群里 @机器人 或私聊时的权限,适用于所有平台的机器人。",
130
+ "set.h.defaultChannel": "Agent 推送、看板发送等未指定渠道时使用。修改会立即生效。",
18
131
  'ask.shuffle': "换一批",
19
132
  'ask.deleteRecent': "删除这条提问记录",
20
133
  'ask.deleteRecentMsg': "将删除「{q}」的全部历史记录(不影响会话本身)。",
@@ -56,7 +169,7 @@ window.__ModuleLoader__.load({
56
169
  'admin.sources': "数据源",
57
170
  'admin.members': "成员与权限",
58
171
  'admin.automation': "告警与报告",
59
- 'admin.push': "推送渠道",
172
+ 'admin.push': "渠道",
60
173
  'admin.settings': "设置",
61
174
  'ds.empty': "还没有数据源",
62
175
  'ds.emptyHint': "先连接一个数据库(MySQL / PostgreSQL / Elasticsearch),或上传一个 CSV / Excel 文件。",
@@ -79,8 +192,8 @@ window.__ModuleLoader__.load({
79
192
  'ob.ask': "试问一个问题",
80
193
  'ob.ask.desc': "用下面推荐的问题试一下,Agent 会写 SQL、执行并画图。",
81
194
  'ob.ask.cta': "试一个问题",
82
- 'ob.push': "配置飞书推送(可选)",
83
- 'ob.push.desc': "把查询结果、看板、定时报告和告警发到飞书群。",
195
+ 'ob.push': "配置推送渠道(可选)",
196
+ 'ob.push.desc': "把查询结果、看板、定时报告和告警发到飞书、Telegram、企业微信或钉钉群。",
84
197
  'ob.push.cta': "去配置",
85
198
  'ask.ex.tables': "这个库里有哪些核心业务表?分别是做什么的?",
86
199
  'ask.ex.biggest': "数据量最大的 10 张表是哪些?",
@@ -115,13 +228,13 @@ window.__ModuleLoader__.load({
115
228
  'set.g.privacy': "模型可见数据",
116
229
  'set.g.query': "查询",
117
230
  'set.g.automation': "自动任务",
118
- 'set.g.lark': "飞书",
231
+ 'set.g.lark': "渠道与机器人",
119
232
  'set.g.storage': "数据与存储",
120
233
  'set.g.about': "关于",
121
234
  'set.gd.privacy': "决定哪些数据会发给模型。查询结果始终完整显示在页面上。",
122
235
  'set.gd.query': "查询的行数、时间和保护。",
123
236
  'set.gd.automation': "告警调度和表结构变更检测。",
124
- 'set.gd.lark': "飞书机器人和未登记用户的权限。",
237
+ 'set.gd.lark': "默认渠道、未登记用户的权限和各平台机器人。",
125
238
  'set.gd.storage': "本地存储、日志保留和配置备份。",
126
239
  'set.gd.about': "版本和运行环境信息。",
127
240
  'settings.sample.off.desc': "模型只看到表名、字段名和类型。",
@@ -152,9 +265,9 @@ window.__ModuleLoader__.load({
152
265
  'set.openAlerts': "查看告警",
153
266
  'set.h.larkDefaultRole': "不在「成员」列表中的飞书用户在群里 @机器人 或私聊时的权限。选择允许时,他们只能访问下方「成员与权限」中设定的默认数据源范围。",
154
267
  'set.configure': "配置",
155
- 'set.defaultChannel': "默认推送渠道",
268
+ 'set.defaultChannel': "默认渠道",
156
269
  'set.noDefaultChannel': "未设置默认渠道",
157
- 'set.noChannels': "还没有推送渠道",
270
+ 'set.noChannels': "还没有渠道",
158
271
  'set.resultsDb': "结果库",
159
272
  'set.resultsDbSub': "{results} 个结果 · {cache} 条缓存",
160
273
  'set.stateFile': "配置文件",
@@ -390,10 +503,10 @@ window.__ModuleLoader__.load({
390
503
  'al.events': '告警记录',
391
504
  'al.message': '内容',
392
505
  'al.deleteConfirm': '删除告警「{name}」?',
393
- 'mb.hint': '为飞书里的同事或群开通数据问答:按成员 / 群限定可访问的数据源,审计记录会归属到具体的人。本机页面始终拥有全部权限。',
506
+ 'mb.hint': "为 IM 里的同事或群开通数据问答:按成员 / 群限定可访问的数据源,审计记录会归属到具体的人。本机页面始终拥有全部权限。",
394
507
  'mb.add': '添加成员',
395
508
  'mb.empty': '还没有成员',
396
- 'mb.emptyHint': '添加飞书同事(open_id 或邮箱)或飞书群(chat_id),再启用上方的飞书机器人,大家就能在飞书里 @机器人 提问。',
509
+ 'mb.emptyHint': "添加同事(飞书、Telegram、企业微信或钉钉的用户 ID)或群聊,再启用上方的机器人,大家就能在 IM 里 @机器人 提问。",
397
510
  'mb.kind': '类型',
398
511
  'mb.kind.person': '飞书用户',
399
512
  'mb.kind.chat': '飞书群',
@@ -414,7 +527,7 @@ window.__ModuleLoader__.load({
414
527
  'mb.usage': '使用情况(近 30 天)',
415
528
  'mb.user': '成员',
416
529
  'mb.lastActive': '最近使用',
417
- 'mb.default': '未登记的飞书用户:',
530
+ 'mb.default': "未登记的用户:",
418
531
  'mb.defaultRole.none': '拒绝(需要先添加为成员)',
419
532
  'mb.defaultRole.viewer': '允许,只读',
420
533
  'mb.defaultRole.analyst': '允许,分析师',
@@ -494,7 +607,7 @@ window.__ModuleLoader__.load({
494
607
  'result.chartType': '图表类型',
495
608
  'result.more': '更多操作',
496
609
  'result.data': '数据',
497
- 'im.sendReply': '发送这条回复到飞书',
610
+ 'im.sendReply': "发送这条回复到 IM",
498
611
  'im.chartMode': '图表形式',
499
612
  'im.cm.default': '跟随渠道',
500
613
  'im.cm.native': '原生图表',
@@ -557,12 +670,12 @@ window.__ModuleLoader__.load({
557
670
  'im.col.time': '时间', 'im.col.channel': '渠道', 'im.col.title': '标题', 'im.col.source': '来源', 'im.col.status': '结果',
558
671
  'im.src.ui': '页面', 'im.src.agent': 'Agent', 'im.src.dashboard': '看板', 'im.src.test': '测试',
559
672
  'im.resultsN': '{count} 个结果', 'im.imagesN': '{count} 张图',
560
- 'im.share': '发送到飞书',
673
+ 'im.share': "发送到 IM",
561
674
  'im.shareAll': '全部发送到 IM',
562
675
  'im.shareDashboard': '发送看板',
563
676
  'im.dashboardTitle': '数据看板',
564
- 'im.sendTitle': '发送到飞书 / Lark',
565
- 'im.noChannel': '还没有推送渠道,请先在「推送」页签添加。',
677
+ 'im.sendTitle': "发送到 IM 渠道",
678
+ 'im.noChannel': "还没有渠道,请先在「管理 → 渠道」添加。",
566
679
  'im.channel': '发送到',
567
680
  'im.msgTitle': '标题',
568
681
  'im.text': '正文(Markdown)',
@@ -905,6 +1018,119 @@ window.__ModuleLoader__.load({
905
1018
  'error.generic': '操作失败:{message}',
906
1019
  }
907
1020
  const en = {
1021
+ 'dash.sourceOf': "Data source",
1022
+ 'dash.sourceMissing': "This chart's data source was deleted; it cannot refresh",
1023
+ 'dash.sourceMissingShort': "source deleted",
1024
+ 'dash.scope': "Show",
1025
+ 'dash.scopeAll': "All sources",
1026
+ 'dash.scopeCurrent': "Only {name}",
1027
+ 'dash.sources': "Sources: {list}",
1028
+ 'dash.scopeEmpty': "No charts from \"{name}\" on this dashboard",
1029
+ 'dash.scopeEmptyHint': "{n} charts from other sources are hidden.",
1030
+ 'im.attachFull': "Attach full data as Excel for results over {n} rows",
1031
+ 'im.rowsLimit': "Messages show up to {n} rows per result and say when rows are omitted; export Excel on the page for full data",
1032
+ 'im.sentFile': "full data attached as Excel",
1033
+ "settings.botDefaultRole": "Unregistered users",
1034
+ "settings.larkDefaultRole": "Unregistered users",
1035
+ "ch.savedTestFailedMsg": "Saved, but the test failed: {msg}",
1036
+ "ch.p.lark": "Lark",
1037
+ "ch.p.telegram": "Telegram",
1038
+ "ch.p.wecom": "WeCom",
1039
+ "ch.p.dingtalk": "DingTalk",
1040
+ "ch.p.lark.mark": "L",
1041
+ "ch.p.telegram.mark": "TG",
1042
+ "ch.p.wecom.mark": "W",
1043
+ "ch.p.dingtalk.mark": "D",
1044
+ "ch.k.lark_webhook": "A custom bot added in group settings. Paste its webhook URL; push only.",
1045
+ "ch.k.lark_app": "A custom app bot: message groups or people, list chats, and answer questions when @-mentioned in Lark.",
1046
+ "ch.f.signSecret": "Fill in when signature verification is on; leave empty to send unsigned.",
1047
+ "ch.f.receiveId": "For groups, use \"Pick chat\" to fill this in.",
1048
+ "ch.desc": "A channel is one connection to an IM platform. It delivers results, dashboards, scheduled reports and alerts; channels that support it can also run a chat bot that answers questions in groups or DMs.",
1049
+ "ch.emptyHint": "Add a Lark, Telegram, WeCom or DingTalk channel to push results to a group, or turn on a bot to ask questions from IM.",
1050
+ "ch.addTitle": "Add channel",
1051
+ "ch.addSub": "Pick a platform and connection type; credentials come next.",
1052
+ "ch.editTitle": "Edit channel \"{name}\"",
1053
+ "ch.back": "Back",
1054
+ "ch.step.pick": "Platform",
1055
+ "ch.step.config": "Configure",
1056
+ "ch.pickPlatform": "Platform",
1057
+ "ch.pickKind": "How to connect {platform}",
1058
+ "ch.kindsN": "{n} connection types",
1059
+ "ch.noCatalog": "No channel types are available.",
1060
+ "ch.unknownKind": "Channel type \"{type}\" is not supported by this version.",
1061
+ "ch.noPublicUrl": "No public URL needed",
1062
+ "ch.cap.native": "Native charts",
1063
+ "ch.cap.image": "Chart images",
1064
+ "ch.cap.none": "Tables only",
1065
+ "ch.cap.buttons": "Buttons",
1066
+ "ch.cap.bot": "Bot",
1067
+ "ch.bot.state.off": "Off",
1068
+ "ch.bot.state.connecting": "Connecting",
1069
+ "ch.bot.state.connected": "Connected",
1070
+ "ch.bot.state.error": "Error",
1071
+ "ch.bot.state.stopped": "Stopped",
1072
+ "ch.secretKeep": "Set; leave empty to keep",
1073
+ "ch.secretSet": "Set",
1074
+ "ch.err.required": "Required",
1075
+ "ch.err.pattern": "Invalid format",
1076
+ "ch.err.url": "Enter an address starting with http(s)://",
1077
+ "ch.setupSteps": "Setup steps ({n})",
1078
+ "ch.pickHint": "Lists the chats the bot is in using the credentials above; nothing is saved.",
1079
+ "ch.tablesOnly": "This channel can only send tables.",
1080
+ "ch.cmImage": "Charts are rendered and sent as images.",
1081
+ "ch.botSection": "Bot",
1082
+ "ch.botSectionHint": "When on, people @-mention the bot in a group or DM it to ask; it answers in a Data Q&A session here. Access follows Members & access.",
1083
+ "ch.botEnable": "Enable bot",
1084
+ "ch.testSavesFirst": "Testing saves the channel first.",
1085
+ "ch.testConn": "Test connection",
1086
+ "ch.testOk": "Test succeeded",
1087
+ "ch.savedTestFailed": "Saved, but the test failed",
1088
+ "ch.saved": "Channel saved",
1089
+ "ch.deleted": "Channel deleted",
1090
+ "ch.defaultSet": "Default channel set",
1091
+ "ch.actionsFor": "Actions for \"{name}\"",
1092
+ "ch.botSettings": "Bot settings",
1093
+ "ch.deleteBotWarn": "The bot on this channel stops too.",
1094
+ "ch.failedN": "{n} failed",
1095
+ "ch.bots": "Bots",
1096
+ "ch.botsHint": "@-mention or DM a bot in IM to ask; answers follow the member permissions below.",
1097
+ "ch.bots.empty": "No bot-capable channels yet",
1098
+ "ch.bots.emptyHint": "Add a Lark app bot, Telegram, WeCom or DingTalk bot channel to ask questions from IM, no public URL needed.",
1099
+ "ch.bots.goAdd": "Add a channel",
1100
+ "ch.botToggle": "Enable the bot of \"{name}\"",
1101
+ "ch.botOn": "Bot of \"{name}\" enabled",
1102
+ "ch.botOff": "Bot of \"{name}\" disabled",
1103
+ "ch.restart": "Reconnect",
1104
+ "ch.convsN": "Recent chats {n}",
1105
+ "ch.p2pOn": "DMs allowed",
1106
+ "ch.sum.on": "{on} / {total} enabled",
1107
+ "ch.sum.err": "{n} failing",
1108
+ "mb.kind2.person": "Person",
1109
+ "mb.kind2.chat": "Group chat",
1110
+ "mb.ids.person": "IM identities",
1111
+ "mb.ids.chat": "Group chat IDs",
1112
+ "mb.ids.personHint": "Identifies this person when they ask from IM; add one per platform.",
1113
+ "mb.ids.chatHint": "People in this group get the group's access when they @-mention the bot.",
1114
+ "mb.ids.add": "Add identity",
1115
+ "mb.ids.platformN": "Platform of identity {n}",
1116
+ "mb.ids.idN": "ID of identity {n}",
1117
+ "mb.ids.emailN": "Email of identity {n} (optional)",
1118
+ "mb.ids.emailPh": "Email (optional)",
1119
+ "mb.ids.remove": "Remove identity {n}",
1120
+ "mb.ids.empty": "Enter an ID or email, or remove this row",
1121
+ "mb.ids.needOne": "Add at least one identity so the member can be recognized",
1122
+ "mb.ids.none": "No identity",
1123
+ "mb.idHelp.lark.person": "Lark open_id (starts with ou_), from the API explorer or a message event; an email alone also works.",
1124
+ "mb.idHelp.lark.chat": "Lark chat_id (starts with oc_); use \"Pick chat\" when editing the app bot channel.",
1125
+ "mb.idHelp.telegram.person": "Numeric Telegram user id; the person can get it from @userinfobot.",
1126
+ "mb.idHelp.telegram.chat": "Telegram group chat id (usually negative, starting with -100); use \"Pick chat\" on the channel.",
1127
+ "mb.idHelp.wecom.person": "WeCom userid (the \"Account\" in the contact details).",
1128
+ "mb.idHelp.wecom.chat": "WeCom group chatid, found in messages the bot receives.",
1129
+ "mb.idHelp.dingtalk.person": "DingTalk userId (admin console → Contacts → member details).",
1130
+ "mb.idHelp.dingtalk.chat": "DingTalk group openConversationId (starts with cid), found in messages the bot receives.",
1131
+ "set.l.unknownUser": "Unregistered users",
1132
+ "set.h.botDefaultRole": "Access for people not in the member list when they @-mention or DM a bot, on every platform.",
1133
+ "set.h.defaultChannel": "Used when the agent or a dashboard send names no channel. Applies immediately.",
908
1134
  'ask.shuffle': "Shuffle",
909
1135
  'ask.deleteRecent': "Delete this question from history",
910
1136
  'ask.deleteRecentMsg': "All history entries of \"{q}\" will be deleted (the conversation itself is kept).",
@@ -946,7 +1172,7 @@ window.__ModuleLoader__.load({
946
1172
  'admin.sources': "Data sources",
947
1173
  'admin.members': "Members & access",
948
1174
  'admin.automation': "Alerts & reports",
949
- 'admin.push': "Push channels",
1175
+ 'admin.push': "Channels",
950
1176
  'admin.settings': "Settings",
951
1177
  'ds.empty': "No data sources yet",
952
1178
  'ds.emptyHint': "Connect a database (MySQL / PostgreSQL / Elasticsearch) or upload a CSV / Excel file.",
@@ -969,8 +1195,8 @@ window.__ModuleLoader__.load({
969
1195
  'ob.ask': "Ask a question",
970
1196
  'ob.ask.desc': "Try a suggested question below; the agent writes SQL, runs it and charts it.",
971
1197
  'ob.ask.cta': "Try one",
972
- 'ob.push': "Set up Lark push (optional)",
973
- 'ob.push.desc': "Send results, dashboards, scheduled reports and alerts to Lark groups.",
1198
+ 'ob.push': "Set up a push channel (optional)",
1199
+ 'ob.push.desc': "Send results, dashboards, scheduled reports and alerts to Lark, Telegram, WeCom or DingTalk groups.",
974
1200
  'ob.push.cta': "Set up",
975
1201
  'ask.ex.tables': "What are the core business tables in this database and what do they do?",
976
1202
  'ask.ex.biggest': "Which 10 tables have the most rows?",
@@ -1005,13 +1231,13 @@ window.__ModuleLoader__.load({
1005
1231
  'set.g.privacy': "Data shown to the model",
1006
1232
  'set.g.query': "Queries",
1007
1233
  'set.g.automation': "Automation",
1008
- 'set.g.lark': "Lark",
1234
+ 'set.g.lark': "Channels & bots",
1009
1235
  'set.g.storage': "Data & storage",
1010
1236
  'set.g.about': "About",
1011
1237
  'set.gd.privacy': "What data is sent to the model. Results always show in full on the page.",
1012
1238
  'set.gd.query': "Row limits, time limits and protection.",
1013
1239
  'set.gd.automation': "Alert scheduling and schema change detection.",
1014
- 'set.gd.lark': "Lark bot and permissions of unregistered users.",
1240
+ 'set.gd.lark': "Default channel, access for unregistered users, and bots per platform.",
1015
1241
  'set.gd.storage': "Local storage, log retention and configuration backup.",
1016
1242
  'set.gd.about': "Version and runtime.",
1017
1243
  'settings.sample.off.desc': "The model sees table names, column names and types only.",
@@ -1042,9 +1268,9 @@ window.__ModuleLoader__.load({
1042
1268
  'set.openAlerts': "View alerts",
1043
1269
  'set.h.larkDefaultRole': "Permission for Lark users not in the member list when they @ the bot or DM it.",
1044
1270
  'set.configure': "Configure",
1045
- 'set.defaultChannel': "Default push channel",
1271
+ 'set.defaultChannel': "Default channel",
1046
1272
  'set.noDefaultChannel': "No default channel",
1047
- 'set.noChannels': "No push channels yet",
1273
+ 'set.noChannels': "No channels yet",
1048
1274
  'set.resultsDb': "Results database",
1049
1275
  'set.resultsDbSub': "{results} results · {cache} cached",
1050
1276
  'set.stateFile': "Configuration file",
@@ -1128,7 +1354,7 @@ window.__ModuleLoader__.load({
1128
1354
  'filter.add': '+ Add filter', 'filter.remove': 'Remove filter', 'filter.clear': 'Clear',
1129
1355
  'filter.hint': 'Filters are injected into the outer WHERE of charts whose tables have the column; other charts (or UNION/derived-table queries) stay unchanged.',
1130
1356
  'filter.appliedCount': '{count} filters', 'filter.notApplied': 'This chart has none of the filter columns; global filters not applied',
1131
- 'im.sendReply': 'Send this reply to Lark', 'im.chartMode': 'Charts as', 'im.cm.default': 'Channel default', 'im.cm.native': 'Native chart', 'im.cm.image': 'Image', 'im.cm.table': 'Table',
1357
+ 'im.sendReply': "Send this reply to IM", 'im.chartMode': 'Charts as', 'im.cm.default': 'Channel default', 'im.cm.native': 'Native chart', 'im.cm.image': 'Image', 'im.cm.table': 'Table',
1132
1358
  'im.cmHint': 'Native charts are interactive in Lark (client 7.1+) and work for both bot types; images need an app bot.',
1133
1359
  'im.md.edit': 'Edit', 'im.md.preview': 'Preview', 'im.md.empty': '(empty)', 'im.md.bold': 'Bold', 'im.md.heading': 'Heading', 'im.md.list': 'List', 'im.md.quote': 'Quote',
1134
1360
  'im.md.green': 'Green', 'im.md.red': 'Red', 'im.md.table': 'Table', 'im.md.link': 'Link', 'im.md.atAll': '@all',
@@ -1149,7 +1375,7 @@ window.__ModuleLoader__.load({
1149
1375
  'ev.section': 'Evaluation', 'ev.hint': 'Verified examples form the evaluation set. Agent mode answers each question from scratch (without seeing its example) and compares results with the example SQL; SQL mode only replays the example SQL to check compatibility.', 'ev.mode.agent': 'Agent answers', 'ev.mode.sql': 'Replay SQL only', 'ev.cases': '{count} cases', 'ev.run': 'Run evaluation', 'ev.running': 'Running {done}/{total}', 'ev.status.pass': 'pass', 'ev.status.fail': 'mismatch', 'ev.status.error': 'error', 'ev.sim': 'SQL similarity {p}%', 'ev.compareSql': 'Compare SQL (left: example, right: agent)', 'ev.include': 'Include',
1150
1376
  'sc.title': '{count} schema changes ({time})', 'sc.ack': 'Dismiss', 'sc.impacts': 'May affect', 'sc.table-added': 'table added', 'sc.table-removed': 'table removed', 'sc.column-added': 'column added', 'sc.column-removed': 'column removed', 'sc.column-type': 'type changed', 'sc.imp.widget': 'chart', 'sc.imp.example': 'example', 'sc.imp.metric': 'metric', 'sc.imp.note': 'note', 'sc.imp.alert': 'alert',
1151
1377
  'al.hint': 'Check metrics or SQL on a schedule and push to Lark when a condition holds: threshold, change, anomaly (z-score) or missing data. Recovery is notified too.', 'al.add': 'New alert', 'al.empty': 'No alerts yet', 'al.emptyHint': 'e.g. notify ops when daily GMV drops more than 20%.', 'al.name': 'Name', 'al.namePh': 'e.g. GMV drop', 'al.source': 'Source', 'al.src.metric': 'Metric library', 'al.src.sql': 'Custom SQL', 'al.metric': 'Metric', 'al.noMetrics': 'No metrics defined yet', 'al.grain': 'Grain', 'al.grain.hour': 'Hourly', 'al.grain.day': 'Daily', 'al.grain.week': 'Weekly', 'al.grain.month': 'Monthly', 'al.sql': 'SQL (first column time, last numeric column value)', 'al.condition': 'Condition', 'al.cond.threshold': 'Threshold', 'al.cond.change': 'Change', 'al.cond.anomaly': 'Anomaly', 'al.cond.missing': 'No data', 'al.condHint.threshold': 'Compares the latest value.', 'al.condHint.change': 'Latest vs previous value.', 'al.condHint.anomaly': 'Deviation of the latest value from the last 14 points, in standard deviations.', 'al.condHint.missing': 'Fires when no rows or the latest value is empty.', 'al.anomalyHint': '(last 14 points)', 'al.dir.any': 'up or down', 'al.dir.up': 'up', 'al.dir.down': 'down', 'al.every': 'Check every', 'al.every.15m': '15 minutes', 'al.every.hour': 'hour', 'al.every.day': 'day', 'al.cooldown': 'Repeat interval (min)', 'al.noPush': 'Record only', 'al.mentionAll': '@all when pushing', 'al.test': 'Dry run', 'al.wouldFire': 'Would fire', 'al.wouldNotFire': 'Would not fire', 'al.firing': 'Firing', 'al.ok': 'OK', 'al.never': 'Not checked', 'al.last': 'last', 'al.checkNow': 'Check now', 'al.pushed': 'pushed', 'al.events': 'Alert log', 'al.message': 'Message', 'al.deleteConfirm': 'Delete alert "{name}"?',
1152
- 'mb.hint': 'Give Lark colleagues or groups access: scope data sources per person or group; audit entries are attributed. This page always has full access.', 'mb.add': 'Add member', 'mb.empty': 'No members yet', 'mb.emptyHint': 'Add Lark users (open_id or email) or groups (chat_id), then enable the Lark bot above.', 'mb.kind': 'Type', 'mb.kind.person': 'Lark user', 'mb.kind.chat': 'Lark group', 'mb.role': 'Role', 'mb.role.admin': 'Admin', 'mb.role.analyst': 'Analyst', 'mb.role.viewer': 'Viewer', 'mb.email': 'Email', 'mb.scope': 'Data sources', 'mb.allDs': 'All data sources', 'mb.noDs': 'none', 'mb.disabled': 'disabled', 'mb.enable': 'Enable', 'mb.disable': 'Disable', 'mb.deleteConfirm': 'Delete member "{name}"?', 'mb.queries': '{n} queries in 30 days', 'mb.noUsage': 'No queries in 30 days', 'mb.usage': 'Usage (30 days)', 'mb.user': 'Member', 'mb.lastActive': 'Last active', 'mb.default': 'Unregistered Lark users:', 'mb.defaultRole.none': 'Refuse (add as member first)', 'mb.defaultRole.viewer': 'Allow, viewer', 'mb.defaultRole.analyst': 'Allow, analyst',
1378
+ 'mb.hint': "Give colleagues or groups in IM access: scope data sources per person or group; audit entries are attributed. This page always has full access.", 'mb.add': 'Add member', 'mb.empty': 'No members yet', 'mb.emptyHint': "Add people (Lark, Telegram, WeCom or DingTalk user IDs) or group chats, then enable a bot above to ask from IM.", 'mb.kind': 'Type', 'mb.kind.person': 'Lark user', 'mb.kind.chat': 'Lark group', 'mb.role': 'Role', 'mb.role.admin': 'Admin', 'mb.role.analyst': 'Analyst', 'mb.role.viewer': 'Viewer', 'mb.email': 'Email', 'mb.scope': 'Data sources', 'mb.allDs': 'All data sources', 'mb.noDs': 'none', 'mb.disabled': 'disabled', 'mb.enable': 'Enable', 'mb.disable': 'Disable', 'mb.deleteConfirm': 'Delete member "{name}"?', 'mb.queries': '{n} queries in 30 days', 'mb.noUsage': 'No queries in 30 days', 'mb.usage': 'Usage (30 days)', 'mb.user': 'Member', 'mb.lastActive': 'Last active', 'mb.default': "Unregistered users:", 'mb.defaultRole.none': 'Refuse (add as member first)', 'mb.defaultRole.viewer': 'Allow, viewer', 'mb.defaultRole.analyst': 'Allow, analyst',
1153
1379
  'bot.title': 'Lark bot (@ to ask)', 'bot.hint': 'Receives messages over the Lark long connection (no public URL). @ the bot in a group or DM it; it answers in a Data Q&A session here and replies with a card.', 'bot.enable': 'Enabled', 'bot.channel': 'App bot', 'bot.noAppChannel': 'Add an app bot channel on the Push tab first', 'bot.scope': 'Session scope', 'bot.scope.chat': 'One session per chat', 'bot.scope.user': 'One session per person per chat', 'bot.p2p': 'Allow direct messages', 'bot.state.idle': 'Off', 'bot.state.connecting': 'Connecting', 'bot.state.connected': 'Connected', 'bot.state.error': 'Error', 'bot.state.stopped': 'Stopped', 'bot.setup': 'Lark console setup', 'bot.step1': 'Create a custom app, note App ID / Secret, add it as an app bot channel on the Push tab.', 'bot.step2': 'Enable the bot capability.', 'bot.step3': 'Grant im:message, im:message.p2p_msg:readonly, im:message.group_at_msg:readonly, im:message:send_as_bot, im:resource, im:chat:readonly, contact:user.base:readonly (plus contact:user.email:readonly to match by email).', 'bot.step4': 'Enable the bot here so the long connection is online, then choose "Receive events through persistent connection" in Events & Callbacks.', 'bot.step5': 'Subscribe to im.message.receive_v1, create and publish a version.', 'bot.step6': 'Add the bot to groups; @ it to ask. Send "帮助" for commands.', 'bot.convs': 'Recent Lark conversations', 'bot.chat': 'Chat', 'bot.lastQ': 'Last question', 'bot.p2pShort': 'DM',
1154
1380
  'settings.cacheTtl': 'Query cache TTL (s, 0 = off)', 'settings.cacheStats': 'Result store', 'settings.cacheStatsText': '{results} results · {mb} MB · {cache} cached', 'settings.clearCache': 'Clear cache', 'settings.schemaWatch': 'Schema change detection', 'settings.schemaWatchOn': 'Compare with the previous analysis and flag added/removed/changed columns and affected charts, examples, metrics and alerts', 'settings.schemaDaily': 'Daily automatic check at (empty = off)', 'settings.schemaNotify': 'Notify via',
1155
1381
  'up.button': 'Upload file', 'up.title': 'Upload CSV / Excel', 'up.drop': 'Drop a file here or click to choose', 'up.formats': '.csv / .tsv / .xlsx (each sheet becomes a table), up to 200 MB; GBK CSV detected automatically', 'up.parsing': 'Parsing…', 'up.target': 'Import into', 'up.newSource': 'New local file data source', 'up.newName': 'Data source name', 'up.mode': 'Existing table', 'up.mode.create': 'Fail', 'up.mode.replace': 'Replace', 'up.mode.append': 'Append', 'up.table': 'Table', 'up.rows': '{rows} rows · {cols} columns', 'up.truncated': 'truncated', 'up.type.TEXT': 'Text', 'up.type.INTEGER': 'Integer', 'up.type.REAL': 'Decimal', 'up.type.DATE': 'Date', 'up.type.DATETIME': 'Datetime', 'up.import': 'Import', 'up.done': 'Imported into "{name}": ', 'up.privacy': 'Files stay on this machine and are queried read-only.', 'up.addFile': 'Upload more files', 'up.localDesc': 'Local files (SQLite), read-only', 'up.dropConfirm': 'Delete table "{name}"?', 'up.askLink': 'Upload a CSV / Excel to ask about', 'up.askHint': 'Imported and selected; ask away.',
@@ -1170,8 +1396,8 @@ window.__ModuleLoader__.load({
1170
1396
  'im.lastFailed': 'Last send failed', 'im.lastSent': 'Last sent', 'im.webhookHidden': 'Webhook URL stored encrypted', 'im.deleteConfirm': 'Delete channel "{name}"? Reports using it stop pushing.',
1171
1397
  'im.log': 'Delivery log', 'im.col.time': 'Time', 'im.col.channel': 'Channel', 'im.col.title': 'Title', 'im.col.source': 'From', 'im.col.status': 'Result',
1172
1398
  'im.src.ui': 'Page', 'im.src.agent': 'Agent', 'im.src.dashboard': 'Dashboard', 'im.src.test': 'Test', 'im.resultsN': '{count} results', 'im.imagesN': '{count} images',
1173
- 'im.share': 'Send to IM', 'im.shareAll': 'Send all to IM', 'im.shareDashboard': 'Send dashboard', 'im.dashboardTitle': 'Dashboard', 'im.sendTitle': 'Send to Lark / Feishu',
1174
- 'im.noChannel': 'No channels yet; add one on the Push tab.', 'im.channel': 'Send to', 'im.msgTitle': 'Title', 'im.text': 'Message (Markdown, optional)', 'im.textPh': 'e.g. GMV +12% day over day, mostly from the east region',
1399
+ 'im.share': 'Send to IM', 'im.shareAll': 'Send all to IM', 'im.shareDashboard': 'Send dashboard', 'im.dashboardTitle': 'Dashboard', 'im.sendTitle': "Send to IM channel",
1400
+ 'im.noChannel': "No channels yet; add one under Admin → Channels.", 'im.channel': 'Send to', 'im.msgTitle': 'Title', 'im.text': 'Message (Markdown, optional)', 'im.textPh': 'e.g. GMV +12% day over day, mostly from the east region',
1175
1401
  'im.attach': 'Attaching {count} results.', 'im.dashboardHint': 'Re-runs every dashboard chart (max 10) with the current global filters, then sends.', 'im.webhookNoImage': 'Group bots cannot send images; charts go as tables.',
1176
1402
  'im.includeSql': 'Include SQL', 'im.send': 'Send', 'im.sent': 'Sent to {channel} ({images} images)', 'rep.push': 'Push to', 'rep.pushNone': 'Do not push',
1177
1403
  'tab.reports': 'Reports', 'ds.type': 'Database type', 'ds.indexPattern': 'Indices (comma separated, wildcards allowed)', 'ds.userOptional': 'User (optional)',
@@ -1717,6 +1943,8 @@ window.__ModuleLoader__.load({
1717
1943
  .n2s-morebtn:hover{background:var(--n2s-hover);color:var(--dsw-alias-label-primary)}
1718
1944
  .n2s-btn.danger-text{color:var(--n2s-err)}
1719
1945
  /* ---- dashboard v2 ---- */
1946
+ .n2s-srcbadge{display:inline-flex;align-items:center;gap:3px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:none}
1947
+ .n2s-seg.small button{height:26px;padding:0 10px;font-size:12px}
1720
1948
  .n2s-dash{grid-template-columns:repeat(6,minmax(0,1fr));align-items:start}
1721
1949
  .n2s-widget.w-third{grid-column:span 2}
1722
1950
  .n2s-widget.w-half{grid-column:span 3}
@@ -1736,6 +1964,66 @@ window.__ModuleLoader__.load({
1736
1964
  .n2s-dashbar{flex-wrap:wrap;row-gap:8px}
1737
1965
  .n2s-dashbar-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
1738
1966
  @media (width<=640px){.n2s-dashbar-actions{width:100%;justify-content:flex-end}.n2s-dashtabs{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}.n2s-dashtabs [role=tab]{flex:none}}
1967
+ /* ---- channels, bots, identities ---- */
1968
+ .n2s-flabel{color:var(--n2s-muted);font-size:12px;display:inline-flex;align-items:center;gap:2px}
1969
+ .n2s-req{color:var(--n2s-err)}
1970
+ .n2s-fielderr{color:var(--n2s-err);font-size:12px;line-height:1.45}
1971
+ .n2s-input.invalid,.n2s-input[aria-invalid=true],.n2s-select[aria-invalid=true]{border-color:var(--n2s-err);box-shadow:0 0 0 3px color-mix(in srgb,var(--n2s-err) 12%,transparent)}
1972
+ .n2s-mono-soft{font-variant-ligatures:none}
1973
+ .n2s-pmark{font-weight:700;letter-spacing:-.02em;flex:none;line-height:1}
1974
+ .n2s-caps{display:inline-flex;flex-wrap:wrap;gap:4px}
1975
+ .n2s-cap{display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:999px;border:.5px solid var(--n2s-line-2);font-size:11px;color:var(--n2s-muted);white-space:nowrap}
1976
+ .n2s-cap.muted{color:var(--n2s-faint);border-style:dashed}
1977
+ .n2s-cap.bot{color:var(--n2s-accent);border-color:color-mix(in srgb,var(--n2s-accent) 40%,transparent);background:color-mix(in srgb,var(--n2s-accent) 6%,transparent)}
1978
+ .n2s-statusdot.conn::before{background:var(--n2s-warn);animation:n2s-pulse 1.2s ease-in-out infinite}
1979
+ .n2s-statusdot.warn::before{background:var(--n2s-warn)}
1980
+ @keyframes n2s-pulse{50%{opacity:.35}}
1981
+ .n2s-chpick{display:flex;align-items:center;gap:8px;min-width:0}
1982
+ .n2s-chpick .n2s-select{flex:1}
1983
+ .n2s-chpick-none{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:7px;background:var(--n2s-fill);color:var(--n2s-faint);flex:none}
1984
+ .n2s-pgroups{display:grid;gap:18px}
1985
+ .n2s-pgroup-head{display:flex;align-items:center;gap:8px;margin:0 2px 8px;font-size:13px;font-weight:600}
1986
+ .n2s-pgroup-head .n2s-faint{font-weight:400;font-size:12px}
1987
+ .n2s-wizard{padding:20px 24px 18px}
1988
+ .n2s-wiz-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
1989
+ .n2s-wiz-steps{display:flex;gap:6px;list-style:none;margin:0 0 16px;padding:0;counter-reset:wiz}
1990
+ .n2s-wiz-steps li{counter-increment:wiz;display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--n2s-faint)}
1991
+ .n2s-wiz-steps li::before{content:counter(wiz);display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1px solid var(--n2s-line-2);font-size:11px}
1992
+ .n2s-wiz-steps li+li::after{content:none}
1993
+ .n2s-wiz-steps li+li{padding-left:6px;border-left:.5px solid var(--n2s-line-2)}
1994
+ .n2s-wiz-steps li.current{color:var(--dsw-alias-label-primary);font-weight:500}
1995
+ .n2s-wiz-steps li.current::before{border-color:var(--n2s-accent);color:var(--n2s-accent)}
1996
+ .n2s-wiz-steps li.done::before{content:'✓';background:var(--n2s-ok);border-color:transparent;color:#fff}
1997
+ .n2s-ptiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin-top:8px}
1998
+ .n2s-ptile{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:12px;border-radius:var(--n2s-radius);border:.5px solid var(--n2s-line-2);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;transition:border-color .12s,background .12s}
1999
+ .n2s-ptile:hover{background:var(--n2s-hover)}
2000
+ .n2s-ptile[aria-checked=true]{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 6%,transparent);box-shadow:0 0 0 1px var(--n2s-accent)}
2001
+ .n2s-ptile:focus-visible,.n2s-kindcard:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:2px}
2002
+ .n2s-ptile-name{font-size:13.5px;font-weight:600}
2003
+ .n2s-kindcards{display:grid;gap:8px;margin-top:8px}
2004
+ .n2s-kindcard{display:grid;gap:4px;padding:12px 14px;border-radius:var(--n2s-radius);border:.5px solid var(--n2s-line-2);background:var(--dsw-alias-bg-base);color:inherit;font:inherit;text-align:left;cursor:pointer;transition:border-color .12s,background .12s}
2005
+ .n2s-kindcard:hover{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 4%,transparent)}
2006
+ .n2s-kindcard-title{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
2007
+ .n2s-kindcard-title svg{color:var(--n2s-faint)}
2008
+ .n2s-kindcard-desc{font-size:12.5px;color:var(--n2s-muted);line-height:1.55}
2009
+ .n2s-setup{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius-sm);padding:10px 12px;background:var(--n2s-fill)}
2010
+ .n2s-setup>summary{cursor:pointer;font-size:13px;font-weight:500}
2011
+ .n2s-setup ol{margin:8px 0 2px;padding-left:20px;font-size:12.5px;line-height:1.75;color:var(--n2s-muted)}
2012
+ .n2s-botbox{margin:16px 0 0;padding:12px 14px 14px;border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius)}
2013
+ .n2s-botbox legend{padding:0 6px;font-size:13px;font-weight:600}
2014
+ .n2s-botbox.focus{border-color:var(--n2s-accent)}
2015
+ .n2s-wiz-foot{margin-top:16px;padding-top:14px;border-top:.5px solid var(--n2s-line);justify-content:flex-end}
2016
+ .n2s-idrows{display:grid;gap:8px}
2017
+ .n2s-idrow-main{display:flex;align-items:center;gap:8px}
2018
+ .n2s-idrow-main .n2s-input{flex:1}
2019
+ .n2s-idrow-help,.n2s-idrow .n2s-fielderr{margin:4px 0 0 38px}
2020
+ .n2s-idchips{display:inline-flex;flex-wrap:wrap;gap:4px;min-width:0}
2021
+ .n2s-idchip{display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px 0 3px;border-radius:999px;background:var(--n2s-fill);font-size:11.5px;max-width:240px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
2022
+ .n2s-botrow{border-bottom:.5px solid var(--n2s-line)}
2023
+ .n2s-botrow:last-child{border-bottom:0}
2024
+ .n2s-botrow .n2s-list2-row{border-bottom:0}
2025
+ .n2s-collapse>button .n2s-badge{margin-left:2px}
2026
+ @media (prefers-reduced-motion:reduce){.n2s-statusdot.conn::before{animation:none}}
1739
2027
  @keyframes n2s-spin{to{transform:rotate(360deg)}}
1740
2028
  @keyframes n2s-shimmer{to{background-position:-200% 0}}
1741
2029
  `
@@ -1833,7 +2121,11 @@ window.__ModuleLoader__.load({
1833
2121
 
1834
2122
  /** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
1835
2123
  let uiLang = 'zh'
1836
- const setUiLang = (snapshot) => { uiLang = /^zh/i.test(String(snapshot?.locale ?? snapshot ?? 'zh')) ? 'zh' : 'en' }
2124
+ // LocaleSnapshot is { active, locales, revision } (dsh-client-locale); older hosts passed a string.
2125
+ const setUiLang = (snapshot) => {
2126
+ const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
2127
+ uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
2128
+ }
1837
2129
 
1838
2130
  function formatNumber(v) {
1839
2131
  const n = Number(v)
@@ -2768,7 +3060,7 @@ window.__ModuleLoader__.load({
2768
3060
  h('div', { className: 'desc' }, t(`ob.${x.key}.desc`))),
2769
3061
  current === x && h('button', { type: 'button', className: 'n2s-btn small primary', disabled: busy || (x.key !== 'connect' && !ds), onClick: x.action }, busy && x.key === 'analyze' ? h('span', { className: 'n2s-spin' }) : null, t(`ob.${x.key}.cta`))))),
2770
3062
  !v.hasChannel && h('div', { className: 'n2s-onboard-extra' },
2771
- h(TypeTile, { type: 'lark_app' }),
3063
+ h(PlatformMark, { t, platform: 'lark' }),
2772
3064
  h('div', { className: 'n2s-grow' }, h('div', { style: { fontWeight: 500, fontSize: 13.5 } }, t('ob.push')), h('div', { className: 'n2s-muted n2s-small' }, t('ob.push.desc'))),
2773
3065
  h('button', { type: 'button', className: 'n2s-btn small', onClick: () => onNavigate({ tab: 'admin', section: 'push' }) }, t('ob.push.cta'))))
2774
3066
  }
@@ -3138,10 +3430,8 @@ window.__ModuleLoader__.load({
3138
3430
  const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7]
3139
3431
 
3140
3432
  function ReportChannelField({ t, value, onChange }) {
3141
- const channels = useChannels()
3142
- return h('label', { className: 'n2s-field full' }, h('span', null, t('rep.push')),
3143
- h('select', { className: 'n2s-select', value, onChange: e => onChange(e.target.value) },
3144
- h('option', { value: '' }, t('rep.pushNone')), channels.map(c => h('option', { key: c.id, value: c.id }, `${c.name} · ${t(`im.type.${c.type}`)}`))))
3433
+ return h('div', { className: 'n2s-field full' }, h('label', { htmlFor: 'n2s-rep-channel', className: 'n2s-flabel' }, t('rep.push')),
3434
+ h(ChannelPicker, { t, id: 'n2s-rep-channel', value, onChange, noneLabel: t('rep.pushNone') }))
3145
3435
  }
3146
3436
 
3147
3437
  function ReportForm({ t, datasources, initial, defaultDatasourceId, onDone, onCancel }) {
@@ -3377,9 +3667,75 @@ window.__ModuleLoader__.load({
3377
3667
  }
3378
3668
  }
3379
3669
 
3670
+ // ------------------------------------------- channels: catalog & shapes
3671
+ // The host may be older (flat Lark-only channels, no channelCatalog /
3672
+ // botStatus RPCs) or newer (docs/CHANNELS.md). Everything below normalizes
3673
+ // both so the pages only deal with { platform, type, config, secretsSet,
3674
+ // capabilities, bot }.
3675
+
3676
+ const KNOWN_PLATFORMS = ['lark', 'telegram', 'wecom', 'dingtalk']
3677
+ const PLATFORM_COLORS = { lark: '#3370ff', telegram: '#2aabee', wecom: '#0ba5a0', dingtalk: '#1a7cff' }
3678
+ const ID_PLACEHOLDER = {
3679
+ lark: { person: 'ou_xxx', chat: 'oc_xxx' }, telegram: { person: '123456789', chat: '-1001234567890' },
3680
+ wecom: { person: 'zhangsan', chat: 'wrxxxx' }, dingtalk: { person: 'manager1234', chat: 'cidxxxx==' },
3681
+ }
3682
+ const LEGACY_CAPS = {
3683
+ lark_webhook: { push: true, markdown: 'full', charts: 'native', tables: 'native', buttons: false, bot: false, listTargets: false, mentionAll: true },
3684
+ lark_app: { push: true, markdown: 'full', charts: 'native', chartImages: true, tables: 'native', buttons: true, bot: true, listTargets: true, mentionAll: true },
3685
+ }
3686
+ /** Localized value of a { zh, en } object (or a plain string). */
3687
+ const L = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? (v[uiLang] ?? v.zh ?? v.en ?? '') : (v ?? ''))
3688
+ const isUnknownMethod = (e) => e?.code === 'nl2sql/unknown-method' || /unknown method/i.test(e?.message ?? '')
3689
+ const platformOfType = (type) => (/^lark_/.test(type ?? '') ? 'lark' : (String(type ?? '').split('_')[0] || 'lark'))
3690
+ const isSecretField = (f) => Boolean(f.secret || f.type === 'secret')
3691
+ /** Translation or '' when the key is missing (hosts return the key or a !!marker). */
3692
+ const tOpt = (t, k) => { const v = t(k); return !v || v === k || v.startsWith('!!') ? '' : v }
3693
+
3694
+ /** Built-in catalog used when the host has no `channelCatalog` RPC: today's Lark form. */
3695
+ function legacyCatalog(t) {
3696
+ const tx = (k) => ({ zh: t(k), en: t(k) })
3697
+ return [{
3698
+ platform: 'lark', name: tx('ch.p.lark'), builtin: true,
3699
+ kinds: [
3700
+ {
3701
+ id: 'lark_webhook', label: tx('im.type.lark_webhook'), description: tx('ch.k.lark_webhook'),
3702
+ fields: [
3703
+ { key: 'webhookUrl', label: tx('im.webhookUrl'), type: 'secret', secret: true, required: true, full: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', pattern: '^https://open\\.(feishu\\.cn|larksuite\\.com)/open-apis/bot/v2/hook/[\\w-]+$' },
3704
+ { key: 'signSecret', label: tx('im.signSecret'), type: 'secret', secret: true, help: tx('ch.f.signSecret') },
3705
+ ],
3706
+ capabilities: LEGACY_CAPS.lark_webhook,
3707
+ setupSteps: { zh: [t('im.help.lark_webhook')], en: [t('im.help.lark_webhook')] },
3708
+ },
3709
+ {
3710
+ id: 'lark_app', label: tx('im.type.lark_app'), description: tx('ch.k.lark_app'),
3711
+ fields: [
3712
+ { key: 'domain', label: tx('im.domain'), type: 'select', options: ['feishu', 'lark'].map(v => ({ value: v, label: tx(`im.domain.${v}`) })) },
3713
+ { key: 'appId', label: tx('im.appId'), type: 'text', required: true, placeholder: 'cli_xxx' },
3714
+ { key: 'appSecret', label: tx('im.appSecret'), type: 'secret', secret: true, required: true },
3715
+ { key: 'receiveIdType', label: tx('im.receiveIdType'), type: 'select', options: ['chat_id', 'email', 'open_id', 'user_id', 'union_id'].map(v => ({ value: v, label: tx(`im.rid.${v}`) })) },
3716
+ { key: 'receiveId', label: tx('im.receiveId'), type: 'text', required: true, placeholder: 'oc_xxx', target: true, help: tx('ch.f.receiveId') },
3717
+ ],
3718
+ capabilities: LEGACY_CAPS.lark_app,
3719
+ setupSteps: { zh: ['im.help.lark_app', 'bot.step3', 'bot.step4', 'bot.step5', 'bot.step7', 'bot.step6'].map(t), en: ['im.help.lark_app', 'bot.step3', 'bot.step4', 'bot.step5', 'bot.step7', 'bot.step6'].map(t) },
3720
+ },
3721
+ ],
3722
+ }]
3723
+ }
3724
+
3725
+ function normalizeChannel(c) {
3726
+ if (!c || typeof c !== 'object') return c
3727
+ const legacy = !c.config
3728
+ const config = c.config ?? Object.fromEntries(['domain', 'appId', 'receiveIdType', 'receiveId'].filter(k => c[k] !== undefined && c[k] !== null).map(k => [k, c[k]]))
3729
+ const secretsSet = c.secretsSet ?? (c.type === 'lark_webhook' ? { webhookUrl: Boolean(c.hasSecret), signSecret: Boolean(c.hasSign) } : c.type === 'lark_app' ? { appSecret: Boolean(c.hasSecret) } : {})
3730
+ return { ...c, platform: c.platform ?? platformOfType(c.type), config, secretsSet, capabilities: c.capabilities ?? LEGACY_CAPS[c.type] ?? { push: true, charts: 'none' }, legacy }
3731
+ }
3732
+
3380
3733
  const channelStore = createStore({ list: null })
3381
3734
  async function refreshChannels() {
3382
- try { channelStore.set({ list: await call('listChannels') }) } catch { channelStore.set({ list: [] }) }
3735
+ try {
3736
+ const list = await call('listChannels')
3737
+ channelStore.set({ list: (Array.isArray(list) ? list : []).map(normalizeChannel) })
3738
+ } catch { channelStore.set({ list: [] }) }
3383
3739
  }
3384
3740
  function useChannels() {
3385
3741
  const get = () => channelStore.get().list
@@ -3387,6 +3743,163 @@ window.__ModuleLoader__.load({
3387
3743
  useEffect(() => { if (list === null) refreshChannels() }, [])
3388
3744
  return list ?? []
3389
3745
  }
3746
+
3747
+ // remote: null = not loaded yet, true = host catalog, false = built-in Lark catalog.
3748
+ const catalogStore = createStore({ list: [], remote: null })
3749
+ async function loadCatalog() {
3750
+ try {
3751
+ const list = await call('channelCatalog')
3752
+ catalogStore.set(Array.isArray(list) && list.length ? { list, remote: true } : { list: [], remote: false })
3753
+ } catch { catalogStore.set({ list: [], remote: false }) }
3754
+ }
3755
+ function sortPlatforms(list) {
3756
+ const rank = (p) => { const i = KNOWN_PLATFORMS.indexOf(p); return i < 0 ? 99 : i }
3757
+ return [...list].sort((a, b) => rank(a.platform) - rank(b.platform))
3758
+ }
3759
+ function useCatalog(t) {
3760
+ const get = () => catalogStore.get()
3761
+ const st = useSyncExternalStore(catalogStore.subscribe, get, get)
3762
+ useEffect(() => { if (st.remote === null) loadCatalog() }, [])
3763
+ const catalog = useMemo(() => (st.remote ? sortPlatforms(st.list) : legacyCatalog(t)), [st, t, uiLang])
3764
+ return { catalog, remote: st.remote === true, ready: st.remote !== null }
3765
+ }
3766
+ function findKind(catalog, type) {
3767
+ for (const p of catalog) { const kind = (p.kinds ?? []).find(k => k.id === type); if (kind) return { platform: p, kind } }
3768
+ return null
3769
+ }
3770
+ function platformName(t, platform, catalog) {
3771
+ if (KNOWN_PLATFORMS.includes(platform)) return t(`ch.p.${platform}`)
3772
+ return L((catalog ?? []).find(p => p.platform === platform)?.name) || String(platform ?? '')
3773
+ }
3774
+ function kindLabel(t, type, catalog) {
3775
+ const k = findKind(catalog ?? [], type)?.kind
3776
+ if (k) return L(k.label)
3777
+ return type === 'lark_webhook' || type === 'lark_app' ? t(`im.type.${type}`) : String(type ?? '')
3778
+ }
3779
+ function channelOptionLabel(t, c, catalog) {
3780
+ return `${c.name} · ${platformName(t, c.platform, catalog)} · ${kindLabel(t, c.type, catalog)}`
3781
+ }
3782
+ /** Chart modes a channel can send: native card charts, PNG images, tables. */
3783
+ function chartModesFor(caps, type) {
3784
+ const cm = caps?.charts ?? 'native'
3785
+ const modes = cm === 'native' ? ['native'] : cm === 'image' ? ['image'] : []
3786
+ if (cm === 'native' && (caps?.chartImages || type === 'lark_app')) modes.push('image')
3787
+ modes.push('table')
3788
+ return modes
3789
+ }
3790
+ /** The form field that holds the delivery target (chat id …), used by the target picker. */
3791
+ function targetFieldOf(kind) {
3792
+ const fields = kind?.fields ?? []
3793
+ return fields.find(f => f.target) ?? fields.find(f => !isSecretField(f) && f.type !== 'select' && /chat|target|receive|conversation/i.test(f.key))
3794
+ }
3795
+ /** One-line, secret-free description of where a channel delivers. */
3796
+ function targetSummary(t, c, kind) {
3797
+ const cfg = c.config ?? {}
3798
+ if (cfg.receiveId) return `${cfg.receiveIdType ? `${cfg.receiveIdType}:` : ''}${cfg.receiveId}`
3799
+ const tf = targetFieldOf(kind)
3800
+ if (tf && cfg[tf.key]) return String(cfg[tf.key])
3801
+ for (const k of ['chatId', 'chat_id', 'conversationId', 'openConversationId', 'target', 'webhookHost', 'host']) if (cfg[k]) return String(cfg[k])
3802
+ if (Object.keys(c.secretsSet ?? {}).some(k => /webhook/i.test(k) && c.secretsSet[k])) return t('im.webhookHidden')
3803
+ const plain = (kind?.fields ?? []).find(f => !isSecretField(f) && f.type !== 'select' && cfg[f.key])
3804
+ return plain ? String(cfg[plain.key]) : ''
3805
+ }
3806
+
3807
+ // ------------------------------------------------------------- bots
3808
+ // New hosts: botStatus / saveBot (one bot per bot-capable channel).
3809
+ // Old hosts: one Lark bot (larkBotStatus / saveLarkBot) bound to a channel.
3810
+
3811
+ const botStore = createStore({ list: null, legacy: false, legacyConf: null, error: null })
3812
+ function legacyBotEntry(v) {
3813
+ return { channelId: v.channelId ?? null, platform: 'lark', enabled: Boolean(v.enabled), allowP2p: v.allowP2p !== false, sessionScope: v.sessionScope ?? 'chat', status: v.status ?? { state: 'idle' }, info: v.bot ?? null, conversations: v.conversations ?? [] }
3814
+ }
3815
+ async function refreshBots() {
3816
+ try {
3817
+ const list = await call('botStatus')
3818
+ if (!Array.isArray(list)) throw Object.assign(new Error('unknown method botStatus'), { code: 'nl2sql/unknown-method' })
3819
+ botStore.set({ list, legacy: false, error: null })
3820
+ } catch (e) {
3821
+ if (!isUnknownMethod(e)) { botStore.set({ list: botStore.get().list ?? [], error: e.message }); return }
3822
+ try {
3823
+ const v = await call('larkBotStatus')
3824
+ botStore.set({ list: v?.channelId ? [legacyBotEntry(v)] : [], legacy: true, legacyConf: v ?? null, error: null })
3825
+ } catch { botStore.set({ list: [], legacy: true, legacyConf: null }) }
3826
+ }
3827
+ }
3828
+ function useBots() {
3829
+ const get = () => botStore.get()
3830
+ const st = useSyncExternalStore(botStore.subscribe, get, get)
3831
+ useEffect(() => { if (st.list === null) refreshBots() }, [])
3832
+ return st
3833
+ }
3834
+ /** Bot settings + status of one channel, merged from listChannels and botStatus. */
3835
+ function botOf(c, bots) {
3836
+ const base = { enabled: false, allowP2p: true, sessionScope: 'chat', status: { state: 'idle' }, conversations: [] }
3837
+ const e = (bots?.list ?? []).find(b => b.channelId === c.id)
3838
+ if (bots?.legacy) {
3839
+ const conf = bots.legacyConf ?? {}
3840
+ return e ? { ...base, ...e } : { ...base, allowP2p: conf.allowP2p !== false, sessionScope: conf.sessionScope ?? 'chat' }
3841
+ }
3842
+ const merged = { ...base, ...(c.bot ?? {}), ...(e ?? {}) }
3843
+ return { ...merged, status: e?.status ?? c.bot?.status ?? { state: merged.enabled ? 'connecting' : 'idle' } }
3844
+ }
3845
+ /** 'off' | 'connecting' | 'connected' | 'error' | 'stopped' */
3846
+ function botStateOf(b) {
3847
+ if (!b?.enabled) return 'off'
3848
+ const s = b.status?.state ?? 'connecting'
3849
+ return s === 'idle' ? 'connecting' : s
3850
+ }
3851
+ const BOT_DOT = { connected: 'ok', error: 'err', connecting: 'conn', stopped: 'warn', off: '' }
3852
+ function BotDot({ t, bot, prefix }) {
3853
+ const s = botStateOf(bot)
3854
+ return h('span', { className: `n2s-statusdot ${BOT_DOT[s] ?? ''}`, title: bot?.status?.error ?? undefined }, prefix ? `${prefix} · ` : '', t(`ch.bot.state.${s}`))
3855
+ }
3856
+ async function saveBotFor(channelId, patch) {
3857
+ const st = botStore.get()
3858
+ if (st.legacy) {
3859
+ const conf = st.legacyConf ?? {}
3860
+ const owner = conf.channelId ?? null
3861
+ // The single legacy bot belongs to another channel: only an explicit enable moves it.
3862
+ if (conf.enabled && owner && owner !== channelId && patch.enabled !== true) return
3863
+ if (patch.enabled === false && owner !== channelId) return
3864
+ await call('saveLarkBot', { channelId, ...patch })
3865
+ } else {
3866
+ await call('saveBot', { channelId, ...patch })
3867
+ }
3868
+ await refreshBots()
3869
+ }
3870
+
3871
+ /** Platform tile: brand-tinted letter mark (original, not a logo). */
3872
+ function PlatformMark({ t, platform, size = 36 }) {
3873
+ const color = PLATFORM_COLORS[platform] ?? '#64748b'
3874
+ const txt = KNOWN_PLATFORMS.includes(platform) ? t(`ch.p.${platform}.mark`) : String(platform ?? '?').slice(0, 2).toUpperCase()
3875
+ return h('span', {
3876
+ className: 'n2s-tile n2s-pmark', 'aria-hidden': true,
3877
+ style: { width: size, height: size, borderRadius: Math.round(size * 0.28), fontSize: Math.max(9, Math.round(size * (txt.length > 1 ? 0.3 : 0.4))), color, background: `color-mix(in srgb, ${color} 13%, transparent)` },
3878
+ }, txt)
3879
+ }
3880
+
3881
+ /** Capability chips: charts / buttons / bot. */
3882
+ function CapabilityChips({ t, caps, type }) {
3883
+ if (!caps) return null
3884
+ const cm = caps.charts ?? 'native'
3885
+ const chart = cm === 'none' ? 'none' : chartModesFor(caps, type).includes('native') ? 'native' : 'image'
3886
+ return h('div', { className: 'n2s-caps' },
3887
+ h('span', { className: `n2s-cap${chart === 'none' ? ' muted' : ''}` }, Icon.chart(11), t(`ch.cap.${chart}`)),
3888
+ caps.buttons && h('span', { className: 'n2s-cap' }, t('ch.cap.buttons')),
3889
+ caps.bot && h('span', { className: 'n2s-cap bot' }, t('ch.cap.bot')))
3890
+ }
3891
+
3892
+ /** Native channel <select> with the platform mark of the selected channel. */
3893
+ function ChannelPicker({ t, value, onChange, noneLabel, id, label }) {
3894
+ const channels = useChannels()
3895
+ const { catalog } = useCatalog(t)
3896
+ const cur = channels.find(c => c.id === value)
3897
+ return h('div', { className: 'n2s-chpick' },
3898
+ cur ? h(PlatformMark, { t, platform: cur.platform, size: 24 }) : h('span', { className: 'n2s-chpick-none', 'aria-hidden': true }, Icon.send(12)),
3899
+ h('select', { id, className: 'n2s-select', 'aria-label': id ? undefined : label, value: value ?? '', onChange: e => onChange(e.target.value) },
3900
+ noneLabel !== undefined && h('option', { value: '' }, noneLabel),
3901
+ channels.map(c => h('option', { key: c.id, value: c.id }, channelOptionLabel(t, c, catalog)))))
3902
+ }
3390
3903
  /** Minimal Markdown → React preview of what the Lark card shows (escaped; <font color> kept). */
3391
3904
  function MdPreview({ text }) {
3392
3905
  const blocks = []
@@ -3506,7 +4019,7 @@ window.__ModuleLoader__.load({
3506
4019
  /** Modal dialog sending a title, text and results to an IM channel. */
3507
4020
  function SendDialog({ t, initial, onClose }) {
3508
4021
  const channels = useChannels()
3509
- const [form, setForm] = useState(() => ({ channelId: '', title: initial.title ?? '', text: initial.text ?? '', includeSql: false, chartMode: '' }))
4022
+ const [form, setForm] = useState(() => ({ channelId: '', title: initial.title ?? '', text: initial.text ?? '', includeSql: false, chartMode: '', attachFull: true }))
3510
4023
  const [resultIds, setResultIds] = useState(initial.resultIds ?? [])
3511
4024
  const [loading, setLoading] = useState(Boolean(initial.message))
3512
4025
  useEffect(() => {
@@ -3534,9 +4047,9 @@ window.__ModuleLoader__.load({
3534
4047
  setState({ busy: true })
3535
4048
  try {
3536
4049
  const res = initial.dashboard
3537
- ? await call('sendDashboard', { channelId: form.channelId, title: form.title, ...(form.chartMode ? { chartMode: form.chartMode } : {}) })
3538
- : await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
3539
- setState({ ok: fmt(t, 'im.sent', { channel: res.channel, images: res.images }) })
4050
+ ? await call('sendDashboard', { channelId: form.channelId, title: form.title, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}) })
4051
+ : await call('sendToChannel', { channelId: form.channelId, title: form.title, text: form.text, resultIds, includeSql: form.includeSql, attachFull: form.attachFull, ...(form.chartMode ? { chartMode: form.chartMode } : {}), sessionId: initial.message?.sessionId })
4052
+ setState({ ok: `${fmt(t, 'im.sent', { channel: res.channel, images: res.images })}${res.files ? ` · ${t('im.sentFile')}` : ''}`, warn: res.warning })
3540
4053
  refreshChannels()
3541
4054
  setTimeout(onClose, 1200)
3542
4055
  } catch (e) {
@@ -3550,128 +4063,440 @@ window.__ModuleLoader__.load({
3550
4063
  channels.length === 0
3551
4064
  ? h('div', { className: 'n2s-empty' }, h('div', { className: 'n2s-muted' }, t('im.noChannel')))
3552
4065
  : h('div', { style: { display: 'grid', gap: 10 } },
3553
- h('label', { className: 'n2s-field' }, h('span', null, t('im.channel')),
3554
- h('select', { className: 'n2s-select', value: form.channelId, onChange: e => setForm(f => ({ ...f, channelId: e.target.value })) }, channels.map(c => h('option', { key: c.id, value: c.id }, `${c.name} · ${t(`im.type.${c.type}`)}`)))),
4066
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-send-channel', className: 'n2s-flabel' }, t('im.channel')),
4067
+ h(ChannelPicker, { t, id: 'n2s-send-channel', value: form.channelId, onChange: (v) => setForm(f => ({ ...f, channelId: v, chartMode: '' })) })),
3555
4068
  h('label', { className: 'n2s-field' }, h('span', null, t('im.msgTitle')), h('input', { className: 'n2s-input', value: form.title, onChange: e => setForm(f => ({ ...f, title: e.target.value })) })),
3556
4069
  !initial.dashboard && h('div', { className: 'n2s-field' }, h('span', null, t('im.text')),
3557
4070
  loading ? h('span', { className: 'n2s-spin n2s-muted' }) : h(MdEditor, { t, value: form.text, onChange: (v) => setForm(f => ({ ...f, text: v })) })),
3558
4071
  h('div', { className: 'n2s-muted n2s-small' }, initial.dashboard ? t('im.dashboardHint') : fmt(t, 'im.attach', { count: resultIds.length })),
3559
4072
  (resultIds.length > 0 || initial.dashboard) && h('div', { className: 'n2s-row' },
3560
4073
  h('span', { className: 'n2s-small' }, t('im.chartMode')),
3561
- h('div', { className: 'n2s-seg' }, ['', 'native', ...(channel?.type === 'lark_app' ? ['image'] : []), 'table'].map(v => h('button', { key: v || 'def', type: 'button', 'aria-pressed': form.chartMode === v, onClick: () => setForm(f => ({ ...f, chartMode: v })) }, t(v ? `im.cm.${v}` : 'im.cm.default'))))),
3562
- !initial.dashboard && resultIds.length > 0 && h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.includeSql, onChange: e => setForm(f => ({ ...f, includeSql: e.target.checked })) }), t('im.includeSql'))),
4074
+ channel?.capabilities?.charts === 'none' && h('span', { className: 'n2s-faint n2s-small' }, t('ch.tablesOnly')),
4075
+ h('div', { className: 'n2s-seg', role: 'group', 'aria-label': t('im.chartMode') }, ['', ...chartModesFor(channel?.capabilities, channel?.type)].map(v => h('button', { key: v || 'def', type: 'button', 'aria-pressed': form.chartMode === v, onClick: () => setForm(f => ({ ...f, chartMode: v })) }, t(v ? `im.cm.${v}` : 'im.cm.default'))))),
4076
+ !initial.dashboard && resultIds.length > 0 && h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.includeSql, onChange: e => setForm(f => ({ ...f, includeSql: e.target.checked })) }), t('im.includeSql')),
4077
+ (resultIds.length > 0 || initial.dashboard) && h('div', { className: 'n2s-field' },
4078
+ channel?.capabilities?.files
4079
+ ? h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.attachFull, onChange: e => setForm(f => ({ ...f, attachFull: e.target.checked })) }), fmt(t, 'im.attachFull', { n: channel.capabilities.tableRows ?? 10 }))
4080
+ : h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'im.rowsLimit', { n: channel?.capabilities?.tableRows ?? 10 })))),
3563
4081
  state?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, state.error),
3564
4082
  state?.ok && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, state.ok),
4083
+ state?.warn && h('div', { className: 'n2s-notice warn', style: { marginTop: 8 } }, state.warn),
3565
4084
  h('div', { className: 'n2s-row', style: { marginTop: 14, justifyContent: 'flex-end' } },
3566
4085
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
3567
4086
  h('button', { type: 'button', className: 'n2s-btn primary', disabled: !form.channelId || !form.title.trim() || state?.busy || loading, onClick: send }, state?.busy ? h('span', { className: 'n2s-spin' }) : null, t('im.send'))))), document.body)
3568
4087
  }
3569
- function ChannelForm({ t, initial, onDone, onCancel }) {
3570
- const [form, setForm] = useState(() => ({ type: 'lark_webhook', name: '', description: '', appId: '', receiveIdType: 'chat_id', receiveId: '', domain: 'feishu', isDefault: false, ...(initial ?? {}), webhookUrl: '', signSecret: '', appSecret: '' }))
3571
- const [error, setError] = useState(null)
3572
- const [busy, setBusy] = useState(false)
3573
- const [chats, setChats] = useState(null)
3574
- const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
3575
- const field = (k, label, props = {}) => h('label', { className: `n2s-field ${props.full ? 'full' : ''}` }, h('span', null, t(label)),
3576
- h('input', { className: 'n2s-input', type: props.type ?? 'text', value: form[k] ?? '', placeholder: props.placeholder, autoComplete: 'off', spellCheck: false, onChange: set(k) }))
3577
- const save = async (thenTest) => {
3578
- setBusy(true); setError(null)
4088
+ // ------------------------------------------------------------ channels page
4089
+ // 「渠道」: channel list grouped by platform, a two-step "add channel" dialog
4090
+ // (platform → kind, then a form rendered from the kind's `fields`), bot
4091
+ // settings per bot-capable channel and the delivery log.
4092
+
4093
+ /** Request from elsewhere (bot overview) to open a channel's editor. */
4094
+ const channelNav = createStore({ edit: null })
4095
+ function openChannelEditor(id, bot) {
4096
+ channelNav.set({ edit: { id, bot: Boolean(bot) } })
4097
+ fbCtx.navigate?.({ tab: 'admin', section: 'push', sub: null })
4098
+ }
4099
+
4100
+ const fieldVisible = (f, config) => {
4101
+ if (!f.showIf) return true
4102
+ const v = String(config?.[f.showIf.key] ?? '')
4103
+ return Array.isArray(f.showIf.equals) ? f.showIf.equals.map(String).includes(v) : v === String(f.showIf.equals)
4104
+ }
4105
+
4106
+ /** Move focus between the [data-nav] items of a container with the arrow keys. */
4107
+ function arrowNav(e, select) {
4108
+ if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
4109
+ const items = [...e.currentTarget.querySelectorAll('[data-nav]:not([disabled])')]
4110
+ if (!items.length) return
4111
+ const i = items.indexOf(document.activeElement)
4112
+ const next = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : (i + (e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
4113
+ e.preventDefault()
4114
+ items[next].focus()
4115
+ if (select) items[next].click()
4116
+ }
4117
+
4118
+ /** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
4119
+ function Modal({ label, labelledBy, onClose, width, children, className }) {
4120
+ const box = useRef(null)
4121
+ useEffect(() => {
4122
+ const prev = document.activeElement
4123
+ setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
4124
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) { e.stopPropagation(); onClose() } }
4125
+ document.addEventListener('keydown', onKey)
4126
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
4127
+ }, [])
4128
+ return ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) onClose() } },
4129
+ h('div', { ref: box, className: `n2s-modal ${className ?? ''}`, role: 'dialog', 'aria-modal': true, 'aria-label': labelledBy ? undefined : label, 'aria-labelledby': labelledBy, style: width ? { width: `min(${width}px, 100%)` } : undefined }, children)), document.body)
4130
+ }
4131
+
4132
+ // ---------------------------------------------------------------- step 1
4133
+
4134
+ function KindPicker({ t, catalog, platform, onPlatform, onPick }) {
4135
+ const p = catalog.find(x => x.platform === platform) ?? catalog[0]
4136
+ return h('div', { className: 'n2s-wiz-pick' },
4137
+ h('div', { className: 'n2s-flabel', id: 'n2s-wiz-plabel' }, t('ch.pickPlatform')),
4138
+ h('div', { className: 'n2s-ptiles', role: 'radiogroup', 'aria-labelledby': 'n2s-wiz-plabel', onKeyDown: (e) => arrowNav(e, true) },
4139
+ catalog.map(x => h('button', {
4140
+ key: x.platform, type: 'button', role: 'radio', 'data-nav': true, 'aria-checked': x.platform === p?.platform, tabIndex: x.platform === p?.platform ? 0 : -1,
4141
+ 'data-autofocus': x.platform === p?.platform ? true : undefined, className: 'n2s-ptile', onClick: () => onPlatform(x.platform),
4142
+ }, h(PlatformMark, { t, platform: x.platform, size: 32 }), h('span', { className: 'n2s-ptile-name' }, platformName(t, x.platform, catalog)),
4143
+ h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
4144
+ p && h(React.Fragment, null,
4145
+ h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
4146
+ h('div', { className: 'n2s-kindcards', role: 'list', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
4147
+ (p.kinds ?? []).map(k => h('button', { key: k.id, type: 'button', role: 'listitem', 'data-nav': true, className: 'n2s-kindcard', onClick: () => onPick(p.platform, k.id) },
4148
+ h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
4149
+ k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
4150
+ h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
4151
+ h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
4152
+ k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl'))))))))
4153
+ }
4154
+
4155
+ // ---------------------------------------------------------------- step 2
4156
+
4157
+ function FieldInput({ t, f, value, onChange, error, keep, idBase }) {
4158
+ const id = `${idBase}-${CSS_ID(f.key)}`
4159
+ const err = error ? `${id}-err` : undefined
4160
+ const help = L(f.help)
4161
+ const common = { id, 'aria-invalid': error ? true : undefined, 'aria-describedby': [err, help ? `${id}-help` : null].filter(Boolean).join(' ') || undefined, 'aria-required': f.required ? true : undefined }
4162
+ const secret = isSecretField(f)
4163
+ const control = f.type === 'select'
4164
+ ? h('select', { ...common, className: 'n2s-select', value: value ?? '', onChange: e => onChange(e.target.value) },
4165
+ (value ?? '') === '' && !(f.options ?? []).some(o => o.value === '') && h('option', { value: '' }, '—'),
4166
+ (f.options ?? []).map(o => h('option', { key: o.value, value: o.value }, L(o.label) || o.value)))
4167
+ : h('input', {
4168
+ ...common, className: `n2s-input${secret ? '' : ' n2s-mono-soft'}${error ? ' invalid' : ''}`, type: secret ? 'password' : f.type === 'url' ? 'url' : 'text',
4169
+ value: value ?? '', autoComplete: secret ? 'new-password' : 'off', spellCheck: false,
4170
+ placeholder: secret && keep ? t('ch.secretKeep') : (L(f.placeholder) || (secret && !f.required ? t('im.optional') : '')),
4171
+ onChange: e => onChange(e.target.value),
4172
+ })
4173
+ return h('div', { className: `n2s-field${f.full || f.type === 'url' || (secret && /url|webhook/i.test(f.key)) ? ' full' : ''}` },
4174
+ h('label', { htmlFor: id, className: 'n2s-flabel' }, L(f.label) || f.key, f.required ? h('span', { className: 'n2s-req', 'aria-hidden': true }, ' *') : null,
4175
+ secret && keep && h('span', { className: 'n2s-badge ok', style: { marginLeft: 6 } }, t('ch.secretSet'))),
4176
+ control,
4177
+ error ? h('span', { id: err, className: 'n2s-fielderr', role: 'alert' }, error) : help ? h('span', { id: `${id}-help`, className: 'n2s-faint n2s-small' }, help) : null)
4178
+ }
4179
+
4180
+ function TargetPicker({ t, kind, remote, initial, config, secrets, onPick, current }) {
4181
+ const [st, setSt] = useState(null)
4182
+ const load = async () => {
4183
+ setSt({ busy: true })
3579
4184
  try {
3580
- const saved = await call('saveChannel', form)
3581
- await refreshChannels()
3582
- if (thenTest) await call('testChannel', { id: saved.id })
3583
- onDone(thenTest ? t('im.testOk') : null)
3584
- } catch (e) { setError(e.message) }
3585
- setBusy(false)
4185
+ const legacyChats = async () => ((await call('listChannelChats', { id: initial?.id, appId: config.appId, appSecret: secrets.appSecret || undefined, domain: config.domain })) ?? []).map(c => ({ id: c.chatId, name: c.name, type: 'group' }))
4186
+ let list
4187
+ if (!remote && kind.id === 'lark_app') list = await legacyChats()
4188
+ else {
4189
+ try {
4190
+ list = await call('listChannelTargets', { ...(initial?.id ? { id: initial.id } : {}), type: kind.id, config, secrets })
4191
+ } catch (e) {
4192
+ if (!isUnknownMethod(e) || kind.id !== 'lark_app') throw e
4193
+ list = await legacyChats()
4194
+ }
4195
+ }
4196
+ setSt({ list: Array.isArray(list) ? list : [] })
4197
+ } catch (e) { setSt({ error: e.message }) }
3586
4198
  }
3587
- const loadChats = async () => {
3588
- setError(null)
3589
- try { setChats(await call('listChannelChats', { id: form.id, appId: form.appId, appSecret: form.appSecret || undefined, domain: form.domain })) } catch (e) { setError(e.message) }
4199
+ return h('div', { className: 'n2s-field full' },
4200
+ h('div', { className: 'n2s-row' },
4201
+ h('button', { type: 'button', className: 'n2s-btn small', onClick: load, disabled: st?.busy }, st?.busy ? h('span', { className: 'n2s-spin' }) : Icon.search(13), t('im.pickChat')),
4202
+ h('span', { className: 'n2s-faint n2s-small' }, t('ch.pickHint'))),
4203
+ st?.error && h('div', { className: 'n2s-fielderr' }, st.error),
4204
+ st?.list && (st.list.length === 0
4205
+ ? h('div', { className: 'n2s-muted n2s-small' }, t('im.noChats'))
4206
+ : h('div', { role: 'group', 'aria-label': fmt(t, 'im.chats', { count: st.list.length }) },
4207
+ h('div', { className: 'n2s-faint n2s-small', style: { margin: '4px 0 6px' } }, fmt(t, 'im.chats', { count: st.list.length })),
4208
+ h('div', { className: 'n2s-chips' }, st.list.map(c => h('button', { key: c.id, type: 'button', className: 'n2s-chip', 'aria-pressed': current === c.id, title: c.id, onClick: () => onPick(c) }, c.name || c.id))))))
4209
+ }
4210
+
4211
+ function ChannelForm({ t, catalog, remote, platform, kind, initial, focusBot, onBack, onClose }) {
4212
+ const bots = useBots()
4213
+ const caps = kind.capabilities ?? {}
4214
+ const modes = chartModesFor(caps, kind.id)
4215
+ const editing = Boolean(initial?.id)
4216
+ const fields = kind.fields ?? []
4217
+ const initBot = useMemo(() => {
4218
+ const b = initial ? botOf(initial, bots) : null
4219
+ return { enabled: Boolean(b?.enabled), allowP2p: b ? b.allowP2p !== false : true, sessionScope: b?.sessionScope ?? 'chat' }
4220
+ }, [])
4221
+ const [form, setForm] = useState(() => {
4222
+ const config = { ...(initial?.config ?? {}) }
4223
+ for (const f of fields) if (!isSecretField(f) && config[f.key] === undefined) config[f.key] = f.default ?? (f.type === 'select' && f.required !== false ? (f.options?.[0]?.value ?? '') : '')
4224
+ return { name: initial?.name ?? '', description: initial?.description ?? '', isDefault: Boolean(initial?.isDefault), chartMode: modes.includes(initial?.chartMode) ? initial.chartMode : modes[0], config, secrets: {}, bot: initBot }
4225
+ })
4226
+ const pristine = useRef(JSON.stringify(form))
4227
+ const dirty = JSON.stringify(form) !== pristine.current
4228
+ const [errors, setErrors] = useState({})
4229
+ const [general, setGeneral] = useState(null)
4230
+ const [busy, setBusy] = useState(null)
4231
+ const botRef = useRef(null)
4232
+ const formRef = useRef(null)
4233
+ useEffect(() => { if (focusBot) setTimeout(() => { botRef.current?.scrollIntoView?.({ block: 'center' }); botRef.current?.querySelector('input')?.focus() }, 30) }, [])
4234
+ const visible = fields.filter(f => fieldVisible(f, form.config))
4235
+ const setConfig = (k, v) => { setForm(f => ({ ...f, config: { ...f.config, [k]: v } })); setErrors(e => ({ ...e, [k]: undefined })) }
4236
+ const setSecret = (k, v) => { setForm(f => ({ ...f, secrets: { ...f.secrets, [k]: v } })); setErrors(e => ({ ...e, [k]: undefined })) }
4237
+ const setBot = (patch) => setForm(f => ({ ...f, bot: { ...f.bot, ...patch } }))
4238
+ const tf = targetFieldOf(kind)
4239
+ const canPick = caps.listTargets && tf && fieldVisible(tf, form.config) && (form.config.receiveIdType === undefined || form.config.receiveIdType === 'chat_id' || !fields.some(f => f.key === 'receiveIdType'))
4240
+
4241
+ const validate = () => {
4242
+ const errs = {}
4243
+ if (!form.name.trim()) errs.name = t('ch.err.required')
4244
+ for (const f of visible) {
4245
+ const secret = isSecretField(f)
4246
+ const v = String((secret ? form.secrets[f.key] : form.config[f.key]) ?? '').trim()
4247
+ if (!v) { if (f.required && !(secret && initial?.secretsSet?.[f.key])) errs[f.key] = t('ch.err.required'); continue }
4248
+ if (f.pattern) { try { if (!new RegExp(f.pattern).test(v)) errs[f.key] = t('ch.err.pattern') } catch {} }
4249
+ if (f.type === 'url' && !/^https?:\/\//i.test(v)) errs[f.key] = t('ch.err.url')
4250
+ }
4251
+ return errs
3590
4252
  }
3591
- return h('div', { className: 'n2s-card' },
3592
- h('div', { className: 'n2s-field', style: { marginBottom: 12 } }, h('span', null, t('im.kind')),
3593
- h('div', { className: 'n2s-seg' }, ['lark_webhook', 'lark_app'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.type === v, disabled: Boolean(initial?.id) && initial.type !== v, onClick: () => setForm(f => ({ ...f, type: v })) }, t(`im.type.${v}`))))),
3594
- h('div', { className: 'n2s-notice', style: { marginBottom: 12 } }, t(`im.help.${form.type}`)),
3595
- h('div', { className: 'n2s-form' },
3596
- field('name', 'im.name', { placeholder: t('ph.channelName') }),
3597
- field('description', 'im.description'),
3598
- form.type === 'lark_webhook' && field('webhookUrl', 'im.webhookUrl', { full: true, type: 'password', placeholder: initial?.hasSecret ? t('ds.passwordKeep') : 'https://open.feishu.cn/open-apis/bot/v2/hook/…' }),
3599
- form.type === 'lark_webhook' && field('signSecret', 'im.signSecret', { type: 'password', placeholder: initial?.hasSign ? t('ds.passwordKeep') : t('im.optional') }),
3600
- form.type === 'lark_app' && h('div', { className: 'n2s-field' }, h('span', null, t('im.domain')),
3601
- h('div', { className: 'n2s-seg' }, ['feishu', 'lark'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.domain === v, onClick: () => setForm(f => ({ ...f, domain: v })) }, t(`im.domain.${v}`))))),
3602
- form.type === 'lark_app' && field('appId', 'im.appId', { placeholder: 'cli_xxx' }),
3603
- form.type === 'lark_app' && field('appSecret', 'im.appSecret', { type: 'password', placeholder: initial?.hasSecret ? t('ds.passwordKeep') : '' }),
3604
- form.type === 'lark_app' && h('label', { className: 'n2s-field' }, h('span', null, t('im.receiveIdType')),
3605
- h('select', { className: 'n2s-select', value: form.receiveIdType, onChange: set('receiveIdType') }, ['chat_id', 'email', 'open_id', 'user_id', 'union_id'].map(v => h('option', { key: v, value: v }, t(`im.rid.${v}`))))),
3606
- form.type === 'lark_app' && h('label', { className: 'n2s-field' }, h('span', null, t('im.receiveId')),
3607
- h('div', { className: 'n2s-row', style: { flexWrap: 'nowrap' } },
3608
- h('input', { className: 'n2s-input', value: form.receiveId, placeholder: form.receiveIdType === 'email' ? 'name@company.com' : form.receiveIdType === 'chat_id' ? 'oc_xxx' : 'ou_xxx', onChange: set('receiveId') }),
3609
- form.receiveIdType === 'chat_id' && h('button', { type: 'button', className: 'n2s-btn small', disabled: !form.appId, onClick: loadChats }, t('im.pickChat')))),
3610
- chats && h('div', { className: 'n2s-field full' }, h('span', null, fmt(t, 'im.chats', { count: chats.length })),
3611
- chats.length === 0 ? h('div', { className: 'n2s-muted n2s-small' }, t('im.noChats')) :
3612
- h('div', { className: 'n2s-chips' }, chats.map(c => h('button', { key: c.chatId, type: 'button', className: 'n2s-chip', 'aria-pressed': form.receiveId === c.chatId, onClick: () => setForm(f => ({ ...f, receiveId: c.chatId, name: f.name || c.name })) }, c.name)))),
3613
- h('div', { className: 'n2s-field full' }, h('span', null, t('im.chartMode')),
3614
- h('div', { className: 'n2s-seg' }, ['native', ...(form.type === 'lark_app' ? ['image'] : []), 'table'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': (form.chartMode ?? 'native') === v, onClick: () => setForm(f => ({ ...f, chartMode: v })) }, t(`im.cm.${v}`)))),
3615
- h('span', { className: 'n2s-muted n2s-small' }, t('im.cmHint'))),
3616
- h('label', { className: 'n2s-check full' }, h('input', { type: 'checkbox', checked: Boolean(form.isDefault), onChange: set('isDefault') }), t('im.setDefault'))),
3617
- error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
3618
- h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
3619
- h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
3620
- h('button', { type: 'button', className: 'n2s-btn', disabled: busy, onClick: () => save(true) }, t('im.saveTest')),
3621
- h('button', { type: 'button', className: 'n2s-btn primary', disabled: busy, onClick: () => save(false) }, t('ds.save'))))
4253
+ const payload = () => {
4254
+ const config = {}
4255
+ const secrets = {}
4256
+ for (const f of visible) {
4257
+ if (isSecretField(f)) { const v = String(form.secrets[f.key] ?? '').trim(); if (v) secrets[f.key] = v }
4258
+ else config[f.key] = typeof form.config[f.key] === 'string' ? form.config[f.key].trim() : form.config[f.key]
4259
+ }
4260
+ const base = { ...(editing ? { id: initial.id } : {}), name: form.name.trim(), type: kind.id, description: form.description.trim(), isDefault: form.isDefault, chartMode: form.chartMode }
4261
+ // Old hosts only understand the flat Lark payload.
4262
+ if (!remote) return { ...base, ...config, ...secrets }
4263
+ return { ...base, config, secrets, ...(caps.bot ? { bot: form.bot } : {}) }
4264
+ }
4265
+ const showError = (e) => {
4266
+ const msg = e?.message ?? String(e)
4267
+ const f = (e?.field && fields.find(x => x.key === e.field)) || visible.find(x => msg.includes(x.key) || [x.label?.zh, x.label?.en, typeof x.label === 'string' ? x.label : null].filter(Boolean).some(l => msg.includes(l)))
4268
+ if (f) setErrors({ [f.key]: msg }); else setGeneral(msg)
4269
+ }
4270
+ const focusFirstError = (errs) => setTimeout(() => {
4271
+ const k = Object.keys(errs).find(x => errs[x])
4272
+ if (k) formRef.current?.querySelector(`#n2s-chf-${CSS_ID(k)}`)?.focus()
4273
+ }, 0)
4274
+ const save = async (test) => {
4275
+ setGeneral(null)
4276
+ const errs = validate()
4277
+ setErrors(errs)
4278
+ if (Object.keys(errs).length) { focusFirstError(errs); return }
4279
+ setBusy(test ? 'test' : 'save')
4280
+ let id = initial?.id
4281
+ try {
4282
+ const saved = await call('saveChannel', payload())
4283
+ id = saved?.id ?? id
4284
+ if (!remote && caps.bot && id && JSON.stringify(form.bot) !== JSON.stringify(initBot) && (form.bot.enabled || initBot.enabled)) {
4285
+ await saveBotFor(id, form.bot).catch(notifyError)
4286
+ }
4287
+ } catch (e) { setBusy(null); showError(e); return }
4288
+ await refreshChannels()
4289
+ refreshBots()
4290
+ if (test && id) {
4291
+ try {
4292
+ const r = await call('testChannel', { id })
4293
+ if (r && r.ok === false) notify(r.detail ? fmt(t, 'ch.savedTestFailedMsg', { msg: r.detail }) : t('ch.savedTestFailed'), 'err')
4294
+ else notify(r?.detail ? `${t('ch.testOk')} · ${r.detail}` : t('ch.testOk'), 'ok')
4295
+ } catch (e) { notify(fmt(t, 'ch.savedTestFailedMsg', { msg: e.message }), 'err') }
4296
+ } else notify(t('ch.saved'), 'ok')
4297
+ setBusy(null)
4298
+ onClose()
4299
+ }
4300
+ const testOnly = async () => {
4301
+ setBusy('test'); setGeneral(null)
4302
+ try {
4303
+ const r = await call('testChannel', { id: initial.id })
4304
+ if (r && r.ok === false) setGeneral(r.detail ?? t('im.lastFailed'))
4305
+ else notify(r?.detail ? `${t('ch.testOk')} · ${r.detail}` : t('ch.testOk'), 'ok')
4306
+ refreshChannels()
4307
+ } catch (e) { setGeneral(e.message) }
4308
+ setBusy(null)
4309
+ }
4310
+ const steps = L(kind.setupSteps)
4311
+ const stepList = Array.isArray(steps) ? steps : []
4312
+ const idBase = 'n2s-chf'
4313
+ return h('div', { ref: formRef },
4314
+ h('div', { className: 'n2s-wiz-head' },
4315
+ !editing && h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-label': t('ch.back'), title: t('ch.back'), onClick: onBack }, h('span', { style: { display: 'inline-flex', transform: 'rotate(180deg)' } }, Icon.arrow(14))),
4316
+ h(PlatformMark, { t, platform, size: 32 }),
4317
+ h('div', { className: 'n2s-grow' },
4318
+ h('div', { id: 'n2s-wiz-title', className: 'n2s-modal-title' }, editing ? fmt(t, 'ch.editTitle', { name: initial.name }) : t('ch.addTitle')),
4319
+ h('div', { className: 'n2s-muted n2s-small' }, `${platformName(t, platform, catalog)} · ${L(kind.label)}`)),
4320
+ h('button', { type: 'button', className: 'n2s-iconbtn n2s-modal-x', 'aria-label': t('ds.cancel'), onClick: onClose }, Icon.close())),
4321
+ !editing && h('ol', { className: 'n2s-wiz-steps', 'aria-label': t('ch.addTitle') },
4322
+ h('li', { className: 'done' }, t('ch.step.pick')), h('li', { className: 'current', 'aria-current': 'step' }, t('ch.step.config'))),
4323
+ stepList.length > 0 && h('details', { className: 'n2s-setup', open: !editing },
4324
+ h('summary', null, fmt(t, 'ch.setupSteps', { n: stepList.length })),
4325
+ h('ol', null, stepList.map((s, i) => h('li', { key: i }, s)))),
4326
+ h('div', { className: 'n2s-form', style: { marginTop: 14 } },
4327
+ h(FieldInput, { t, idBase, f: { key: 'name', label: t('im.name'), required: true, placeholder: t('ph.channelName') }, value: form.name, error: errors.name, onChange: (v) => { setForm(f => ({ ...f, name: v })); setErrors(e => ({ ...e, name: undefined })) } }),
4328
+ h(FieldInput, { t, idBase, f: { key: 'description', label: t('im.description') }, value: form.description, onChange: (v) => setForm(f => ({ ...f, description: v })) }),
4329
+ visible.map(f => h(FieldInput, {
4330
+ key: f.key, t, idBase, f, error: errors[f.key],
4331
+ keep: isSecretField(f) && Boolean(initial?.secretsSet?.[f.key]),
4332
+ value: isSecretField(f) ? (form.secrets[f.key] ?? '') : form.config[f.key],
4333
+ onChange: (v) => (isSecretField(f) ? setSecret(f.key, v) : setConfig(f.key, v)),
4334
+ })),
4335
+ canPick && h(TargetPicker, {
4336
+ t, kind, remote, initial, config: Object.fromEntries(visible.filter(f => !isSecretField(f)).map(f => [f.key, form.config[f.key]])), secrets: Object.fromEntries(Object.entries(form.secrets).filter(([k, v]) => v && visible.some(f => f.key === k))), current: form.config[tf.key],
4337
+ onPick: (c) => { setForm(f => ({ ...f, name: f.name || c.name || '', config: { ...f.config, [tf.key]: c.id } })); setErrors(e => ({ ...e, [tf.key]: undefined, name: undefined })) },
4338
+ }),
4339
+ h('div', { className: 'n2s-field full' },
4340
+ h('span', { className: 'n2s-flabel', id: 'n2s-chf-cm' }, t('im.chartMode')),
4341
+ h('div', { className: 'n2s-seg', role: 'group', 'aria-labelledby': 'n2s-chf-cm', style: { justifySelf: 'start' } }, modes.map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.chartMode === v, onClick: () => setForm(f => ({ ...f, chartMode: v })) }, t(`im.cm.${v}`)))),
4342
+ h('span', { className: 'n2s-faint n2s-small' }, caps.charts === 'none' ? t('ch.tablesOnly') : modes.includes('native') ? t('im.cmHint') : t('ch.cmImage'))),
4343
+ h('label', { className: 'n2s-check full' }, h('input', { type: 'checkbox', checked: form.isDefault, onChange: e => setForm(f => ({ ...f, isDefault: e.target.checked })) }), t('im.setDefault'))),
4344
+ caps.bot && h('fieldset', { ref: botRef, className: `n2s-botbox${focusBot ? ' focus' : ''}` },
4345
+ h('legend', null, t('ch.botSection')),
4346
+ h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 10 } }, t('ch.botSectionHint')),
4347
+ h('label', { className: 'n2s-switch' }, h('input', { type: 'checkbox', role: 'switch', checked: form.bot.enabled, onChange: e => setBot({ enabled: e.target.checked }) }), h('span', null, t('ch.botEnable'))),
4348
+ form.bot.enabled && h('div', { className: 'n2s-form', style: { marginTop: 12 } },
4349
+ h('label', { className: 'n2s-field' }, h('span', null, t('bot.scope')),
4350
+ h('select', { className: 'n2s-select', value: form.bot.sessionScope, onChange: e => setBot({ sessionScope: e.target.value }) }, ['chat', 'user'].map(x => h('option', { key: x, value: x }, t(`bot.scope.${x}`))))),
4351
+ h('label', { className: 'n2s-check', style: { alignSelf: 'end', height: 34 } }, h('input', { type: 'checkbox', checked: form.bot.allowP2p, onChange: e => setBot({ allowP2p: e.target.checked }) }), t('bot.p2p')))),
4352
+ general && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 12 } }, general),
4353
+ h('div', { className: 'n2s-row n2s-wiz-foot' },
4354
+ h('span', { className: 'n2s-faint n2s-small n2s-grow' }, editing && !dirty ? '' : t('ch.testSavesFirst')),
4355
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
4356
+ editing && !dirty
4357
+ ? h('button', { type: 'button', className: 'n2s-btn', disabled: Boolean(busy), onClick: testOnly }, busy === 'test' ? h('span', { className: 'n2s-spin' }) : null, t('ch.testConn'))
4358
+ : h('button', { type: 'button', className: 'n2s-btn', disabled: Boolean(busy), onClick: () => save(true) }, busy === 'test' ? h('span', { className: 'n2s-spin' }) : null, t('im.saveTest')),
4359
+ h('button', { type: 'button', className: 'n2s-btn primary', disabled: Boolean(busy), onClick: () => save(false) }, busy === 'save' ? h('span', { className: 'n2s-spin' }) : null, t('ds.save'))))
4360
+ }
4361
+ const CSS_ID = (k) => String(k).replace(/[^\w-]/g, '_')
4362
+
4363
+ /** Add (pick platform → kind → form) or edit (form only) a channel. */
4364
+ function ChannelWizard({ t, initial, focusBot, onClose }) {
4365
+ const { catalog, remote, ready } = useCatalog(t)
4366
+ const editing = Boolean(initial?.id)
4367
+ const [platform, setPlatform] = useState(initial?.platform ?? null)
4368
+ const [type, setType] = useState(initial?.type ?? null)
4369
+ const p = platform ?? catalog[0]?.platform
4370
+ const found = type ? findKind(catalog, type) : null
4371
+ const step = type ? 'form' : 'pick'
4372
+ return h(Modal, { label: editing ? fmt(t, 'ch.editTitle', { name: initial.name }) : t('ch.addTitle'), labelledBy: step === 'form' && found ? 'n2s-wiz-title' : undefined, onClose, width: 720, className: 'n2s-wizard' },
4373
+ !ready ? h('div', { className: 'n2s-skel', style: { height: 220 } }) :
4374
+ step === 'pick' ? h(React.Fragment, null,
4375
+ h('div', { className: 'n2s-wiz-head' },
4376
+ h('div', { className: 'n2s-grow' }, h('div', { className: 'n2s-modal-title' }, t('ch.addTitle')), h('div', { className: 'n2s-muted n2s-small' }, t('ch.addSub'))),
4377
+ h('button', { type: 'button', className: 'n2s-iconbtn n2s-modal-x', 'aria-label': t('ds.cancel'), onClick: onClose }, Icon.close())),
4378
+ h('ol', { className: 'n2s-wiz-steps', 'aria-label': t('ch.addTitle') }, h('li', { className: 'current', 'aria-current': 'step' }, t('ch.step.pick')), h('li', null, t('ch.step.config'))),
4379
+ catalog.length === 0 ? h('div', { className: 'n2s-empty' }, t('ch.noCatalog')) :
4380
+ h(KindPicker, { t, catalog, platform: p, onPlatform: setPlatform, onPick: (pl, k) => { setPlatform(pl); setType(k) } })) :
4381
+ !found ? h('div', null,
4382
+ h('div', { className: 'n2s-notice err' }, fmt(t, 'ch.unknownKind', { type })),
4383
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } }, h('button', { type: 'button', className: 'n2s-btn', onClick: onClose }, t('ds.cancel')))) :
4384
+ h(ChannelForm, { key: type, t, catalog, remote, platform: found.platform.platform, kind: found.kind, initial: editing ? initial : null, focusBot, onBack: () => setType(null), onClose }))
4385
+ }
4386
+
4387
+ // ----------------------------------------------------------------- list
4388
+
4389
+ const shortTime = (ts) => new Date(ts).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
4390
+
4391
+ function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
4392
+ const kind = findKind(catalog, c.type)?.kind
4393
+ const caps = c.capabilities ?? kind?.capabilities
4394
+ const target = targetSummary(t, c, kind)
4395
+ const bot = caps?.bot ? botOf(c, bots) : null
4396
+ return h('div', { className: 'n2s-list2-row', 'data-channel': c.id },
4397
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
4398
+ h('div', { className: 'n2s-row', style: { gap: 6 } },
4399
+ h('span', { className: 'n2s-list2-title' }, c.name),
4400
+ c.isDefault && h('span', { className: 'n2s-badge blue' }, t('im.default')),
4401
+ c.needsSecret && h('span', { className: 'n2s-badge warn', title: t('ds.needsSecretHint') }, t('ds.needsSecret')),
4402
+ c.secretsSet?.signSecret && h('span', { className: 'n2s-badge' }, t('im.signed'))),
4403
+ h('div', { className: 'n2s-list2-sub' }, kindLabel(t, c.type, catalog), target ? ' · ' : '', target && h('span', { className: target === t('im.webhookHidden') ? undefined : 'n2s-mono' }, target)),
4404
+ h('div', { className: 'n2s-row', style: { gap: 8, marginTop: 6 } },
4405
+ h(CapabilityChips, { t, caps, type: c.type }),
4406
+ bot && h(BotDot, { t, bot, prefix: t('ch.cap.bot') })),
4407
+ c.lastError && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)', whiteSpace: 'normal' } }, c.lastError)),
4408
+ h('div', { className: 'n2s-list2-side' },
4409
+ h('span', { className: `n2s-statusdot ${c.lastError ? 'err' : c.lastSentAt ? 'ok' : ''}` }, c.lastError ? t('im.lastFailed') : c.lastSentAt ? t('im.statusOk') : t('im.statusNew')),
4410
+ c.lastSentAt && h('span', { className: 'n2s-faint n2s-small' }, `${t('im.lastSent')} ${shortTime(c.lastSentAt)}`)),
4411
+ h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
4412
+ h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(() => call('testChannel', { id: c.id }), t('ch.testOk')) }, t('im.test')),
4413
+ h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: c.name }), trigger: { content: Icon.more() }, items: [
4414
+ { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => onEdit(c, false) },
4415
+ !c.isDefault && { label: t('im.makeDefault'), icon: Icon.check(14), onClick: () => act(() => makeDefault(c), t('ch.defaultSet')) },
4416
+ caps?.bot && { label: t('ch.botSettings'), icon: Icon.spark(14), onClick: () => onEdit(c, true) },
4417
+ { divider: true },
4418
+ { label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'im.deleteConfirm', { name: c.name }), message: caps?.bot && bot?.enabled ? t('ch.deleteBotWarn') : undefined, confirmLabel: t('ds.delete'), danger: true })) act(() => call('deleteChannel', { id: c.id }), t('ch.deleted')) } },
4419
+ ] })))
4420
+ }
4421
+
4422
+ /** Mark a channel as default; old hosts need the flat payload, new ones the config shape. */
4423
+ function makeDefault(c) {
4424
+ if (c.legacy) return call('saveChannel', { id: c.id, name: c.name, type: c.type, description: c.description, chartMode: c.chartMode, ...c.config, isDefault: true })
4425
+ return call('saveChannel', { id: c.id, name: c.name, type: c.type, description: c.description, chartMode: c.chartMode, config: c.config, secrets: {}, isDefault: true })
4426
+ }
4427
+
4428
+ function DeliveryLog({ t, channels, deliveries }) {
4429
+ const [open, setOpen] = useState(false)
4430
+ const rows = deliveries ?? []
4431
+ if (!rows.length) return null
4432
+ const failed = rows.slice(0, 50).filter(d => d.status !== 'ok').length
4433
+ const platformOf = (d) => d.platform ?? channels.find(c => c.id === d.channelId)?.platform ?? channels.find(c => c.name === d.channelName)?.platform
4434
+ return h('div', { className: 'n2s-collapse' },
4435
+ h('button', { type: 'button', 'aria-expanded': open, 'aria-controls': 'n2s-deliveries', onClick: () => setOpen(v => !v) },
4436
+ t('im.log'), h('span', { className: 'n2s-faint' }, rows.length), failed > 0 && h('span', { className: 'n2s-badge err' }, fmt(t, 'ch.failedN', { n: failed })), Icon.chev(12)),
4437
+ open && h('div', { id: 'n2s-deliveries', className: 'n2s-table-wrap', style: { marginTop: 12 } }, h('table', { className: 'n2s-table' },
4438
+ h('thead', null, h('tr', null, ['im.col.time', 'im.col.channel', 'im.col.title', 'im.col.source', 'im.col.status'].map(k => h('th', { key: k }, t(k))))),
4439
+ h('tbody', null, rows.slice(0, 50).map(d => h('tr', { key: d.id },
4440
+ h('td', { className: 'n2s-muted' }, shortTime(d.at)),
4441
+ h('td', null, h('span', { className: 'n2s-row', style: { gap: 6, flexWrap: 'nowrap' } }, platformOf(d) && h(PlatformMark, { t, platform: platformOf(d), size: 18 }), d.channelName)),
4442
+ h('td', { title: d.title }, d.title),
4443
+ h('td', { className: 'n2s-muted' }, tOpt(t, `im.src.${d.source}`) || d.source),
4444
+ h('td', { title: d.error ?? '' }, d.status === 'ok'
4445
+ ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : '', d.files ? t('im.sentFile') : '', d.error ? d.error : ''].filter(Boolean).join(' · '))
4446
+ : h('span', { className: 'n2s-statusdot err' }, d.error ?? t('im.lastFailed')))))))))
3622
4447
  }
3623
4448
 
3624
4449
  function PushTab({ t }) {
3625
4450
  const channels = useChannels()
4451
+ const bots = useBots()
4452
+ const { catalog } = useCatalog(t)
3626
4453
  const deliveries = useAsync(() => call('listDeliveries'), [channels])
3627
4454
  const [editing, setEditing] = useState(null)
3628
- const [notice, setNotice] = useState(null)
4455
+ // Pick up host upgrades (catalog, new channel shape) whenever the page opens.
4456
+ useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
4457
+ useEffect(() => {
4458
+ const id = setInterval(() => { if (!document.hidden) refreshBots() }, 8000)
4459
+ return () => clearInterval(id)
4460
+ }, [])
4461
+ // Opened from the bot overview: jump straight into that channel's bot settings.
4462
+ const nav = useSyncExternalStore(channelNav.subscribe, channelNav.get, channelNav.get)
4463
+ useEffect(() => {
4464
+ if (!nav.edit) return
4465
+ const c = channels.find(x => x.id === nav.edit.id)
4466
+ if (c) { setEditing({ channel: c, focusBot: nav.edit.bot }); channelNav.set({ edit: null }) }
4467
+ }, [nav, channels])
3629
4468
  const act = async (fn, ok) => {
3630
- setNotice(null)
3631
- try { await fn(); if (ok) setNotice({ ok: true, text: ok }) } catch (e) { setNotice({ ok: false, text: e.message }) }
4469
+ try { const r = await fn(); if (r && r.ok === false) notify(r.detail ?? t('im.lastFailed'), 'err'); else if (ok) notify(r?.detail ? `${ok} · ${r.detail}` : ok, 'ok') } catch (e) { notifyError(e) }
3632
4470
  refreshChannels()
4471
+ refreshBots()
4472
+ }
4473
+ const groups = []
4474
+ for (const c of channels) {
4475
+ let g = groups.find(x => x.platform === c.platform)
4476
+ if (!g) groups.push(g = { platform: c.platform, items: [] })
4477
+ g.items.push(c)
3633
4478
  }
4479
+ const rank = (p) => { const i = catalog.findIndex(x => x.platform === p); return i < 0 ? 99 : i }
4480
+ groups.sort((a, b) => rank(a.platform) - rank(b.platform))
4481
+ const add = () => setEditing({ channel: null })
3634
4482
  return h('div', null,
3635
- h(SectionHead, { title: t('tab.push'), desc: t('im.hintShort') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('im.add'))),
3636
- notice && h('div', { className: `n2s-notice ${notice.ok ? 'ok' : 'err'}`, style: { marginBottom: 10 }, role: 'status' }, notice.text),
3637
- editing && h(ChannelForm, { key: editing.id ?? 'new', t, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: (msg) => { setEditing(null); if (msg) setNotice({ ok: true, text: msg }) } }),
3638
- channels.length === 0 && !editing && h('div', { className: 'n2s-empty' }, h('h3', null, t('im.empty')), h('div', null, t('im.emptyHint')), h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, t('im.add'))),
3639
- channels.length > 0 && h('div', { className: 'n2s-list2', style: { marginTop: editing ? 12 : 0 } }, channels.map(c => h('div', { key: c.id, className: 'n2s-list2-row' },
3640
- h(TypeTile, { type: c.type }),
3641
- h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
3642
- h('div', { className: 'n2s-row', style: { gap: 6 } },
3643
- h('span', { className: 'n2s-list2-title' }, c.name),
3644
- c.isDefault && h('span', { className: 'n2s-badge' }, t('im.default')),
3645
- c.needsSecret && h('span', { className: 'n2s-badge warn', title: t('ds.needsSecretHint') }, t('ds.needsSecret')),
3646
- c.hasSign && h('span', { className: 'n2s-badge' }, t('im.signed'))),
3647
- h('div', { className: 'n2s-list2-sub' }, t(`im.type.${c.type}`), ' · ', c.type === 'lark_app' ? h('span', { className: 'n2s-mono' }, `${c.receiveIdType}:${c.receiveId}`) : t('im.webhookHidden')),
3648
- c.lastError && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)' } }, c.lastError)),
3649
- h('div', { className: 'n2s-list2-side' },
3650
- h('span', { className: `n2s-statusdot ${c.lastError ? 'err' : c.lastSentAt ? 'ok' : ''}` }, c.lastError ? t('im.lastFailed') : c.lastSentAt ? t('im.statusOk') : t('im.statusNew')),
3651
- c.lastSentAt && h('span', { className: 'n2s-faint n2s-small' }, new Date(c.lastSentAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }))),
3652
- h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
3653
- h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(() => call('testChannel', { id: c.id }), t('im.testOk')) }, t('im.test')),
3654
- h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
3655
- !c.isDefault && { label: t('im.makeDefault'), icon: Icon.check(14), onClick: () => act(() => call('saveChannel', { ...c, isDefault: true })) },
3656
- { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(c) },
3657
- { divider: true },
3658
- { label: t('ds.delete'), icon: Icon.trash(14), danger: true, onClick: async () => { if (await confirmDialog({ title: fmt(t, 'im.deleteConfirm', { name: c.name }), danger: true })) act(() => call('deleteChannel', { id: c.id })) } },
3659
- ] }))))),
3660
- (deliveries.value ?? []).length > 0 && h('div', { className: 'n2s-section' }, h('h2', null, t('im.log')),
3661
- h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
3662
- h('thead', null, h('tr', null, ['im.col.time', 'im.col.channel', 'im.col.title', 'im.col.source', 'im.col.status'].map(k => h('th', { key: k }, t(k))))),
3663
- h('tbody', null, deliveries.value.slice(0, 50).map(d => h('tr', { key: d.id },
3664
- h('td', { className: 'n2s-muted' }, new Date(d.at).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })), h('td', null, d.channelName), h('td', { title: d.title }, d.title),
3665
- h('td', { className: 'n2s-muted' }, t(`im.src.${d.source}`)),
3666
- h('td', { title: d.error ?? '' }, d.status === 'ok'
3667
- ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : ''].filter(Boolean).join(' · '))
3668
- : h('span', { className: 'n2s-statusdot err' }, d.error ?? t('im.lastFailed'))))))))))
4483
+ h(SectionHead, { title: t('admin.push'), desc: t('ch.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: add }, '+ ', t('im.add'))),
4484
+ channels.length === 0
4485
+ ? h('div', { className: 'n2s-empty' },
4486
+ h('div', { className: 'n2s-row', style: { justifyContent: 'center', gap: 8, marginBottom: 12 } }, catalog.map(p => h(PlatformMark, { key: p.platform, t, platform: p.platform, size: 32 }))),
4487
+ h('h3', null, t('im.empty')), h('div', null, t('ch.emptyHint')),
4488
+ h('button', { type: 'button', className: 'n2s-btn primary', onClick: add }, t('im.add')))
4489
+ : h('div', { className: 'n2s-pgroups' }, groups.map(g => h('section', { key: g.platform, className: 'n2s-pgroup', 'aria-label': platformName(t, g.platform, catalog) },
4490
+ h('div', { className: 'n2s-pgroup-head' }, h(PlatformMark, { t, platform: g.platform, size: 22 }), h('span', null, platformName(t, g.platform, catalog)), h('span', { className: 'n2s-faint' }, g.items.length)),
4491
+ h('div', { className: 'n2s-list2' }, g.items.map(c => h(ChannelRow, { key: c.id, t, c, catalog, bots, act, onEdit: (ch, focusBot) => setEditing({ channel: ch, focusBot }) })))))),
4492
+ h(DeliveryLog, { t, channels, deliveries: deliveries.value }),
4493
+ editing && h(ChannelWizard, { key: editing.channel?.id ?? 'new', t, initial: editing.channel, focusBot: editing.focusBot, onClose: () => setEditing(null) }))
3669
4494
  }
3670
4495
 
3671
4496
  const HIDDEN_PIN = { hidden: true }
3672
4497
  const WIDGET_WIDTHS = ['third', 'half', 'full']
3673
4498
 
3674
- function DashboardWidget({ t, widget, onRemove, onChanged, onMove, tick, first, last, filters, filterCount, otherBoards = [], onTransfer, onLoaded, drag }) {
4499
+ function DashboardWidget({ t, widget, onRemove, onChanged, onMove, tick, first, last, filters, filterCount, otherBoards = [], onTransfer, onLoaded, drag, source, showSource }) {
3675
4500
  const [state, setState] = useState({ busy: true })
3676
4501
  const [local, setLocal] = useState(0)
3677
4502
  useEffect(() => {
@@ -3697,6 +4522,7 @@ window.__ModuleLoader__.load({
3697
4522
  onKeyDown: (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); if (!first) onMove(-1) } if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); if (!last) onMove(1) } },
3698
4523
  }, svg(14, [h('circle', { key: 1, cx: 6, cy: 4, r: .9, fill: 'currentColor' }), h('circle', { key: 2, cx: 10, cy: 4, r: .9, fill: 'currentColor' }), h('circle', { key: 3, cx: 6, cy: 8, r: .9, fill: 'currentColor' }), h('circle', { key: 4, cx: 10, cy: 8, r: .9, fill: 'currentColor' }), h('circle', { key: 5, cx: 6, cy: 12, r: .9, fill: 'currentColor' }), h('circle', { key: 6, cx: 10, cy: 12, r: .9, fill: 'currentColor' })])),
3699
4524
  h('span', { className: 'title', title: widget.title }, widget.title),
4525
+ showSource && h('span', { className: `n2s-badge n2s-srcbadge${source ? '' : ' warn'}`, title: source ? `${t('dash.sourceOf')}: ${source.name} · ${typeLabel(t, source.type)}` : t('dash.sourceMissing') }, Icon.db(11), source ? source.name : t('dash.sourceMissingShort')),
3700
4526
  state.busy ? h('span', { className: 'n2s-spin n2s-faint', 'aria-label': t('dash.loading') }) : ago && h('span', { className: 'n2s-faint n2s-small n2s-hide-narrow', title: state.result?.cached ? fmt(t, 'dash.cachedAt', { time: new Date(state.result.cachedAt).toLocaleTimeString() }) : t('dash.updatedAt') }, state.result?.cached ? `${ago} · ${t('dash.cached')}` : ago),
3701
4527
  state.result?.filters?.length > 0 && h('span', { className: 'n2s-badge blue', title: state.result.filters.map(f => f.description).join('\n') }, fmt(t, 'filter.appliedCount', { count: state.result.filters.length })),
3702
4528
  state.result && filterCount > 0 && !(state.result.filters?.length) && h('span', { className: 'n2s-badge', title: t('filter.notApplied') }, t('filter.notAppliedShort')),
@@ -3717,7 +4543,7 @@ window.__ModuleLoader__.load({
3717
4543
  state.result && h(ResultView, { result: widgetResult, t, compact: true, embedded: true, onPinned: HIDDEN_PIN }))
3718
4544
  }
3719
4545
 
3720
- function DashboardTab({ t }) {
4546
+ function DashboardTab({ t, ds, datasources = [] }) {
3721
4547
  const [boardId, setBoardId] = useState(null)
3722
4548
  const list = useAsync(() => call('listDashboard'), [boardId])
3723
4549
  const boards = useAsync(() => call('listDashboards'), [boardId])
@@ -3732,7 +4558,17 @@ window.__ModuleLoader__.load({
3732
4558
  const [order, setOrder] = useState(null)
3733
4559
  useEffect(() => { setOrder(null) }, [list.value])
3734
4560
  const serverWidgets = list.value ?? []
3735
- const widgets = order ? order.map(id => serverWidgets.find(w => w.id === id)).filter(Boolean) : serverWidgets
4561
+ const allWidgets = order ? order.map(id => serverWidgets.find(w => w.id === id)).filter(Boolean) : serverWidgets
4562
+ // Widgets keep the data source they were pinned from; a board may mix sources.
4563
+ // Scope: all sources, or only the one selected at the top right (remembered per browser).
4564
+ const [scope, setScopeState] = useState(() => { try { return localStorage.getItem('nl2sql:dashScope') === 'current' ? 'current' : 'all' } catch { return 'all' } })
4565
+ const setScope = (v) => { setScopeState(v); try { localStorage.setItem('nl2sql:dashScope', v) } catch {} }
4566
+ const sourceIds = [...new Set(allWidgets.map(w => w.datasourceId))]
4567
+ const mixed = sourceIds.length > 1
4568
+ const dsById = new Map(datasources.map(d => [d.id, d]))
4569
+ const widgets = scope === 'current' && ds ? allWidgets.filter(w => w.datasourceId === ds.id) : allWidgets
4570
+ const hiddenByScope = allWidgets.length - widgets.length
4571
+ const showSource = mixed || (ds && sourceIds.some(id => id !== ds.id))
3736
4572
  const saved = useAsync(() => call('getDashboardFilters'), [boardId])
3737
4573
  const [filters, setFilters] = useState(null)
3738
4574
  const [filtersFor, setFiltersFor] = useState(null)
@@ -3754,7 +4590,11 @@ window.__ModuleLoader__.load({
3754
4590
  // Drag and drop.
3755
4591
  const [dragId, setDragId] = useState(null)
3756
4592
  const [over, setOver] = useState(null) // { id, pos: 'before'|'after' }
3757
- const commitOrder = async (ids) => {
4593
+ const commitOrder = async (visibleIds) => {
4594
+ // Hidden (other-source) widgets keep their slots; visible ones take the new order.
4595
+ const queue = [...visibleIds]
4596
+ const visible = new Set(visibleIds)
4597
+ const ids = allWidgets.map(w => (visible.has(w.id) ? queue.shift() : w.id))
3758
4598
  setOrder(ids)
3759
4599
  try { await call('reorderWidgets', { dashboardId: boards.value?.current, ids }) } catch (e) { notifyError(e); setOrder(null) }
3760
4600
  list.reload()
@@ -3793,9 +4633,13 @@ window.__ModuleLoader__.load({
3793
4633
  }
3794
4634
  return h('div', null,
3795
4635
  h(DashboardSwitcher, { t, value: { id: boardId }, onChange: (id) => setBoardId(`${id}:${Date.now()}`) }),
3796
- widgets.length > 0 && h('div', { className: 'n2s-toolbar n2s-dashbar' },
4636
+ allWidgets.length > 0 && h('div', { className: 'n2s-toolbar n2s-dashbar' },
3797
4637
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
3798
- h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4638
+ h('div', { className: 'n2s-row', style: { gap: 10 } },
4639
+ h('div', { className: 'n2s-h2', style: { margin: 0 } }, fmt(t, 'dash.count', { count: widgets.length })),
4640
+ (mixed || hiddenByScope > 0 || (ds && sourceIds.some(id => id !== ds.id))) && h('div', { className: 'n2s-seg small', role: 'group', 'aria-label': t('dash.scope') },
4641
+ ['all', 'current'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': scope === v, disabled: v === 'current' && !ds, onClick: () => setScope(v) }, v === 'all' ? t('dash.scopeAll') : fmt(t, 'dash.scopeCurrent', { name: ds?.name ?? '' }))))),
4642
+ showSource && h('div', { className: 'n2s-faint n2s-small' }, fmt(t, 'dash.sources', { list: sourceIds.map(id => dsById.get(id)?.name ?? t('dash.sourceMissingShort')).join('、') })),
3799
4643
  h('div', { className: 'n2s-faint n2s-small', role: 'status', 'aria-live': 'polite' },
3800
4644
  loadingN > 0 ? fmt(t, 'dash.loadingN', { n: loadingN }) : lastAt ? fmt(t, 'dash.lastRefresh', { time: new Date(lastAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) : '',
3801
4645
  cachedN > 0 && loadingN === 0 ? h('span', null, ' · ', h('button', { type: 'button', className: 'n2s-link', title: t('dash.cachedHint'), onClick: () => setTick(x => x + 1) }, fmt(t, 'dash.cachedN', { n: cachedN }))) : null,
@@ -3812,11 +4656,12 @@ window.__ModuleLoader__.load({
3812
4656
  widgets.length > 0 && filtersReady && h(FilterBar, { key: `f:${boardId ?? 'b'}`, t, filters, onChange: changeFilters }),
3813
4657
  sendingDash && h(SendDialog, { t, initial: { title: t('im.dashboardTitle'), dashboard: true }, onClose: () => setSendingDash(false) }),
3814
4658
  list.error && h('div', { className: 'n2s-notice err' }, list.error.message),
3815
- !list.loading && widgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
4659
+ !list.loading && widgets.length === 0 && hiddenByScope > 0 && h('div', { className: 'n2s-empty' }, h('h3', null, fmt(t, 'dash.scopeEmpty', { name: ds?.name ?? '' })), h('div', null, fmt(t, 'dash.scopeEmptyHint', { n: hiddenByScope })), h('button', { type: 'button', className: 'n2s-btn', onClick: () => setScope('all') }, t('dash.scopeAll'))),
4660
+ !list.loading && allWidgets.length === 0 && h('div', { className: 'n2s-empty' }, h('h3', null, t('dash.empty')), h('div', null, t('dash.emptyHint')), h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'ask' }) }, t('dash.goAsk'))),
3816
4661
  widgets.length > 1 && h('div', { className: 'n2s-faint n2s-small n2s-hide-narrow', style: { margin: '-4px 0 8px' } }, t('dash.dragTip')),
3817
4662
  filtersReady && !list.loading && h('div', { className: `n2s-dash${dragId ? ' is-dragging' : ''}`, key: `d:${boardId ?? 'b'}` }, widgets.map((w, i) => h(DashboardWidget, {
3818
4663
  key: w.id, t, widget: w, tick, first: i === 0, last: i === widgets.length - 1, filters: filters ?? [], filterCount: (filters ?? []).length,
3819
- onChanged: () => list.reload(), onLoaded, drag: dragProps(w),
4664
+ onChanged: () => list.reload(), onLoaded, drag: dragProps(w), source: dsById.get(w.datasourceId), showSource,
3820
4665
  onMove: (delta) => move(w, delta),
3821
4666
  onRemove: async () => { if (!await confirmDialog({ title: fmt(t, 'dash.removeConfirm', { title: w.title }), danger: true })) return; try { await call('unpinWidget', { id: w.id }); notify(t('dash.removed'), 'ok') } catch (e) { notifyError(e) } list.reload() },
3822
4667
  otherBoards, onTransfer: async (to, copy) => { try { await call('transferWidget', { id: w.id, toDashboardId: to, copy }); notify(copy ? t('dash.copied') : t('dash.moved'), 'ok') } catch (e) { notifyError(e) } list.reload(); boards.reload() },
@@ -3916,7 +4761,7 @@ window.__ModuleLoader__.load({
3916
4761
  h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
3917
4762
  h('label', { className: 'n2s-field' }, h('span', null, t('al.every')), h('select', { className: 'n2s-select', value: form.every, onChange: set('every') }, ['15m', 'hour', 'day'].map(v => h('option', { key: v, value: v }, t(`al.every.${v}`))))),
3918
4763
  form.every === 'day' ? h('label', { className: 'n2s-field' }, h('span', null, t('rep.time')), h('input', { className: 'n2s-input', type: 'time', value: form.at, onChange: set('at') })) : h('label', { className: 'n2s-field' }, h('span', null, t('al.cooldown')), h('input', { className: 'n2s-input', type: 'number', value: form.cooldownMin, onChange: set('cooldownMin') })),
3919
- h('label', { className: 'n2s-field' }, h('span', null, t('rep.push')), h('select', { className: 'n2s-select', value: form.channelId ?? '', onChange: set('channelId') }, h('option', { value: '' }, t('al.noPush')), channels.map(ch => h('option', { key: ch.id, value: ch.id }, ch.name)))),
4764
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-al-channel', className: 'n2s-flabel' }, t('rep.push')), h(ChannelPicker, { t, id: 'n2s-al-channel', value: form.channelId ?? '', noneLabel: t('al.noPush'), onChange: (v) => setForm(f => ({ ...f, channelId: v })) })),
3920
4765
  h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
3921
4766
  test && h('div', { className: `n2s-notice ${test.error ? 'err' : test.triggered ? 'warn' : 'ok'}`, style: { marginTop: 10 } }, test.busy ? t('ds.testing') : test.error ?? `${test.triggered ? t('al.wouldFire') : t('al.wouldNotFire')}:${test.message}`),
3922
4767
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
@@ -4165,25 +5010,86 @@ window.__ModuleLoader__.load({
4165
5010
  h('div', { className: 'n2s-cards' }, group.map(card)))
4166
5011
  }))
4167
5012
  }
4168
- // ------------------------------------------------- members & lark bot
5013
+ // ------------------------------------------------------ members & bots
5014
+
5015
+ /** Identities of a member: the new array, or the legacy Lark fields. */
5016
+ function identitiesOf(u) {
5017
+ if (Array.isArray(u?.identities)) return u.identities.filter(x => x && x.id).map(x => ({ platform: x.platform ?? 'lark', id: String(x.id), ...(x.email ? { email: x.email } : {}) }))
5018
+ const out = []
5019
+ const id = u?.kind === 'chat' ? u?.larkChatId : u?.larkOpenId
5020
+ if (id || (u?.kind !== 'chat' && u?.email)) out.push({ platform: 'lark', id: id ?? '', ...(u?.kind !== 'chat' && u?.email ? { email: u.email } : {}) })
5021
+ return out
5022
+ }
5023
+
5024
+ /** Platforms members can be identified on: those with a bot-capable kind. */
5025
+ function identityPlatforms(catalog) {
5026
+ const list = catalog.filter(p => (p.kinds ?? []).some(k => k.capabilities?.bot)).map(p => p.platform)
5027
+ return list.length ? list : ['lark']
5028
+ }
5029
+
5030
+ function IdentityEditor({ t, kind, rows, onChange, catalog, errors }) {
5031
+ const platforms = identityPlatforms(catalog)
5032
+ const set = (i, patch) => onChange(rows.map((r, j) => j === i ? { ...r, ...patch } : r))
5033
+ const help = (p) => tOpt(t, `mb.idHelp.${p}.${kind}`)
5034
+ return h('div', { className: 'n2s-field full', role: 'group', 'aria-labelledby': 'n2s-ids-label' },
5035
+ h('span', { id: 'n2s-ids-label', className: 'n2s-flabel' }, kind === 'chat' ? t('mb.ids.chat') : t('mb.ids.person')),
5036
+ h('span', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.ids.chatHint') : t('mb.ids.personHint')),
5037
+ h('div', { className: 'n2s-idrows' }, rows.map((r, i) => h('div', { key: i, className: 'n2s-idrow', 'data-identity': i },
5038
+ h('div', { className: 'n2s-idrow-main' },
5039
+ h(PlatformMark, { t, platform: r.platform, size: 30 }),
5040
+ h('select', { className: 'n2s-select inline', 'aria-label': fmt(t, 'mb.ids.platformN', { n: i + 1 }), value: r.platform, onChange: e => set(i, { platform: e.target.value }) },
5041
+ [...new Set([...platforms, r.platform])].map(p => h('option', { key: p, value: p }, platformName(t, p, catalog)))),
5042
+ h('input', { className: `n2s-input n2s-mono${errors?.[i] ? ' invalid' : ''}`, 'aria-label': fmt(t, 'mb.ids.idN', { n: i + 1 }), 'aria-invalid': errors?.[i] ? true : undefined, value: r.id, placeholder: ID_PLACEHOLDER[r.platform]?.[kind] ?? 'id', spellCheck: false, autoComplete: 'off', onChange: e => set(i, { id: e.target.value }) }),
5043
+ kind === 'person' && h('input', { className: 'n2s-input', style: { maxWidth: 220 }, type: 'email', 'aria-label': fmt(t, 'mb.ids.emailN', { n: i + 1 }), value: r.email ?? '', placeholder: t('mb.ids.emailPh'), onChange: e => set(i, { email: e.target.value }) }),
5044
+ h('button', { type: 'button', className: 'n2s-iconbtn danger', 'aria-label': fmt(t, 'mb.ids.remove', { n: i + 1 }), title: t('ds.delete'), onClick: () => onChange(rows.filter((_, j) => j !== i)) }, Icon.close())),
5045
+ errors?.[i] ? h('div', { className: 'n2s-fielderr', role: 'alert' }, errors[i]) : help(r.platform) && h('div', { className: 'n2s-faint n2s-small n2s-idrow-help' }, help(r.platform))))),
5046
+ h('button', { type: 'button', className: 'n2s-btn small ghost', style: { justifySelf: 'start' }, onClick: () => onChange([...rows, { platform: rows[rows.length - 1]?.platform ?? platforms[0], id: '', email: '' }]) }, '+ ', t('mb.ids.add')))
5047
+ }
5048
+
5049
+ function IdentityChips({ t, u, catalog }) {
5050
+ const ids = identitiesOf(u)
5051
+ if (!ids.length) return h('span', { className: 'n2s-badge warn' }, t('mb.ids.none'))
5052
+ return h('span', { className: 'n2s-idchips' }, ids.map((x, i) => h('span', { key: i, className: 'n2s-idchip', title: `${platformName(t, x.platform, catalog)} · ${x.id}${x.email ? ` · ${x.email}` : ''}` },
5053
+ h(PlatformMark, { t, platform: x.platform, size: 16 }), h('span', { className: 'n2s-mono' }, x.id || x.email))))
5054
+ }
4169
5055
 
4170
5056
  function UserForm({ t, initial, datasources, onDone, onCancel }) {
4171
- const [form, setForm] = useState(() => ({ kind: 'person', name: '', role: 'analyst', larkOpenId: '', email: '', larkChatId: '', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }))
5057
+ const { catalog } = useCatalog(t)
5058
+ const [form, setForm] = useState(() => {
5059
+ const base = { kind: 'person', name: '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
5060
+ const ids = identitiesOf(initial)
5061
+ return { ...base, identities: ids.length ? ids : [{ platform: 'lark', id: '', email: '' }] }
5062
+ })
4172
5063
  const [error, setError] = useState(null)
5064
+ const [idErrors, setIdErrors] = useState({})
4173
5065
  const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
4174
5066
  const ids = form.datasourceIds ?? []
4175
5067
  const rules = form.rowRules ?? []
4176
5068
  const setRule = (i, patch) => setForm(f => ({ ...f, rowRules: (f.rowRules ?? []).map((r, j) => j === i ? { ...r, ...patch } : r) }))
4177
- const save = async () => { setError(null); try { await call('saveUser', { ...form, datasourceIds: form.allDatasources ? null : ids, rowRules: rules.filter(r => r.column && (Array.isArray(r.values) ? r.values.length : String(r.values ?? '').trim())) }); onDone() } catch (e) { setError(e.message) } }
5069
+ const save = async () => {
5070
+ setError(null)
5071
+ const identities = form.identities
5072
+ .map(r => ({ platform: r.platform, id: String(r.id ?? '').trim(), ...(form.kind === 'person' && String(r.email ?? '').trim() ? { email: String(r.email).trim() } : {}) }))
5073
+ // Rows left empty are dropped; at least one identity is required.
5074
+ const kept = identities.filter(r => r.id || r.email)
5075
+ if (!kept.length) { setIdErrors({ 0: t('mb.ids.needOne') }); return }
5076
+ setIdErrors({})
5077
+ // Legacy mirror for hosts that still read the Lark-only fields.
5078
+ const lark = kept.find(r => r.platform === 'lark' && r.id)
5079
+ const email = kept.find(r => r.email)?.email
5080
+ const legacy = form.kind === 'chat' ? { larkChatId: lark?.id ?? '' } : { larkOpenId: lark?.id ?? '', email: email ?? '' }
5081
+ const { identities: _i, larkOpenId: _o, larkChatId: _c, email: _e, ...rest } = form
5082
+ try {
5083
+ await call('saveUser', { ...rest, ...legacy, identities: kept.filter(r => r.id), datasourceIds: form.allDatasources ? null : ids, rowRules: rules.filter(r => r.column && (Array.isArray(r.values) ? r.values.length : String(r.values ?? '').trim())) })
5084
+ onDone()
5085
+ } catch (e) { setError(e.message) }
5086
+ }
4178
5087
  return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
4179
5088
  h('div', { className: 'n2s-form' },
4180
- h('div', { className: 'n2s-field full' }, h('span', null, t('mb.kind')), h('div', { className: 'n2s-seg' }, ['person', 'chat'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.kind === v, disabled: Boolean(initial?.id), onClick: () => setForm(f => ({ ...f, kind: v })) }, t(`mb.kind.${v}`))))),
5089
+ h('div', { className: 'n2s-field full' }, h('span', { id: 'n2s-mb-kind' }, t('mb.kind')), h('div', { className: 'n2s-seg', role: 'group', 'aria-labelledby': 'n2s-mb-kind', style: { justifySelf: 'start' } }, ['person', 'chat'].map(v => h('button', { key: v, type: 'button', 'aria-pressed': form.kind === v, disabled: Boolean(initial?.id), onClick: () => setForm(f => ({ ...f, kind: v })) }, t(`mb.kind2.${v}`))))),
4181
5090
  h('label', { className: 'n2s-field' }, h('span', null, t('im.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name') })),
4182
5091
  h('label', { className: 'n2s-field' }, h('span', null, t('mb.role')), h('select', { className: 'n2s-select', value: form.role, onChange: set('role') }, ['admin', 'analyst', 'viewer'].map(r => h('option', { key: r, value: r }, t(`mb.role.${r}`)))), h('span', { className: 'n2s-faint n2s-small' }, t(`mb.roleHint.${form.role}`))),
4183
- form.kind === 'person' ? h(React.Fragment, null,
4184
- h('label', { className: 'n2s-field' }, h('span', null, 'open_id'), h('input', { className: 'n2s-input n2s-mono', value: form.larkOpenId ?? '', placeholder: 'ou_xxx', onChange: set('larkOpenId') })),
4185
- h('label', { className: 'n2s-field' }, h('span', null, t('mb.email')), h('input', { className: 'n2s-input', value: form.email ?? '', placeholder: 'name@company.com', onChange: set('email') })))
4186
- : h('label', { className: 'n2s-field full' }, h('span', null, 'chat_id'), h('input', { className: 'n2s-input n2s-mono', value: form.larkChatId ?? '', placeholder: 'oc_xxx', onChange: set('larkChatId') })),
5092
+ h(IdentityEditor, { t, kind: form.kind, rows: form.identities, catalog, errors: idErrors, onChange: (rows) => { setForm(f => ({ ...f, identities: rows })); setIdErrors({}) } }),
4187
5093
  h('div', { className: 'n2s-field full' }, h('span', null, t('mb.scope')),
4188
5094
  h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.allDatasources, onChange: set('allDatasources') }), t('mb.allDs')),
4189
5095
  !form.allDatasources && h('div', { className: 'n2s-chips', style: { marginTop: 6 } }, datasources.map(d => h('button', { key: d.id, type: 'button', className: 'n2s-chip', 'aria-pressed': ids.includes(d.id), onClick: () => setForm(f => ({ ...f, datasourceIds: ids.includes(d.id) ? ids.filter(x => x !== d.id) : [...ids, d.id] })) }, d.name)))),
@@ -4197,44 +5103,69 @@ window.__ModuleLoader__.load({
4197
5103
  h('button', { type: 'button', className: 'n2s-iconbtn', 'aria-label': t('ds.delete'), onClick: () => setForm(f => ({ ...f, rowRules: (f.rowRules ?? []).filter((_, j) => j !== i) })) }, Icon.close()))),
4198
5104
  h('button', { type: 'button', className: 'n2s-btn small ghost', style: { justifySelf: 'start' }, onClick: () => setForm(f => ({ ...f, rowRules: [...(f.rowRules ?? []), { datasourceId: null, column: '', values: '' }] })) }, '+ ', t('mb.addRule'))),
4199
5105
  h('label', { className: 'n2s-field full' }, h('span', null, t('im.description')), h('input', { className: 'n2s-input', value: form.note ?? '', onChange: set('note') }))),
4200
- error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
5106
+ error && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 10 } }, error),
4201
5107
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
4202
5108
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
4203
5109
  h('button', { type: 'button', className: 'n2s-btn primary', onClick: save }, t('ds.save'))))
4204
5110
  }
4205
5111
 
4206
- function LarkBotCard({ t }) {
4207
- const st = useAsync(() => call('larkBotStatus'), [])
4208
- const channels = useChannels().filter(c => c.type === 'lark_app')
4209
- const [error, setError] = useState(null)
4210
- useEffect(() => { const id = setInterval(() => st.reload(), 5000); return () => clearInterval(id) }, [])
4211
- const v = st.value
4212
- if (!v) return h('div', { className: 'n2s-skel', style: { height: 120 } })
4213
- const save = async (patch) => { setError(null); try { await call('saveLarkBot', patch); st.reload() } catch (e) { setError(e.message) } }
4214
- const state = v.status?.state ?? 'idle'
4215
- return h('div', { className: 'n2s-card', style: { marginBottom: 16 } },
4216
- h('div', { className: 'n2s-row', style: { alignItems: 'flex-start' } },
4217
- h(TypeTile, { type: 'lark_app' }),
4218
- h('div', { className: 'n2s-grow' },
4219
- h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-list2-title' }, t('bot.title')), h('span', { className: `n2s-statusdot ${state === 'connected' ? 'ok' : state === 'error' ? 'err' : ''}` }, t(`bot.state.${state}`)), v.bot?.name && h('span', { className: 'n2s-badge' }, v.bot.name)),
4220
- h('div', { className: 'n2s-list2-sub', style: { whiteSpace: 'normal' } }, t('bot.hint')),
4221
- v.status?.error && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)', whiteSpace: 'normal' } }, v.status.error)),
4222
- h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: v.enabled, disabled: !v.channelId && !v.enabled, onChange: e => save({ enabled: e.target.checked }) }), t('bot.enable'))),
4223
- h('div', { className: 'n2s-form', style: { marginTop: 12 } },
4224
- h('label', { className: 'n2s-field' }, h('span', null, t('bot.channel')),
4225
- h('select', { className: 'n2s-select', value: v.channelId ?? '', onChange: e => save({ channelId: e.target.value || null }) }, h('option', { value: '' }, channels.length ? '—' : t('bot.noAppChannel')), channels.map(c => h('option', { key: c.id, value: c.id }, `${c.name} · ${c.appId}`)))),
4226
- h('label', { className: 'n2s-field' }, h('span', null, t('bot.scope')),
4227
- h('select', { className: 'n2s-select', value: v.sessionScope, onChange: e => save({ sessionScope: e.target.value }) }, ['chat', 'user'].map(x => h('option', { key: x, value: x }, t(`bot.scope.${x}`))))),
4228
- h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: v.allowP2p, onChange: e => save({ allowP2p: e.target.checked }) }), t('bot.p2p'))),
4229
- h('details', { className: 'n2s-details' }, h('summary', null, t('bot.setup')), h('ol', { className: 'n2s-small n2s-muted', style: { paddingLeft: 18, margin: '8px 0 0', lineHeight: 1.8 } }, [1, 2, 3, 4, 5, 7, 6].map(i => h('li', { key: i }, t(`bot.step${i}`)))), h('div', { className: 'n2s-small n2s-faint', style: { marginTop: 6 } }, t('bot.actionsHint'))),
4230
- error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
4231
- v.conversations.length > 0 && h('div', { style: { marginTop: 12 } }, h('div', { className: 'n2s-h2' }, t('bot.convs')),
4232
- h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
4233
- h('thead', null, h('tr', null, [t('bot.chat'), t('bot.lastQ'), t('mb.user'), t('im.col.time'), ''].map((x, i) => h('th', { key: i }, x)))),
4234
- h('tbody', null, v.conversations.slice(0, 20).map(c => h('tr', { key: c.key },
4235
- h('td', null, c.chatName ?? (c.chatType === 'p2p' ? t('bot.p2pShort') : c.chatId)), h('td', { title: c.lastQuestion }, c.lastQuestion), h('td', null, c.lastActor ?? ''),
4236
- h('td', { className: 'n2s-muted' }, c.lastAt ? new Date(c.lastAt).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''),
4237
- h('td', null, c.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-label': t('hist.openSession'), title: t('hist.openSession'), onClick: () => fbCtx.openSession?.(c.sessionId) }, Icon.external(13))))))))))
5112
+ /** 「机器人」: every bot-capable channel with status, enable switch and recent conversations. */
5113
+ function BotsOverview({ t }) {
5114
+ const channels = useChannels()
5115
+ const bots = useBots()
5116
+ const { catalog } = useCatalog(t)
5117
+ const [busy, setBusy] = useState(null)
5118
+ const [open, setOpen] = useState(null)
5119
+ useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
5120
+ useEffect(() => {
5121
+ const id = setInterval(() => { if (!document.hidden) refreshBots() }, 5000)
5122
+ return () => clearInterval(id)
5123
+ }, [])
5124
+ const capable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.bot)
5125
+ const toggle = async (c, enabled) => {
5126
+ setBusy(c.id)
5127
+ try { await saveBotFor(c.id, { enabled }); notify(enabled ? fmt(t, 'ch.botOn', { name: c.name }) : fmt(t, 'ch.botOff', { name: c.name }), 'ok') } catch (e) { notifyError(e) }
5128
+ refreshChannels()
5129
+ setBusy(null)
5130
+ }
5131
+ const restart = async (c) => {
5132
+ try {
5133
+ if (bots.legacy) await call('restartLarkBot'); else await call('restartBot', { channelId: c.id })
5134
+ await refreshBots()
5135
+ } catch (e) { notifyError(e) }
5136
+ }
5137
+ return h('div', { className: 'n2s-section', style: { marginTop: 0, marginBottom: 24 } },
5138
+ h('div', { className: 'n2s-row', style: { marginBottom: 10 } },
5139
+ h('h2', { className: 'n2s-h2', style: { margin: 0 } }, t('ch.bots')),
5140
+ h('span', { className: 'n2s-faint n2s-small n2s-grow' }, t('ch.botsHint'))),
5141
+ bots.error && h('div', { className: 'n2s-notice err', style: { marginBottom: 8 } }, bots.error),
5142
+ capable.length === 0
5143
+ ? h('div', { className: 'n2s-empty', style: { padding: '24px 20px' } },
5144
+ h('h3', null, t('ch.bots.empty')), h('div', null, t('ch.bots.emptyHint')),
5145
+ h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'push' }) }, t('ch.bots.goAdd')))
5146
+ : h('div', { className: 'n2s-list2' }, capable.map(c => {
5147
+ const b = botOf(c, bots)
5148
+ const convs = b.conversations ?? []
5149
+ const expanded = open === c.id
5150
+ return h('div', { key: c.id, className: 'n2s-botrow', 'data-bot': c.id },
5151
+ h('div', { className: 'n2s-list2-row' },
5152
+ h(PlatformMark, { t, platform: c.platform }),
5153
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5154
+ h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, c.name), b.info?.name && h('span', { className: 'n2s-badge' }, b.info.name), h(BotDot, { t, bot: b })),
5155
+ h('div', { className: 'n2s-list2-sub' }, `${platformName(t, c.platform, catalog)} · ${kindLabel(t, c.type, catalog)} · ${t(`bot.scope.${b.sessionScope ?? 'chat'}`)}${b.allowP2p ? ` · ${t('ch.p2pOn')}` : ''}`),
5156
+ b.enabled && b.status?.error && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)', whiteSpace: 'normal' } }, b.status.error)),
5157
+ h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
5158
+ convs.length > 0 && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-expanded': expanded, onClick: () => setOpen(expanded ? null : c.id) }, fmt(t, 'ch.convsN', { n: convs.length }), Icon.chev(10)),
5159
+ b.enabled && botStateOf(b) === 'error' && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => restart(c) }, Icon.refresh(12), t('ch.restart')),
5160
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => openChannelEditor(c.id, true) }, t('ch.botSettings')),
5161
+ h('label', { className: 'n2s-switch', title: t('bot.enable') }, h('input', { type: 'checkbox', role: 'switch', 'aria-label': fmt(t, 'ch.botToggle', { name: c.name }), checked: Boolean(b.enabled), disabled: busy === c.id, onChange: e => toggle(c, e.target.checked) })))),
5162
+ expanded && h('div', { style: { padding: '0 16px 14px' } }, h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
5163
+ h('thead', null, h('tr', null, [t('bot.chat'), t('bot.lastQ'), t('mb.user'), t('im.col.time'), ''].map((x, i) => h('th', { key: i }, x)))),
5164
+ h('tbody', null, convs.slice(0, 20).map(cv => h('tr', { key: cv.key },
5165
+ h('td', null, cv.chatName ?? (cv.chatType === 'p2p' ? t('bot.p2pShort') : cv.chatId)), h('td', { title: cv.lastQuestion }, cv.lastQuestion), h('td', null, cv.lastActor ?? ''),
5166
+ h('td', { className: 'n2s-muted' }, cv.lastAt ? shortTime(cv.lastAt) : ''),
5167
+ h('td', null, cv.sessionId && h('button', { type: 'button', className: 'n2s-btn small ghost', 'aria-label': t('hist.openSession'), title: t('hist.openSession'), onClick: () => fbCtx.openSession?.(cv.sessionId) }, Icon.external(13))))))))))
5168
+ })))
4238
5169
  }
4239
5170
 
4240
5171
  function MembersTab({ t, datasources }) {
@@ -4242,14 +5173,15 @@ window.__ModuleLoader__.load({
4242
5173
  const usage = useAsync(() => call('usageByActor', { days: 30 }), [])
4243
5174
  const settings = useAsync(() => call('getSettings'), [])
4244
5175
  const [editing, setEditing] = useState(null)
5176
+ const { catalog } = useCatalog(t)
4245
5177
  const list = users.value ?? []
4246
5178
  const usageById = new Map((usage.value ?? []).map(u => [u.actorId, u]))
4247
5179
  return h('div', null,
4248
5180
  h(SectionHead, { title: t('tab.members'), desc: t('mb.hint') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: () => setEditing({}) }, '+ ', t('mb.add'))),
4249
- h(LarkBotCard, { t }),
5181
+ h(BotsOverview, { t }),
4250
5182
  editing && h(UserForm, { key: editing.id ?? 'new', t, datasources, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); users.reload() } }),
4251
5183
  settings.value && h('div', { className: 'n2s-setnote', style: { marginBottom: 10 } },
4252
- t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
5184
+ t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.botDefaultRole ?? settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
4253
5185
  h('button', { type: 'button', className: 'n2s-link', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'settings' }) }, t('set.changeInSettings'))),
4254
5186
  list.length === 0 && !editing ? h('div', { className: 'n2s-empty' }, h('h3', null, t('mb.empty')), h('div', null, t('mb.emptyHint'))) :
4255
5187
  h('div', { className: 'n2s-list2' }, list.map(u => {
@@ -4258,10 +5190,10 @@ window.__ModuleLoader__.load({
4258
5190
  h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
4259
5191
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
4260
5192
  h('div', { className: 'n2s-row', style: { gap: 6 } }, h('span', { className: 'n2s-list2-title' }, u.name), h('span', { className: 'n2s-badge' }, t(`mb.role.${u.role}`)), u.disabled && h('span', { className: 'n2s-badge err' }, t('mb.disabled'))),
4261
- h('div', { className: 'n2s-list2-sub' }, `${u.kind === 'chat' ? `chat_id ${u.larkChatId}` : [u.email, u.larkOpenId].filter(Boolean).join(' · ')} · ${u.datasourceIds === null ? t('mb.allDs') : u.datasourceNames.join('、') || t('mb.noDs')}`),
5193
+ h('div', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } }, h(IdentityChips, { t, u, catalog }), h('span', { className: 'n2s-muted n2s-small' }, u.datasourceIds === null ? t('mb.allDs') : (u.datasourceNames ?? []).join(uiLang === 'zh' ? '、' : ', ') || t('mb.noDs'))),
4262
5194
  (u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${t('mb.rowRules')}:${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}`)),
4263
5195
  h('div', { className: 'n2s-list2-side' }, h('span', { className: 'n2s-small' }, us ? fmt(t, 'mb.queries', { n: us.queries }) : t('mb.noUsage')), us?.lastAt && h('span', { className: 'n2s-faint n2s-small' }, new Date(us.lastAt).toLocaleDateString())),
4264
- h(Dropdown, { label: t('result.more'), trigger: { content: Icon.more() }, items: [
5196
+ h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
4265
5197
  { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
4266
5198
  { label: u.disabled ? t('mb.enable') : t('mb.disable'), icon: Icon.refresh(14), onClick: async () => { try { await call('saveUser', { id: u.id, disabled: !u.disabled }) } catch (e) { notifyError(e) } users.reload() } },
4267
5199
  { divider: true },
@@ -4302,8 +5234,7 @@ window.__ModuleLoader__.load({
4302
5234
  }
4303
5235
 
4304
5236
  function ChannelSelect({ t, value, onChange }) {
4305
- const channels = useChannels()
4306
- return h('select', { className: 'n2s-select', value, onChange: e => onChange(e.target.value) }, h('option', { value: '' }, t('rep.pushNone')), channels.map(c => h('option', { key: c.id, value: c.id }, c.name)))
5237
+ return h('div', { style: { maxWidth: 380 } }, h(ChannelPicker, { t, value, onChange, noneLabel: t('rep.pushNone'), label: t('set.l.notify') }))
4307
5238
  }
4308
5239
 
4309
5240
  // Questions started from the page bind their new session to the chosen
@@ -4561,7 +5492,7 @@ window.__ModuleLoader__.load({
4561
5492
  nav.tab === 'schema' ? h(SchemaTab, { t, ds, onAskTable: (tb) => ask(fmt(t, 'ask.tablePrompt', { name: tb.name, comment: tb.comment ? `(${tb.comment})` : '' })) }) :
4562
5493
  nav.tab === 'knowledge' ? h(KnowledgeTab, { t, ds }) :
4563
5494
  nav.tab === 'history' ? h(HistoryTab, { t, datasources, onOpenSession: (id) => ctx.uiWorkspace.openSession(id) }) :
4564
- nav.tab === 'dashboard' ? h(DashboardTab, { t }) :
5495
+ nav.tab === 'dashboard' ? h(DashboardTab, { t, ds, datasources }) :
4565
5496
  h(AdminArea, { t, ctx, section: ADMIN_SECTIONS.includes(nav.section) ? nav.section : 'sources', sub: nav.sub, onNavigate: setNav, ds, datasources }))),
4566
5497
  h(FeedbackLayer, { t })))
4567
5498
  }
@@ -4589,7 +5520,7 @@ window.__ModuleLoader__.load({
4589
5520
  privacy: ['sampleMode', 'resultToModel', 'modelRows', 'maskSensitive'],
4590
5521
  query: ['maxRows', 'timeoutMs', 'scanGuardRows', 'cacheTtlSec', 'schemaOverviewMax'],
4591
5522
  automation: ['schemaWatch', 'schemaCheckDaily', 'schemaNotifyChannel'],
4592
- lark: ['larkDefaultRole'],
5523
+ lark: ['botDefaultRole', 'larkDefaultRole'],
4593
5524
  storage: ['retention'],
4594
5525
  about: [],
4595
5526
  }
@@ -4655,6 +5586,8 @@ window.__ModuleLoader__.load({
4655
5586
  h('input', { type: 'radio', name: key, checked: form[key] === v, onChange: () => set(key, v) }),
4656
5587
  h('span', null, h('span', { className: 'title' }, t(`${prefix}.${v}`)), h('span', { className: 'desc' }, t(`${prefix}.${v}.desc`))))))
4657
5588
  const num = (key, props = {}) => h('input', { id: `set-${key}`, className: 'n2s-input', type: 'number', style: { maxWidth: 200 }, value: form[key] ?? '', onChange: e => set(key, e.target.value === '' ? '' : Number(e.target.value)), ...props })
5589
+ // New hosts expose botDefaultRole (larkDefaultRole stays as an alias); old ones only larkDefaultRole.
5590
+ const roleKey = 'botDefaultRole' in base ? 'botDefaultRole' : 'larkDefaultRole'
4658
5591
  const overrides = datasources.filter(d => d.privacy && Object.keys(d.privacy).length)
4659
5592
  const body =
4660
5593
  group === 'privacy' ? h(React.Fragment, null,
@@ -4679,8 +5612,9 @@ window.__ModuleLoader__.load({
4679
5612
  form.schemaCheckDaily && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => set('schemaCheckDaily', '') }, t('set.turnOff')))),
4680
5613
  h(SettingRow, { label: t('set.l.notify'), hint: t('set.h.schemaNotify'), changed: changed('schemaNotifyChannel') }, h(ChannelSelect, { t, value: form.schemaNotifyChannel ?? '', onChange: v => set('schemaNotifyChannel', v) }))) :
4681
5614
  group === 'lark' ? h(React.Fragment, null,
4682
- h(LarkSummaryCard, { t, onOpenMembers: () => onNavigate({ section: 'members' }), onOpenPush: () => onNavigate({ section: 'push' }) }),
4683
- h(SettingRow, { label: t('set.l.unknownLark'), hint: t('set.h.larkDefaultRole'), changed: changed('larkDefaultRole') }, radio('larkDefaultRole', ['none', 'viewer', 'analyst'], 'mb.defaultRole'))) :
5615
+ h(SettingRow, { label: t('set.defaultChannel'), hint: t('set.h.defaultChannel'), htmlFor: 'set-defchannel' }, h(DefaultChannelControl, { t, onOpenPush: () => onNavigate({ section: 'push' }) })),
5616
+ h(SettingRow, { label: t('set.l.unknownUser'), hint: t('set.h.botDefaultRole'), changed: changed(roleKey) }, radio(roleKey, ['none', 'viewer', 'analyst'], 'mb.defaultRole')),
5617
+ h(BotSummary, { t, onOpenMembers: () => onNavigate({ section: 'members' }), onOpenPush: () => onNavigate({ section: 'push' }) })) :
4684
5618
  group === 'storage' ? h(StorageSection, { t, form, set, changed, defaults }) :
4685
5619
  h(AboutSection, { t })
4686
5620
  const canReset = GROUP_KEYS[group].length > 0
@@ -4713,20 +5647,49 @@ window.__ModuleLoader__.load({
4713
5647
  h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpen }, t('set.openAlerts')))
4714
5648
  }
4715
5649
 
4716
- function LarkSummaryCard({ t, onOpenMembers, onOpenPush }) {
4717
- const bot = useAsync(() => call('larkBotStatus').catch(() => null), [])
5650
+ /** Default channel: applied immediately (it is a channel property, not a setting). */
5651
+ function DefaultChannelControl({ t, onOpenPush }) {
4718
5652
  const channels = useChannels()
4719
5653
  const def = channels.find(c => c.isDefault)
4720
- const state = bot.value?.status?.state ?? 'idle'
4721
- return h('div', { className: 'n2s-infogrid' },
4722
- h('div', { className: 'n2s-infocard' },
4723
- h('span', { className: `n2s-statusdot ${state === 'connected' ? 'ok' : state === 'error' ? 'err' : ''}` }, t('bot.title')),
4724
- h('span', { className: 'n2s-muted n2s-small n2s-grow' }, bot.value?.enabled ? t(`bot.state.${state}`) : t('bot.state.idle')),
4725
- h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpenMembers }, t('set.configure'))),
4726
- h('div', { className: 'n2s-infocard' },
4727
- h('span', { className: 'n2s-small', style: { fontWeight: 500 } }, t('set.defaultChannel')),
4728
- h('span', { className: 'n2s-muted n2s-small n2s-grow' }, def ? def.name : channels.length ? t('set.noDefaultChannel') : t('set.noChannels')),
4729
- h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpenPush }, t('set.configure'))))
5654
+ if (!channels.length) return h('div', { className: 'n2s-row' }, h('span', { className: 'n2s-muted n2s-small' }, t('set.noChannels')), h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpenPush }, t('im.add')))
5655
+ return h('div', { style: { maxWidth: 380 } }, h(ChannelPicker, {
5656
+ t, id: 'set-defchannel', value: def?.id ?? '', noneLabel: def ? undefined : t('set.noDefaultChannel'),
5657
+ onChange: async (id) => {
5658
+ const c = channels.find(x => x.id === id)
5659
+ if (!c) return
5660
+ try { await makeDefault(c); notify(t('ch.defaultSet'), 'ok') } catch (e) { notifyError(e) }
5661
+ refreshChannels()
5662
+ },
5663
+ }))
5664
+ }
5665
+
5666
+ /** Bots per platform: how many are on and whether any is failing. */
5667
+ function BotSummary({ t, onOpenMembers, onOpenPush }) {
5668
+ const channels = useChannels()
5669
+ const bots = useBots()
5670
+ const { catalog } = useCatalog(t)
5671
+ const capable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.bot)
5672
+ const byPlatform = []
5673
+ for (const c of capable) {
5674
+ let g = byPlatform.find(x => x.platform === c.platform)
5675
+ if (!g) byPlatform.push(g = { platform: c.platform, total: 0, on: 0, err: 0, ok: 0 })
5676
+ const b = botOf(c, bots)
5677
+ const s = botStateOf(b)
5678
+ g.total++
5679
+ if (b.enabled) g.on++
5680
+ if (s === 'error') g.err++
5681
+ if (s === 'connected') g.ok++
5682
+ }
5683
+ return h(React.Fragment, null,
5684
+ h('div', { className: 'n2s-setsub' }, t('ch.bots')),
5685
+ byPlatform.length === 0
5686
+ ? h('div', { className: 'n2s-setnote' }, t('ch.bots.emptyHint'), ' ', h('button', { type: 'button', className: 'n2s-link', onClick: onOpenPush }, t('ch.bots.goAdd')))
5687
+ : h('div', { className: 'n2s-infogrid' }, byPlatform.map(g => h('div', { key: g.platform, className: 'n2s-infocard' },
5688
+ h(PlatformMark, { t, platform: g.platform, size: 28 }),
5689
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5690
+ h('div', { className: 'n2s-small', style: { fontWeight: 500 } }, platformName(t, g.platform, catalog)),
5691
+ h('span', { className: `n2s-statusdot ${g.err ? 'err' : g.ok ? 'ok' : ''}` }, g.err ? fmt(t, 'ch.sum.err', { n: g.err }) : fmt(t, 'ch.sum.on', { on: g.on, total: g.total }))),
5692
+ h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpenMembers }, t('set.configure'))))))
4730
5693
  }
4731
5694
 
4732
5695
  const RETENTION_KINDS = ['history', 'feedback', 'delivery', 'alertEvent', 'admin', 'evalRun', 'schemaChange']