@hedeqiang/dsh-nl2sql 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -15,6 +15,107 @@ window.__ModuleLoader__.load({
15
15
  // ------------------------------------------------------------ locale
16
16
 
17
17
  const zh = {
18
+ "settings.botDefaultRole": "未登记的用户",
19
+ "settings.larkDefaultRole": "未登记的用户",
20
+ "ch.savedTestFailedMsg": "已保存,但测试失败:{msg}",
21
+ "ch.p.lark": "飞书",
22
+ "ch.p.telegram": "Telegram",
23
+ "ch.p.wecom": "企业微信",
24
+ "ch.p.dingtalk": "钉钉",
25
+ "ch.p.lark.mark": "飞",
26
+ "ch.p.telegram.mark": "TG",
27
+ "ch.p.wecom.mark": "企",
28
+ "ch.p.dingtalk.mark": "钉",
29
+ "ch.k.lark_webhook": "在群设置里添加的自定义机器人,复制 Webhook 地址即可,只能推送。",
30
+ "ch.k.lark_app": "企业自建应用机器人:可发给群或个人、列出群聊,并能在飞书里 @机器人 提问。",
31
+ "ch.f.signSecret": "开启「签名校验」后填写;不填则不签名。",
32
+ "ch.f.receiveId": "群聊可点「选择群聊」自动填写。",
33
+ "ch.desc": "渠道是与一个 IM 平台的连接,用于推送查询结果、看板、定时报告和告警;支持的渠道还能作为机器人,在群里或私聊中直接提问。",
34
+ "ch.emptyHint": "添加飞书、Telegram、企业微信或钉钉渠道,把结果推送到群里,或开启机器人在 IM 里提问。",
35
+ "ch.addTitle": "添加渠道",
36
+ "ch.addSub": "选择平台和接入方式,下一步填写凭据。",
37
+ "ch.editTitle": "编辑渠道「{name}」",
38
+ "ch.back": "返回上一步",
39
+ "ch.step.pick": "选择平台",
40
+ "ch.step.config": "填写配置",
41
+ "ch.pickPlatform": "平台",
42
+ "ch.pickKind": "{platform} 的接入方式",
43
+ "ch.kindsN": "{n} 种接入方式",
44
+ "ch.noCatalog": "没有可用的渠道类型。",
45
+ "ch.unknownKind": "当前版本不支持渠道类型「{type}」。",
46
+ "ch.noPublicUrl": "无需公网地址",
47
+ "ch.cap.native": "原生图表",
48
+ "ch.cap.image": "图片图表",
49
+ "ch.cap.none": "仅表格",
50
+ "ch.cap.buttons": "按钮",
51
+ "ch.cap.bot": "机器人",
52
+ "ch.bot.state.off": "未启用",
53
+ "ch.bot.state.connecting": "连接中",
54
+ "ch.bot.state.connected": "已连接",
55
+ "ch.bot.state.error": "连接失败",
56
+ "ch.bot.state.stopped": "已停止",
57
+ "ch.secretKeep": "已设置,留空不修改",
58
+ "ch.secretSet": "已设置",
59
+ "ch.err.required": "必填",
60
+ "ch.err.pattern": "格式不正确",
61
+ "ch.err.url": "请输入以 http(s):// 开头的地址",
62
+ "ch.setupSteps": "配置步骤({n} 步)",
63
+ "ch.pickHint": "用当前填写的凭据列出机器人所在的群,不会保存。",
64
+ "ch.tablesOnly": "该渠道只能发送表格。",
65
+ "ch.cmImage": "图表会渲染成图片发送。",
66
+ "ch.botSection": "机器人",
67
+ "ch.botSectionHint": "启用后,群里 @机器人 或私聊即可提问;机器人在 Harness 中开一个数据问答会话作答并回复。成员权限见「成员与权限」。",
68
+ "ch.botEnable": "启用机器人",
69
+ "ch.testSavesFirst": "测试前会先保存。",
70
+ "ch.testConn": "测试连接",
71
+ "ch.testOk": "测试成功",
72
+ "ch.savedTestFailed": "已保存,但测试失败",
73
+ "ch.saved": "渠道已保存",
74
+ "ch.deleted": "渠道已删除",
75
+ "ch.defaultSet": "已设为默认渠道",
76
+ "ch.actionsFor": "「{name}」的操作",
77
+ "ch.botSettings": "机器人设置",
78
+ "ch.deleteBotWarn": "这个渠道的机器人也会停止。",
79
+ "ch.failedN": "{n} 条失败",
80
+ "ch.bots": "机器人",
81
+ "ch.botsHint": "在 IM 里 @机器人 或私聊提问,按下方成员权限作答。",
82
+ "ch.bots.empty": "还没有支持机器人的渠道",
83
+ "ch.bots.emptyHint": "添加飞书应用机器人、Telegram、企业微信或钉钉的机器人渠道后,就能在 IM 里直接提问,无需公网地址。",
84
+ "ch.bots.goAdd": "去添加渠道",
85
+ "ch.botToggle": "启用「{name}」的机器人",
86
+ "ch.botOn": "「{name}」的机器人已启用",
87
+ "ch.botOff": "「{name}」的机器人已停用",
88
+ "ch.restart": "重连",
89
+ "ch.convsN": "最近对话 {n}",
90
+ "ch.p2pOn": "允许私聊",
91
+ "ch.sum.on": "{on} / {total} 个已启用",
92
+ "ch.sum.err": "{n} 个连接失败",
93
+ "mb.kind2.person": "个人",
94
+ "mb.kind2.chat": "群聊",
95
+ "mb.ids.person": "IM 身份",
96
+ "mb.ids.chat": "群聊 ID",
97
+ "mb.ids.personHint": "用于识别在 IM 里提问的人;可以添加多个平台的身份。",
98
+ "mb.ids.chatHint": "这个群里的人 @机器人 时按群的权限作答。",
99
+ "mb.ids.add": "添加身份",
100
+ "mb.ids.platformN": "身份 {n} 的平台",
101
+ "mb.ids.idN": "身份 {n} 的 ID",
102
+ "mb.ids.emailN": "身份 {n} 的邮箱(可选)",
103
+ "mb.ids.emailPh": "邮箱(可选)",
104
+ "mb.ids.remove": "删除身份 {n}",
105
+ "mb.ids.empty": "请填写 ID 或邮箱,或删除这一行",
106
+ "mb.ids.needOne": "至少需要一个身份,用于识别成员",
107
+ "mb.ids.none": "未设置身份",
108
+ "mb.idHelp.lark.person": "飞书 open_id(ou_ 开头),可在开放平台 API 调试台或机器人收到的消息事件里找到;也可只填邮箱。",
109
+ "mb.idHelp.lark.chat": "飞书群 chat_id(oc_ 开头),可在「渠道」编辑应用机器人时用「选择群聊」查看。",
110
+ "mb.idHelp.telegram.person": "Telegram 数字用户 ID,可让对方给 @userinfobot 发消息获取。",
111
+ "mb.idHelp.telegram.chat": "Telegram 群 chat id(通常是 -100 开头的负数),可在「渠道」里用「选择群聊」查看。",
112
+ "mb.idHelp.wecom.person": "企业微信 userid(通讯录 → 成员详情里的「账号」)。",
113
+ "mb.idHelp.wecom.chat": "企业微信群聊 chatid,可在机器人收到的消息里找到。",
114
+ "mb.idHelp.dingtalk.person": "钉钉 userId(管理后台 → 通讯录 → 成员详情)。",
115
+ "mb.idHelp.dingtalk.chat": "钉钉群 openConversationId(cid 开头),可在机器人收到的消息里找到。",
116
+ "set.l.unknownUser": "未登记的用户",
117
+ "set.h.botDefaultRole": "不在「成员」列表中的人在群里 @机器人 或私聊时的权限,适用于所有平台的机器人。",
118
+ "set.h.defaultChannel": "Agent 推送、看板发送等未指定渠道时使用。修改会立即生效。",
18
119
  'ask.shuffle': "换一批",
19
120
  'ask.deleteRecent': "删除这条提问记录",
20
121
  'ask.deleteRecentMsg': "将删除「{q}」的全部历史记录(不影响会话本身)。",
@@ -56,7 +157,7 @@ window.__ModuleLoader__.load({
56
157
  'admin.sources': "数据源",
57
158
  'admin.members': "成员与权限",
58
159
  'admin.automation': "告警与报告",
59
- 'admin.push': "推送渠道",
160
+ 'admin.push': "渠道",
60
161
  'admin.settings': "设置",
61
162
  'ds.empty': "还没有数据源",
62
163
  'ds.emptyHint': "先连接一个数据库(MySQL / PostgreSQL / Elasticsearch),或上传一个 CSV / Excel 文件。",
@@ -79,8 +180,8 @@ window.__ModuleLoader__.load({
79
180
  'ob.ask': "试问一个问题",
80
181
  'ob.ask.desc': "用下面推荐的问题试一下,Agent 会写 SQL、执行并画图。",
81
182
  'ob.ask.cta': "试一个问题",
82
- 'ob.push': "配置飞书推送(可选)",
83
- 'ob.push.desc': "把查询结果、看板、定时报告和告警发到飞书群。",
183
+ 'ob.push': "配置推送渠道(可选)",
184
+ 'ob.push.desc': "把查询结果、看板、定时报告和告警发到飞书、Telegram、企业微信或钉钉群。",
84
185
  'ob.push.cta': "去配置",
85
186
  'ask.ex.tables': "这个库里有哪些核心业务表?分别是做什么的?",
86
187
  'ask.ex.biggest': "数据量最大的 10 张表是哪些?",
@@ -115,13 +216,13 @@ window.__ModuleLoader__.load({
115
216
  'set.g.privacy': "模型可见数据",
116
217
  'set.g.query': "查询",
117
218
  'set.g.automation': "自动任务",
118
- 'set.g.lark': "飞书",
219
+ 'set.g.lark': "渠道与机器人",
119
220
  'set.g.storage': "数据与存储",
120
221
  'set.g.about': "关于",
121
222
  'set.gd.privacy': "决定哪些数据会发给模型。查询结果始终完整显示在页面上。",
122
223
  'set.gd.query': "查询的行数、时间和保护。",
123
224
  'set.gd.automation': "告警调度和表结构变更检测。",
124
- 'set.gd.lark': "飞书机器人和未登记用户的权限。",
225
+ 'set.gd.lark': "默认渠道、未登记用户的权限和各平台机器人。",
125
226
  'set.gd.storage': "本地存储、日志保留和配置备份。",
126
227
  'set.gd.about': "版本和运行环境信息。",
127
228
  'settings.sample.off.desc': "模型只看到表名、字段名和类型。",
@@ -152,9 +253,9 @@ window.__ModuleLoader__.load({
152
253
  'set.openAlerts': "查看告警",
153
254
  'set.h.larkDefaultRole': "不在「成员」列表中的飞书用户在群里 @机器人 或私聊时的权限。选择允许时,他们只能访问下方「成员与权限」中设定的默认数据源范围。",
154
255
  'set.configure': "配置",
155
- 'set.defaultChannel': "默认推送渠道",
256
+ 'set.defaultChannel': "默认渠道",
156
257
  'set.noDefaultChannel': "未设置默认渠道",
157
- 'set.noChannels': "还没有推送渠道",
258
+ 'set.noChannels': "还没有渠道",
158
259
  'set.resultsDb': "结果库",
159
260
  'set.resultsDbSub': "{results} 个结果 · {cache} 条缓存",
160
261
  'set.stateFile': "配置文件",
@@ -390,10 +491,10 @@ window.__ModuleLoader__.load({
390
491
  'al.events': '告警记录',
391
492
  'al.message': '内容',
392
493
  'al.deleteConfirm': '删除告警「{name}」?',
393
- 'mb.hint': '为飞书里的同事或群开通数据问答:按成员 / 群限定可访问的数据源,审计记录会归属到具体的人。本机页面始终拥有全部权限。',
494
+ 'mb.hint': "为 IM 里的同事或群开通数据问答:按成员 / 群限定可访问的数据源,审计记录会归属到具体的人。本机页面始终拥有全部权限。",
394
495
  'mb.add': '添加成员',
395
496
  'mb.empty': '还没有成员',
396
- 'mb.emptyHint': '添加飞书同事(open_id 或邮箱)或飞书群(chat_id),再启用上方的飞书机器人,大家就能在飞书里 @机器人 提问。',
497
+ 'mb.emptyHint': "添加同事(飞书、Telegram、企业微信或钉钉的用户 ID)或群聊,再启用上方的机器人,大家就能在 IM 里 @机器人 提问。",
397
498
  'mb.kind': '类型',
398
499
  'mb.kind.person': '飞书用户',
399
500
  'mb.kind.chat': '飞书群',
@@ -414,7 +515,7 @@ window.__ModuleLoader__.load({
414
515
  'mb.usage': '使用情况(近 30 天)',
415
516
  'mb.user': '成员',
416
517
  'mb.lastActive': '最近使用',
417
- 'mb.default': '未登记的飞书用户:',
518
+ 'mb.default': "未登记的用户:",
418
519
  'mb.defaultRole.none': '拒绝(需要先添加为成员)',
419
520
  'mb.defaultRole.viewer': '允许,只读',
420
521
  'mb.defaultRole.analyst': '允许,分析师',
@@ -494,7 +595,7 @@ window.__ModuleLoader__.load({
494
595
  'result.chartType': '图表类型',
495
596
  'result.more': '更多操作',
496
597
  'result.data': '数据',
497
- 'im.sendReply': '发送这条回复到飞书',
598
+ 'im.sendReply': "发送这条回复到 IM",
498
599
  'im.chartMode': '图表形式',
499
600
  'im.cm.default': '跟随渠道',
500
601
  'im.cm.native': '原生图表',
@@ -557,12 +658,12 @@ window.__ModuleLoader__.load({
557
658
  'im.col.time': '时间', 'im.col.channel': '渠道', 'im.col.title': '标题', 'im.col.source': '来源', 'im.col.status': '结果',
558
659
  'im.src.ui': '页面', 'im.src.agent': 'Agent', 'im.src.dashboard': '看板', 'im.src.test': '测试',
559
660
  'im.resultsN': '{count} 个结果', 'im.imagesN': '{count} 张图',
560
- 'im.share': '发送到飞书',
661
+ 'im.share': "发送到 IM",
561
662
  'im.shareAll': '全部发送到 IM',
562
663
  'im.shareDashboard': '发送看板',
563
664
  'im.dashboardTitle': '数据看板',
564
- 'im.sendTitle': '发送到飞书 / Lark',
565
- 'im.noChannel': '还没有推送渠道,请先在「推送」页签添加。',
665
+ 'im.sendTitle': "发送到 IM 渠道",
666
+ 'im.noChannel': "还没有渠道,请先在「管理 → 渠道」添加。",
566
667
  'im.channel': '发送到',
567
668
  'im.msgTitle': '标题',
568
669
  'im.text': '正文(Markdown)',
@@ -905,6 +1006,107 @@ window.__ModuleLoader__.load({
905
1006
  'error.generic': '操作失败:{message}',
906
1007
  }
907
1008
  const en = {
1009
+ "settings.botDefaultRole": "Unregistered users",
1010
+ "settings.larkDefaultRole": "Unregistered users",
1011
+ "ch.savedTestFailedMsg": "Saved, but the test failed: {msg}",
1012
+ "ch.p.lark": "Lark",
1013
+ "ch.p.telegram": "Telegram",
1014
+ "ch.p.wecom": "WeCom",
1015
+ "ch.p.dingtalk": "DingTalk",
1016
+ "ch.p.lark.mark": "L",
1017
+ "ch.p.telegram.mark": "TG",
1018
+ "ch.p.wecom.mark": "W",
1019
+ "ch.p.dingtalk.mark": "D",
1020
+ "ch.k.lark_webhook": "A custom bot added in group settings. Paste its webhook URL; push only.",
1021
+ "ch.k.lark_app": "A custom app bot: message groups or people, list chats, and answer questions when @-mentioned in Lark.",
1022
+ "ch.f.signSecret": "Fill in when signature verification is on; leave empty to send unsigned.",
1023
+ "ch.f.receiveId": "For groups, use \"Pick chat\" to fill this in.",
1024
+ "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.",
1025
+ "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.",
1026
+ "ch.addTitle": "Add channel",
1027
+ "ch.addSub": "Pick a platform and connection type; credentials come next.",
1028
+ "ch.editTitle": "Edit channel \"{name}\"",
1029
+ "ch.back": "Back",
1030
+ "ch.step.pick": "Platform",
1031
+ "ch.step.config": "Configure",
1032
+ "ch.pickPlatform": "Platform",
1033
+ "ch.pickKind": "How to connect {platform}",
1034
+ "ch.kindsN": "{n} connection types",
1035
+ "ch.noCatalog": "No channel types are available.",
1036
+ "ch.unknownKind": "Channel type \"{type}\" is not supported by this version.",
1037
+ "ch.noPublicUrl": "No public URL needed",
1038
+ "ch.cap.native": "Native charts",
1039
+ "ch.cap.image": "Chart images",
1040
+ "ch.cap.none": "Tables only",
1041
+ "ch.cap.buttons": "Buttons",
1042
+ "ch.cap.bot": "Bot",
1043
+ "ch.bot.state.off": "Off",
1044
+ "ch.bot.state.connecting": "Connecting",
1045
+ "ch.bot.state.connected": "Connected",
1046
+ "ch.bot.state.error": "Error",
1047
+ "ch.bot.state.stopped": "Stopped",
1048
+ "ch.secretKeep": "Set; leave empty to keep",
1049
+ "ch.secretSet": "Set",
1050
+ "ch.err.required": "Required",
1051
+ "ch.err.pattern": "Invalid format",
1052
+ "ch.err.url": "Enter an address starting with http(s)://",
1053
+ "ch.setupSteps": "Setup steps ({n})",
1054
+ "ch.pickHint": "Lists the chats the bot is in using the credentials above; nothing is saved.",
1055
+ "ch.tablesOnly": "This channel can only send tables.",
1056
+ "ch.cmImage": "Charts are rendered and sent as images.",
1057
+ "ch.botSection": "Bot",
1058
+ "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.",
1059
+ "ch.botEnable": "Enable bot",
1060
+ "ch.testSavesFirst": "Testing saves the channel first.",
1061
+ "ch.testConn": "Test connection",
1062
+ "ch.testOk": "Test succeeded",
1063
+ "ch.savedTestFailed": "Saved, but the test failed",
1064
+ "ch.saved": "Channel saved",
1065
+ "ch.deleted": "Channel deleted",
1066
+ "ch.defaultSet": "Default channel set",
1067
+ "ch.actionsFor": "Actions for \"{name}\"",
1068
+ "ch.botSettings": "Bot settings",
1069
+ "ch.deleteBotWarn": "The bot on this channel stops too.",
1070
+ "ch.failedN": "{n} failed",
1071
+ "ch.bots": "Bots",
1072
+ "ch.botsHint": "@-mention or DM a bot in IM to ask; answers follow the member permissions below.",
1073
+ "ch.bots.empty": "No bot-capable channels yet",
1074
+ "ch.bots.emptyHint": "Add a Lark app bot, Telegram, WeCom or DingTalk bot channel to ask questions from IM, no public URL needed.",
1075
+ "ch.bots.goAdd": "Add a channel",
1076
+ "ch.botToggle": "Enable the bot of \"{name}\"",
1077
+ "ch.botOn": "Bot of \"{name}\" enabled",
1078
+ "ch.botOff": "Bot of \"{name}\" disabled",
1079
+ "ch.restart": "Reconnect",
1080
+ "ch.convsN": "Recent chats {n}",
1081
+ "ch.p2pOn": "DMs allowed",
1082
+ "ch.sum.on": "{on} / {total} enabled",
1083
+ "ch.sum.err": "{n} failing",
1084
+ "mb.kind2.person": "Person",
1085
+ "mb.kind2.chat": "Group chat",
1086
+ "mb.ids.person": "IM identities",
1087
+ "mb.ids.chat": "Group chat IDs",
1088
+ "mb.ids.personHint": "Identifies this person when they ask from IM; add one per platform.",
1089
+ "mb.ids.chatHint": "People in this group get the group's access when they @-mention the bot.",
1090
+ "mb.ids.add": "Add identity",
1091
+ "mb.ids.platformN": "Platform of identity {n}",
1092
+ "mb.ids.idN": "ID of identity {n}",
1093
+ "mb.ids.emailN": "Email of identity {n} (optional)",
1094
+ "mb.ids.emailPh": "Email (optional)",
1095
+ "mb.ids.remove": "Remove identity {n}",
1096
+ "mb.ids.empty": "Enter an ID or email, or remove this row",
1097
+ "mb.ids.needOne": "Add at least one identity so the member can be recognized",
1098
+ "mb.ids.none": "No identity",
1099
+ "mb.idHelp.lark.person": "Lark open_id (starts with ou_), from the API explorer or a message event; an email alone also works.",
1100
+ "mb.idHelp.lark.chat": "Lark chat_id (starts with oc_); use \"Pick chat\" when editing the app bot channel.",
1101
+ "mb.idHelp.telegram.person": "Numeric Telegram user id; the person can get it from @userinfobot.",
1102
+ "mb.idHelp.telegram.chat": "Telegram group chat id (usually negative, starting with -100); use \"Pick chat\" on the channel.",
1103
+ "mb.idHelp.wecom.person": "WeCom userid (the \"Account\" in the contact details).",
1104
+ "mb.idHelp.wecom.chat": "WeCom group chatid, found in messages the bot receives.",
1105
+ "mb.idHelp.dingtalk.person": "DingTalk userId (admin console → Contacts → member details).",
1106
+ "mb.idHelp.dingtalk.chat": "DingTalk group openConversationId (starts with cid), found in messages the bot receives.",
1107
+ "set.l.unknownUser": "Unregistered users",
1108
+ "set.h.botDefaultRole": "Access for people not in the member list when they @-mention or DM a bot, on every platform.",
1109
+ "set.h.defaultChannel": "Used when the agent or a dashboard send names no channel. Applies immediately.",
908
1110
  'ask.shuffle': "Shuffle",
909
1111
  'ask.deleteRecent': "Delete this question from history",
910
1112
  'ask.deleteRecentMsg': "All history entries of \"{q}\" will be deleted (the conversation itself is kept).",
@@ -946,7 +1148,7 @@ window.__ModuleLoader__.load({
946
1148
  'admin.sources': "Data sources",
947
1149
  'admin.members': "Members & access",
948
1150
  'admin.automation': "Alerts & reports",
949
- 'admin.push': "Push channels",
1151
+ 'admin.push': "Channels",
950
1152
  'admin.settings': "Settings",
951
1153
  'ds.empty': "No data sources yet",
952
1154
  'ds.emptyHint': "Connect a database (MySQL / PostgreSQL / Elasticsearch) or upload a CSV / Excel file.",
@@ -969,8 +1171,8 @@ window.__ModuleLoader__.load({
969
1171
  'ob.ask': "Ask a question",
970
1172
  'ob.ask.desc': "Try a suggested question below; the agent writes SQL, runs it and charts it.",
971
1173
  '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.",
1174
+ 'ob.push': "Set up a push channel (optional)",
1175
+ 'ob.push.desc': "Send results, dashboards, scheduled reports and alerts to Lark, Telegram, WeCom or DingTalk groups.",
974
1176
  'ob.push.cta': "Set up",
975
1177
  'ask.ex.tables': "What are the core business tables in this database and what do they do?",
976
1178
  'ask.ex.biggest': "Which 10 tables have the most rows?",
@@ -1005,13 +1207,13 @@ window.__ModuleLoader__.load({
1005
1207
  'set.g.privacy': "Data shown to the model",
1006
1208
  'set.g.query': "Queries",
1007
1209
  'set.g.automation': "Automation",
1008
- 'set.g.lark': "Lark",
1210
+ 'set.g.lark': "Channels & bots",
1009
1211
  'set.g.storage': "Data & storage",
1010
1212
  'set.g.about': "About",
1011
1213
  'set.gd.privacy': "What data is sent to the model. Results always show in full on the page.",
1012
1214
  'set.gd.query': "Row limits, time limits and protection.",
1013
1215
  'set.gd.automation': "Alert scheduling and schema change detection.",
1014
- 'set.gd.lark': "Lark bot and permissions of unregistered users.",
1216
+ 'set.gd.lark': "Default channel, access for unregistered users, and bots per platform.",
1015
1217
  'set.gd.storage': "Local storage, log retention and configuration backup.",
1016
1218
  'set.gd.about': "Version and runtime.",
1017
1219
  'settings.sample.off.desc': "The model sees table names, column names and types only.",
@@ -1042,9 +1244,9 @@ window.__ModuleLoader__.load({
1042
1244
  'set.openAlerts': "View alerts",
1043
1245
  'set.h.larkDefaultRole': "Permission for Lark users not in the member list when they @ the bot or DM it.",
1044
1246
  'set.configure': "Configure",
1045
- 'set.defaultChannel': "Default push channel",
1247
+ 'set.defaultChannel': "Default channel",
1046
1248
  'set.noDefaultChannel': "No default channel",
1047
- 'set.noChannels': "No push channels yet",
1249
+ 'set.noChannels': "No channels yet",
1048
1250
  'set.resultsDb': "Results database",
1049
1251
  'set.resultsDbSub': "{results} results · {cache} cached",
1050
1252
  'set.stateFile': "Configuration file",
@@ -1128,7 +1330,7 @@ window.__ModuleLoader__.load({
1128
1330
  'filter.add': '+ Add filter', 'filter.remove': 'Remove filter', 'filter.clear': 'Clear',
1129
1331
  '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
1332
  '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',
1333
+ '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
1334
  'im.cmHint': 'Native charts are interactive in Lark (client 7.1+) and work for both bot types; images need an app bot.',
1133
1335
  '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
1336
  'im.md.green': 'Green', 'im.md.red': 'Red', 'im.md.table': 'Table', 'im.md.link': 'Link', 'im.md.atAll': '@all',
@@ -1149,7 +1351,7 @@ window.__ModuleLoader__.load({
1149
1351
  '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
1352
  '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
1353
  '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',
1354
+ '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
1355
  '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
1356
  '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
1357
  '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 +1372,8 @@ window.__ModuleLoader__.load({
1170
1372
  '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
1373
  '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
1374
  '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',
1375
+ 'im.share': 'Send to IM', 'im.shareAll': 'Send all to IM', 'im.shareDashboard': 'Send dashboard', 'im.dashboardTitle': 'Dashboard', 'im.sendTitle': "Send to IM channel",
1376
+ '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
1377
  '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
1378
  'im.includeSql': 'Include SQL', 'im.send': 'Send', 'im.sent': 'Sent to {channel} ({images} images)', 'rep.push': 'Push to', 'rep.pushNone': 'Do not push',
1177
1379
  'tab.reports': 'Reports', 'ds.type': 'Database type', 'ds.indexPattern': 'Indices (comma separated, wildcards allowed)', 'ds.userOptional': 'User (optional)',
@@ -1736,6 +1938,66 @@ window.__ModuleLoader__.load({
1736
1938
  .n2s-dashbar{flex-wrap:wrap;row-gap:8px}
1737
1939
  .n2s-dashbar-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
1738
1940
  @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}}
1941
+ /* ---- channels, bots, identities ---- */
1942
+ .n2s-flabel{color:var(--n2s-muted);font-size:12px;display:inline-flex;align-items:center;gap:2px}
1943
+ .n2s-req{color:var(--n2s-err)}
1944
+ .n2s-fielderr{color:var(--n2s-err);font-size:12px;line-height:1.45}
1945
+ .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)}
1946
+ .n2s-mono-soft{font-variant-ligatures:none}
1947
+ .n2s-pmark{font-weight:700;letter-spacing:-.02em;flex:none;line-height:1}
1948
+ .n2s-caps{display:inline-flex;flex-wrap:wrap;gap:4px}
1949
+ .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}
1950
+ .n2s-cap.muted{color:var(--n2s-faint);border-style:dashed}
1951
+ .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)}
1952
+ .n2s-statusdot.conn::before{background:var(--n2s-warn);animation:n2s-pulse 1.2s ease-in-out infinite}
1953
+ .n2s-statusdot.warn::before{background:var(--n2s-warn)}
1954
+ @keyframes n2s-pulse{50%{opacity:.35}}
1955
+ .n2s-chpick{display:flex;align-items:center;gap:8px;min-width:0}
1956
+ .n2s-chpick .n2s-select{flex:1}
1957
+ .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}
1958
+ .n2s-pgroups{display:grid;gap:18px}
1959
+ .n2s-pgroup-head{display:flex;align-items:center;gap:8px;margin:0 2px 8px;font-size:13px;font-weight:600}
1960
+ .n2s-pgroup-head .n2s-faint{font-weight:400;font-size:12px}
1961
+ .n2s-wizard{padding:20px 24px 18px}
1962
+ .n2s-wiz-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
1963
+ .n2s-wiz-steps{display:flex;gap:6px;list-style:none;margin:0 0 16px;padding:0;counter-reset:wiz}
1964
+ .n2s-wiz-steps li{counter-increment:wiz;display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--n2s-faint)}
1965
+ .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}
1966
+ .n2s-wiz-steps li+li::after{content:none}
1967
+ .n2s-wiz-steps li+li{padding-left:6px;border-left:.5px solid var(--n2s-line-2)}
1968
+ .n2s-wiz-steps li.current{color:var(--dsw-alias-label-primary);font-weight:500}
1969
+ .n2s-wiz-steps li.current::before{border-color:var(--n2s-accent);color:var(--n2s-accent)}
1970
+ .n2s-wiz-steps li.done::before{content:'✓';background:var(--n2s-ok);border-color:transparent;color:#fff}
1971
+ .n2s-ptiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin-top:8px}
1972
+ .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}
1973
+ .n2s-ptile:hover{background:var(--n2s-hover)}
1974
+ .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)}
1975
+ .n2s-ptile:focus-visible,.n2s-kindcard:focus-visible{outline:2px solid var(--n2s-accent);outline-offset:2px}
1976
+ .n2s-ptile-name{font-size:13.5px;font-weight:600}
1977
+ .n2s-kindcards{display:grid;gap:8px;margin-top:8px}
1978
+ .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}
1979
+ .n2s-kindcard:hover{border-color:var(--n2s-accent);background:color-mix(in srgb,var(--n2s-accent) 4%,transparent)}
1980
+ .n2s-kindcard-title{display:flex;align-items:center;gap:6px;font-size:14px;font-weight:600}
1981
+ .n2s-kindcard-title svg{color:var(--n2s-faint)}
1982
+ .n2s-kindcard-desc{font-size:12.5px;color:var(--n2s-muted);line-height:1.55}
1983
+ .n2s-setup{border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius-sm);padding:10px 12px;background:var(--n2s-fill)}
1984
+ .n2s-setup>summary{cursor:pointer;font-size:13px;font-weight:500}
1985
+ .n2s-setup ol{margin:8px 0 2px;padding-left:20px;font-size:12.5px;line-height:1.75;color:var(--n2s-muted)}
1986
+ .n2s-botbox{margin:16px 0 0;padding:12px 14px 14px;border:.5px solid var(--n2s-line-2);border-radius:var(--n2s-radius)}
1987
+ .n2s-botbox legend{padding:0 6px;font-size:13px;font-weight:600}
1988
+ .n2s-botbox.focus{border-color:var(--n2s-accent)}
1989
+ .n2s-wiz-foot{margin-top:16px;padding-top:14px;border-top:.5px solid var(--n2s-line);justify-content:flex-end}
1990
+ .n2s-idrows{display:grid;gap:8px}
1991
+ .n2s-idrow-main{display:flex;align-items:center;gap:8px}
1992
+ .n2s-idrow-main .n2s-input{flex:1}
1993
+ .n2s-idrow-help,.n2s-idrow .n2s-fielderr{margin:4px 0 0 38px}
1994
+ .n2s-idchips{display:inline-flex;flex-wrap:wrap;gap:4px;min-width:0}
1995
+ .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}
1996
+ .n2s-botrow{border-bottom:.5px solid var(--n2s-line)}
1997
+ .n2s-botrow:last-child{border-bottom:0}
1998
+ .n2s-botrow .n2s-list2-row{border-bottom:0}
1999
+ .n2s-collapse>button .n2s-badge{margin-left:2px}
2000
+ @media (prefers-reduced-motion:reduce){.n2s-statusdot.conn::before{animation:none}}
1739
2001
  @keyframes n2s-spin{to{transform:rotate(360deg)}}
1740
2002
  @keyframes n2s-shimmer{to{background-position:-200% 0}}
1741
2003
  `
@@ -1833,7 +2095,11 @@ window.__ModuleLoader__.load({
1833
2095
 
1834
2096
  /** Current UI language ('zh' | 'en'), updated by the page/tool views on render. */
1835
2097
  let uiLang = 'zh'
1836
- const setUiLang = (snapshot) => { uiLang = /^zh/i.test(String(snapshot?.locale ?? snapshot ?? 'zh')) ? 'zh' : 'en' }
2098
+ // LocaleSnapshot is { active, locales, revision } (dsh-client-locale); older hosts passed a string.
2099
+ const setUiLang = (snapshot) => {
2100
+ const id = typeof snapshot === 'string' ? snapshot : (snapshot?.active ?? snapshot?.locale ?? 'zh')
2101
+ uiLang = /^zh/i.test(String(id)) ? 'zh' : 'en'
2102
+ }
1837
2103
 
1838
2104
  function formatNumber(v) {
1839
2105
  const n = Number(v)
@@ -2768,7 +3034,7 @@ window.__ModuleLoader__.load({
2768
3034
  h('div', { className: 'desc' }, t(`ob.${x.key}.desc`))),
2769
3035
  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
3036
  !v.hasChannel && h('div', { className: 'n2s-onboard-extra' },
2771
- h(TypeTile, { type: 'lark_app' }),
3037
+ h(PlatformMark, { t, platform: 'lark' }),
2772
3038
  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
3039
  h('button', { type: 'button', className: 'n2s-btn small', onClick: () => onNavigate({ tab: 'admin', section: 'push' }) }, t('ob.push.cta'))))
2774
3040
  }
@@ -3138,10 +3404,8 @@ window.__ModuleLoader__.load({
3138
3404
  const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7]
3139
3405
 
3140
3406
  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}`)}`))))
3407
+ return h('div', { className: 'n2s-field full' }, h('label', { htmlFor: 'n2s-rep-channel', className: 'n2s-flabel' }, t('rep.push')),
3408
+ h(ChannelPicker, { t, id: 'n2s-rep-channel', value, onChange, noneLabel: t('rep.pushNone') }))
3145
3409
  }
3146
3410
 
3147
3411
  function ReportForm({ t, datasources, initial, defaultDatasourceId, onDone, onCancel }) {
@@ -3377,9 +3641,75 @@ window.__ModuleLoader__.load({
3377
3641
  }
3378
3642
  }
3379
3643
 
3644
+ // ------------------------------------------- channels: catalog & shapes
3645
+ // The host may be older (flat Lark-only channels, no channelCatalog /
3646
+ // botStatus RPCs) or newer (docs/CHANNELS.md). Everything below normalizes
3647
+ // both so the pages only deal with { platform, type, config, secretsSet,
3648
+ // capabilities, bot }.
3649
+
3650
+ const KNOWN_PLATFORMS = ['lark', 'telegram', 'wecom', 'dingtalk']
3651
+ const PLATFORM_COLORS = { lark: '#3370ff', telegram: '#2aabee', wecom: '#0ba5a0', dingtalk: '#1a7cff' }
3652
+ const ID_PLACEHOLDER = {
3653
+ lark: { person: 'ou_xxx', chat: 'oc_xxx' }, telegram: { person: '123456789', chat: '-1001234567890' },
3654
+ wecom: { person: 'zhangsan', chat: 'wrxxxx' }, dingtalk: { person: 'manager1234', chat: 'cidxxxx==' },
3655
+ }
3656
+ const LEGACY_CAPS = {
3657
+ lark_webhook: { push: true, markdown: 'full', charts: 'native', tables: 'native', buttons: false, bot: false, listTargets: false, mentionAll: true },
3658
+ lark_app: { push: true, markdown: 'full', charts: 'native', chartImages: true, tables: 'native', buttons: true, bot: true, listTargets: true, mentionAll: true },
3659
+ }
3660
+ /** Localized value of a { zh, en } object (or a plain string). */
3661
+ const L = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? (v[uiLang] ?? v.zh ?? v.en ?? '') : (v ?? ''))
3662
+ const isUnknownMethod = (e) => e?.code === 'nl2sql/unknown-method' || /unknown method/i.test(e?.message ?? '')
3663
+ const platformOfType = (type) => (/^lark_/.test(type ?? '') ? 'lark' : (String(type ?? '').split('_')[0] || 'lark'))
3664
+ const isSecretField = (f) => Boolean(f.secret || f.type === 'secret')
3665
+ /** Translation or '' when the key is missing (hosts return the key or a !!marker). */
3666
+ const tOpt = (t, k) => { const v = t(k); return !v || v === k || v.startsWith('!!') ? '' : v }
3667
+
3668
+ /** Built-in catalog used when the host has no `channelCatalog` RPC: today's Lark form. */
3669
+ function legacyCatalog(t) {
3670
+ const tx = (k) => ({ zh: t(k), en: t(k) })
3671
+ return [{
3672
+ platform: 'lark', name: tx('ch.p.lark'), builtin: true,
3673
+ kinds: [
3674
+ {
3675
+ id: 'lark_webhook', label: tx('im.type.lark_webhook'), description: tx('ch.k.lark_webhook'),
3676
+ fields: [
3677
+ { 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-]+$' },
3678
+ { key: 'signSecret', label: tx('im.signSecret'), type: 'secret', secret: true, help: tx('ch.f.signSecret') },
3679
+ ],
3680
+ capabilities: LEGACY_CAPS.lark_webhook,
3681
+ setupSteps: { zh: [t('im.help.lark_webhook')], en: [t('im.help.lark_webhook')] },
3682
+ },
3683
+ {
3684
+ id: 'lark_app', label: tx('im.type.lark_app'), description: tx('ch.k.lark_app'),
3685
+ fields: [
3686
+ { key: 'domain', label: tx('im.domain'), type: 'select', options: ['feishu', 'lark'].map(v => ({ value: v, label: tx(`im.domain.${v}`) })) },
3687
+ { key: 'appId', label: tx('im.appId'), type: 'text', required: true, placeholder: 'cli_xxx' },
3688
+ { key: 'appSecret', label: tx('im.appSecret'), type: 'secret', secret: true, required: true },
3689
+ { 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}`) })) },
3690
+ { key: 'receiveId', label: tx('im.receiveId'), type: 'text', required: true, placeholder: 'oc_xxx', target: true, help: tx('ch.f.receiveId') },
3691
+ ],
3692
+ capabilities: LEGACY_CAPS.lark_app,
3693
+ 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) },
3694
+ },
3695
+ ],
3696
+ }]
3697
+ }
3698
+
3699
+ function normalizeChannel(c) {
3700
+ if (!c || typeof c !== 'object') return c
3701
+ const legacy = !c.config
3702
+ const config = c.config ?? Object.fromEntries(['domain', 'appId', 'receiveIdType', 'receiveId'].filter(k => c[k] !== undefined && c[k] !== null).map(k => [k, c[k]]))
3703
+ const secretsSet = c.secretsSet ?? (c.type === 'lark_webhook' ? { webhookUrl: Boolean(c.hasSecret), signSecret: Boolean(c.hasSign) } : c.type === 'lark_app' ? { appSecret: Boolean(c.hasSecret) } : {})
3704
+ return { ...c, platform: c.platform ?? platformOfType(c.type), config, secretsSet, capabilities: c.capabilities ?? LEGACY_CAPS[c.type] ?? { push: true, charts: 'none' }, legacy }
3705
+ }
3706
+
3380
3707
  const channelStore = createStore({ list: null })
3381
3708
  async function refreshChannels() {
3382
- try { channelStore.set({ list: await call('listChannels') }) } catch { channelStore.set({ list: [] }) }
3709
+ try {
3710
+ const list = await call('listChannels')
3711
+ channelStore.set({ list: (Array.isArray(list) ? list : []).map(normalizeChannel) })
3712
+ } catch { channelStore.set({ list: [] }) }
3383
3713
  }
3384
3714
  function useChannels() {
3385
3715
  const get = () => channelStore.get().list
@@ -3387,6 +3717,163 @@ window.__ModuleLoader__.load({
3387
3717
  useEffect(() => { if (list === null) refreshChannels() }, [])
3388
3718
  return list ?? []
3389
3719
  }
3720
+
3721
+ // remote: null = not loaded yet, true = host catalog, false = built-in Lark catalog.
3722
+ const catalogStore = createStore({ list: [], remote: null })
3723
+ async function loadCatalog() {
3724
+ try {
3725
+ const list = await call('channelCatalog')
3726
+ catalogStore.set(Array.isArray(list) && list.length ? { list, remote: true } : { list: [], remote: false })
3727
+ } catch { catalogStore.set({ list: [], remote: false }) }
3728
+ }
3729
+ function sortPlatforms(list) {
3730
+ const rank = (p) => { const i = KNOWN_PLATFORMS.indexOf(p); return i < 0 ? 99 : i }
3731
+ return [...list].sort((a, b) => rank(a.platform) - rank(b.platform))
3732
+ }
3733
+ function useCatalog(t) {
3734
+ const get = () => catalogStore.get()
3735
+ const st = useSyncExternalStore(catalogStore.subscribe, get, get)
3736
+ useEffect(() => { if (st.remote === null) loadCatalog() }, [])
3737
+ const catalog = useMemo(() => (st.remote ? sortPlatforms(st.list) : legacyCatalog(t)), [st, t, uiLang])
3738
+ return { catalog, remote: st.remote === true, ready: st.remote !== null }
3739
+ }
3740
+ function findKind(catalog, type) {
3741
+ for (const p of catalog) { const kind = (p.kinds ?? []).find(k => k.id === type); if (kind) return { platform: p, kind } }
3742
+ return null
3743
+ }
3744
+ function platformName(t, platform, catalog) {
3745
+ if (KNOWN_PLATFORMS.includes(platform)) return t(`ch.p.${platform}`)
3746
+ return L((catalog ?? []).find(p => p.platform === platform)?.name) || String(platform ?? '')
3747
+ }
3748
+ function kindLabel(t, type, catalog) {
3749
+ const k = findKind(catalog ?? [], type)?.kind
3750
+ if (k) return L(k.label)
3751
+ return type === 'lark_webhook' || type === 'lark_app' ? t(`im.type.${type}`) : String(type ?? '')
3752
+ }
3753
+ function channelOptionLabel(t, c, catalog) {
3754
+ return `${c.name} · ${platformName(t, c.platform, catalog)} · ${kindLabel(t, c.type, catalog)}`
3755
+ }
3756
+ /** Chart modes a channel can send: native card charts, PNG images, tables. */
3757
+ function chartModesFor(caps, type) {
3758
+ const cm = caps?.charts ?? 'native'
3759
+ const modes = cm === 'native' ? ['native'] : cm === 'image' ? ['image'] : []
3760
+ if (cm === 'native' && (caps?.chartImages || type === 'lark_app')) modes.push('image')
3761
+ modes.push('table')
3762
+ return modes
3763
+ }
3764
+ /** The form field that holds the delivery target (chat id …), used by the target picker. */
3765
+ function targetFieldOf(kind) {
3766
+ const fields = kind?.fields ?? []
3767
+ return fields.find(f => f.target) ?? fields.find(f => !isSecretField(f) && f.type !== 'select' && /chat|target|receive|conversation/i.test(f.key))
3768
+ }
3769
+ /** One-line, secret-free description of where a channel delivers. */
3770
+ function targetSummary(t, c, kind) {
3771
+ const cfg = c.config ?? {}
3772
+ if (cfg.receiveId) return `${cfg.receiveIdType ? `${cfg.receiveIdType}:` : ''}${cfg.receiveId}`
3773
+ const tf = targetFieldOf(kind)
3774
+ if (tf && cfg[tf.key]) return String(cfg[tf.key])
3775
+ for (const k of ['chatId', 'chat_id', 'conversationId', 'openConversationId', 'target', 'webhookHost', 'host']) if (cfg[k]) return String(cfg[k])
3776
+ if (Object.keys(c.secretsSet ?? {}).some(k => /webhook/i.test(k) && c.secretsSet[k])) return t('im.webhookHidden')
3777
+ const plain = (kind?.fields ?? []).find(f => !isSecretField(f) && f.type !== 'select' && cfg[f.key])
3778
+ return plain ? String(cfg[plain.key]) : ''
3779
+ }
3780
+
3781
+ // ------------------------------------------------------------- bots
3782
+ // New hosts: botStatus / saveBot (one bot per bot-capable channel).
3783
+ // Old hosts: one Lark bot (larkBotStatus / saveLarkBot) bound to a channel.
3784
+
3785
+ const botStore = createStore({ list: null, legacy: false, legacyConf: null, error: null })
3786
+ function legacyBotEntry(v) {
3787
+ 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 ?? [] }
3788
+ }
3789
+ async function refreshBots() {
3790
+ try {
3791
+ const list = await call('botStatus')
3792
+ if (!Array.isArray(list)) throw Object.assign(new Error('unknown method botStatus'), { code: 'nl2sql/unknown-method' })
3793
+ botStore.set({ list, legacy: false, error: null })
3794
+ } catch (e) {
3795
+ if (!isUnknownMethod(e)) { botStore.set({ list: botStore.get().list ?? [], error: e.message }); return }
3796
+ try {
3797
+ const v = await call('larkBotStatus')
3798
+ botStore.set({ list: v?.channelId ? [legacyBotEntry(v)] : [], legacy: true, legacyConf: v ?? null, error: null })
3799
+ } catch { botStore.set({ list: [], legacy: true, legacyConf: null }) }
3800
+ }
3801
+ }
3802
+ function useBots() {
3803
+ const get = () => botStore.get()
3804
+ const st = useSyncExternalStore(botStore.subscribe, get, get)
3805
+ useEffect(() => { if (st.list === null) refreshBots() }, [])
3806
+ return st
3807
+ }
3808
+ /** Bot settings + status of one channel, merged from listChannels and botStatus. */
3809
+ function botOf(c, bots) {
3810
+ const base = { enabled: false, allowP2p: true, sessionScope: 'chat', status: { state: 'idle' }, conversations: [] }
3811
+ const e = (bots?.list ?? []).find(b => b.channelId === c.id)
3812
+ if (bots?.legacy) {
3813
+ const conf = bots.legacyConf ?? {}
3814
+ return e ? { ...base, ...e } : { ...base, allowP2p: conf.allowP2p !== false, sessionScope: conf.sessionScope ?? 'chat' }
3815
+ }
3816
+ const merged = { ...base, ...(c.bot ?? {}), ...(e ?? {}) }
3817
+ return { ...merged, status: e?.status ?? c.bot?.status ?? { state: merged.enabled ? 'connecting' : 'idle' } }
3818
+ }
3819
+ /** 'off' | 'connecting' | 'connected' | 'error' | 'stopped' */
3820
+ function botStateOf(b) {
3821
+ if (!b?.enabled) return 'off'
3822
+ const s = b.status?.state ?? 'connecting'
3823
+ return s === 'idle' ? 'connecting' : s
3824
+ }
3825
+ const BOT_DOT = { connected: 'ok', error: 'err', connecting: 'conn', stopped: 'warn', off: '' }
3826
+ function BotDot({ t, bot, prefix }) {
3827
+ const s = botStateOf(bot)
3828
+ return h('span', { className: `n2s-statusdot ${BOT_DOT[s] ?? ''}`, title: bot?.status?.error ?? undefined }, prefix ? `${prefix} · ` : '', t(`ch.bot.state.${s}`))
3829
+ }
3830
+ async function saveBotFor(channelId, patch) {
3831
+ const st = botStore.get()
3832
+ if (st.legacy) {
3833
+ const conf = st.legacyConf ?? {}
3834
+ const owner = conf.channelId ?? null
3835
+ // The single legacy bot belongs to another channel: only an explicit enable moves it.
3836
+ if (conf.enabled && owner && owner !== channelId && patch.enabled !== true) return
3837
+ if (patch.enabled === false && owner !== channelId) return
3838
+ await call('saveLarkBot', { channelId, ...patch })
3839
+ } else {
3840
+ await call('saveBot', { channelId, ...patch })
3841
+ }
3842
+ await refreshBots()
3843
+ }
3844
+
3845
+ /** Platform tile: brand-tinted letter mark (original, not a logo). */
3846
+ function PlatformMark({ t, platform, size = 36 }) {
3847
+ const color = PLATFORM_COLORS[platform] ?? '#64748b'
3848
+ const txt = KNOWN_PLATFORMS.includes(platform) ? t(`ch.p.${platform}.mark`) : String(platform ?? '?').slice(0, 2).toUpperCase()
3849
+ return h('span', {
3850
+ className: 'n2s-tile n2s-pmark', 'aria-hidden': true,
3851
+ 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)` },
3852
+ }, txt)
3853
+ }
3854
+
3855
+ /** Capability chips: charts / buttons / bot. */
3856
+ function CapabilityChips({ t, caps, type }) {
3857
+ if (!caps) return null
3858
+ const cm = caps.charts ?? 'native'
3859
+ const chart = cm === 'none' ? 'none' : chartModesFor(caps, type).includes('native') ? 'native' : 'image'
3860
+ return h('div', { className: 'n2s-caps' },
3861
+ h('span', { className: `n2s-cap${chart === 'none' ? ' muted' : ''}` }, Icon.chart(11), t(`ch.cap.${chart}`)),
3862
+ caps.buttons && h('span', { className: 'n2s-cap' }, t('ch.cap.buttons')),
3863
+ caps.bot && h('span', { className: 'n2s-cap bot' }, t('ch.cap.bot')))
3864
+ }
3865
+
3866
+ /** Native channel <select> with the platform mark of the selected channel. */
3867
+ function ChannelPicker({ t, value, onChange, noneLabel, id, label }) {
3868
+ const channels = useChannels()
3869
+ const { catalog } = useCatalog(t)
3870
+ const cur = channels.find(c => c.id === value)
3871
+ return h('div', { className: 'n2s-chpick' },
3872
+ cur ? h(PlatformMark, { t, platform: cur.platform, size: 24 }) : h('span', { className: 'n2s-chpick-none', 'aria-hidden': true }, Icon.send(12)),
3873
+ h('select', { id, className: 'n2s-select', 'aria-label': id ? undefined : label, value: value ?? '', onChange: e => onChange(e.target.value) },
3874
+ noneLabel !== undefined && h('option', { value: '' }, noneLabel),
3875
+ channels.map(c => h('option', { key: c.id, value: c.id }, channelOptionLabel(t, c, catalog)))))
3876
+ }
3390
3877
  /** Minimal Markdown → React preview of what the Lark card shows (escaped; <font color> kept). */
3391
3878
  function MdPreview({ text }) {
3392
3879
  const blocks = []
@@ -3550,15 +4037,16 @@ window.__ModuleLoader__.load({
3550
4037
  channels.length === 0
3551
4038
  ? h('div', { className: 'n2s-empty' }, h('div', { className: 'n2s-muted' }, t('im.noChannel')))
3552
4039
  : 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}`)}`)))),
4040
+ h('div', { className: 'n2s-field' }, h('label', { htmlFor: 'n2s-send-channel', className: 'n2s-flabel' }, t('im.channel')),
4041
+ h(ChannelPicker, { t, id: 'n2s-send-channel', value: form.channelId, onChange: (v) => setForm(f => ({ ...f, channelId: v, chartMode: '' })) })),
3555
4042
  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
4043
  !initial.dashboard && h('div', { className: 'n2s-field' }, h('span', null, t('im.text')),
3557
4044
  loading ? h('span', { className: 'n2s-spin n2s-muted' }) : h(MdEditor, { t, value: form.text, onChange: (v) => setForm(f => ({ ...f, text: v })) })),
3558
4045
  h('div', { className: 'n2s-muted n2s-small' }, initial.dashboard ? t('im.dashboardHint') : fmt(t, 'im.attach', { count: resultIds.length })),
3559
4046
  (resultIds.length > 0 || initial.dashboard) && h('div', { className: 'n2s-row' },
3560
4047
  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'))))),
4048
+ channel?.capabilities?.charts === 'none' && h('span', { className: 'n2s-faint n2s-small' }, t('ch.tablesOnly')),
4049
+ 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'))))),
3562
4050
  !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'))),
3563
4051
  state?.error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, state.error),
3564
4052
  state?.ok && h('div', { className: 'n2s-notice ok', style: { marginTop: 10 } }, state.ok),
@@ -3566,106 +4054,412 @@ window.__ModuleLoader__.load({
3566
4054
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
3567
4055
  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
4056
  }
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)
4057
+ // ------------------------------------------------------------ channels page
4058
+ // 「渠道」: channel list grouped by platform, a two-step "add channel" dialog
4059
+ // (platform → kind, then a form rendered from the kind's `fields`), bot
4060
+ // settings per bot-capable channel and the delivery log.
4061
+
4062
+ /** Request from elsewhere (bot overview) to open a channel's editor. */
4063
+ const channelNav = createStore({ edit: null })
4064
+ function openChannelEditor(id, bot) {
4065
+ channelNav.set({ edit: { id, bot: Boolean(bot) } })
4066
+ fbCtx.navigate?.({ tab: 'admin', section: 'push', sub: null })
4067
+ }
4068
+
4069
+ const fieldVisible = (f, config) => {
4070
+ if (!f.showIf) return true
4071
+ const v = String(config?.[f.showIf.key] ?? '')
4072
+ return Array.isArray(f.showIf.equals) ? f.showIf.equals.map(String).includes(v) : v === String(f.showIf.equals)
4073
+ }
4074
+
4075
+ /** Move focus between the [data-nav] items of a container with the arrow keys. */
4076
+ function arrowNav(e, select) {
4077
+ if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
4078
+ const items = [...e.currentTarget.querySelectorAll('[data-nav]:not([disabled])')]
4079
+ if (!items.length) return
4080
+ const i = items.indexOf(document.activeElement)
4081
+ 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
4082
+ e.preventDefault()
4083
+ items[next].focus()
4084
+ if (select) items[next].click()
4085
+ }
4086
+
4087
+ /** Modal shell: portal, backdrop, Escape, initial focus and focus restore. */
4088
+ function Modal({ label, labelledBy, onClose, width, children, className }) {
4089
+ const box = useRef(null)
4090
+ useEffect(() => {
4091
+ const prev = document.activeElement
4092
+ setTimeout(() => box.current?.querySelector('[data-autofocus], input:not([type=checkbox]), select, button:not(.n2s-modal-x)')?.focus(), 0)
4093
+ const onKey = (e) => { if (e.key === 'Escape' && !feedbackStore.get().dialog) { e.stopPropagation(); onClose() } }
4094
+ document.addEventListener('keydown', onKey)
4095
+ return () => { document.removeEventListener('keydown', onKey); prev?.focus?.() }
4096
+ }, [])
4097
+ return ReactDOM.createPortal(h('div', { className: 'n2s-modal-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) onClose() } },
4098
+ 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)
4099
+ }
4100
+
4101
+ // ---------------------------------------------------------------- step 1
4102
+
4103
+ function KindPicker({ t, catalog, platform, onPlatform, onPick }) {
4104
+ const p = catalog.find(x => x.platform === platform) ?? catalog[0]
4105
+ return h('div', { className: 'n2s-wiz-pick' },
4106
+ h('div', { className: 'n2s-flabel', id: 'n2s-wiz-plabel' }, t('ch.pickPlatform')),
4107
+ h('div', { className: 'n2s-ptiles', role: 'radiogroup', 'aria-labelledby': 'n2s-wiz-plabel', onKeyDown: (e) => arrowNav(e, true) },
4108
+ catalog.map(x => h('button', {
4109
+ key: x.platform, type: 'button', role: 'radio', 'data-nav': true, 'aria-checked': x.platform === p?.platform, tabIndex: x.platform === p?.platform ? 0 : -1,
4110
+ 'data-autofocus': x.platform === p?.platform ? true : undefined, className: 'n2s-ptile', onClick: () => onPlatform(x.platform),
4111
+ }, h(PlatformMark, { t, platform: x.platform, size: 32 }), h('span', { className: 'n2s-ptile-name' }, platformName(t, x.platform, catalog)),
4112
+ h('span', { className: 'n2s-faint n2s-small' }, fmt(t, 'ch.kindsN', { n: (x.kinds ?? []).length }))))),
4113
+ p && h(React.Fragment, null,
4114
+ h('div', { className: 'n2s-flabel', id: 'n2s-wiz-klabel', style: { marginTop: 18 } }, fmt(t, 'ch.pickKind', { platform: platformName(t, p.platform, catalog) })),
4115
+ h('div', { className: 'n2s-kindcards', role: 'list', 'aria-labelledby': 'n2s-wiz-klabel', onKeyDown: (e) => arrowNav(e, false) },
4116
+ (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) },
4117
+ h('span', { className: 'n2s-kindcard-title' }, L(k.label), h('span', { className: 'n2s-grow' }), Icon.arrow(14)),
4118
+ k.description && h('span', { className: 'n2s-kindcard-desc' }, L(k.description)),
4119
+ h('span', { className: 'n2s-row', style: { gap: 6, marginTop: 4 } },
4120
+ h(CapabilityChips, { t, caps: k.capabilities, type: k.id }),
4121
+ k.capabilities?.bot && h('span', { className: 'n2s-faint n2s-small' }, t('ch.noPublicUrl'))))))))
4122
+ }
4123
+
4124
+ // ---------------------------------------------------------------- step 2
4125
+
4126
+ function FieldInput({ t, f, value, onChange, error, keep, idBase }) {
4127
+ const id = `${idBase}-${CSS_ID(f.key)}`
4128
+ const err = error ? `${id}-err` : undefined
4129
+ const help = L(f.help)
4130
+ 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 }
4131
+ const secret = isSecretField(f)
4132
+ const control = f.type === 'select'
4133
+ ? h('select', { ...common, className: 'n2s-select', value: value ?? '', onChange: e => onChange(e.target.value) },
4134
+ (value ?? '') === '' && !(f.options ?? []).some(o => o.value === '') && h('option', { value: '' }, '—'),
4135
+ (f.options ?? []).map(o => h('option', { key: o.value, value: o.value }, L(o.label) || o.value)))
4136
+ : h('input', {
4137
+ ...common, className: `n2s-input${secret ? '' : ' n2s-mono-soft'}${error ? ' invalid' : ''}`, type: secret ? 'password' : f.type === 'url' ? 'url' : 'text',
4138
+ value: value ?? '', autoComplete: secret ? 'new-password' : 'off', spellCheck: false,
4139
+ placeholder: secret && keep ? t('ch.secretKeep') : (L(f.placeholder) || (secret && !f.required ? t('im.optional') : '')),
4140
+ onChange: e => onChange(e.target.value),
4141
+ })
4142
+ return h('div', { className: `n2s-field${f.full || f.type === 'url' || (secret && /url|webhook/i.test(f.key)) ? ' full' : ''}` },
4143
+ h('label', { htmlFor: id, className: 'n2s-flabel' }, L(f.label) || f.key, f.required ? h('span', { className: 'n2s-req', 'aria-hidden': true }, ' *') : null,
4144
+ secret && keep && h('span', { className: 'n2s-badge ok', style: { marginLeft: 6 } }, t('ch.secretSet'))),
4145
+ control,
4146
+ error ? h('span', { id: err, className: 'n2s-fielderr', role: 'alert' }, error) : help ? h('span', { id: `${id}-help`, className: 'n2s-faint n2s-small' }, help) : null)
4147
+ }
4148
+
4149
+ function TargetPicker({ t, kind, remote, initial, config, secrets, onPick, current }) {
4150
+ const [st, setSt] = useState(null)
4151
+ const load = async () => {
4152
+ setSt({ busy: true })
3579
4153
  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)
4154
+ 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' }))
4155
+ let list
4156
+ if (!remote && kind.id === 'lark_app') list = await legacyChats()
4157
+ else {
4158
+ try {
4159
+ list = await call('listChannelTargets', { ...(initial?.id ? { id: initial.id } : {}), type: kind.id, config, secrets })
4160
+ } catch (e) {
4161
+ if (!isUnknownMethod(e) || kind.id !== 'lark_app') throw e
4162
+ list = await legacyChats()
4163
+ }
4164
+ }
4165
+ setSt({ list: Array.isArray(list) ? list : [] })
4166
+ } catch (e) { setSt({ error: e.message }) }
3586
4167
  }
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) }
4168
+ return h('div', { className: 'n2s-field full' },
4169
+ h('div', { className: 'n2s-row' },
4170
+ 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')),
4171
+ h('span', { className: 'n2s-faint n2s-small' }, t('ch.pickHint'))),
4172
+ st?.error && h('div', { className: 'n2s-fielderr' }, st.error),
4173
+ st?.list && (st.list.length === 0
4174
+ ? h('div', { className: 'n2s-muted n2s-small' }, t('im.noChats'))
4175
+ : h('div', { role: 'group', 'aria-label': fmt(t, 'im.chats', { count: st.list.length }) },
4176
+ h('div', { className: 'n2s-faint n2s-small', style: { margin: '4px 0 6px' } }, fmt(t, 'im.chats', { count: st.list.length })),
4177
+ 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))))))
4178
+ }
4179
+
4180
+ function ChannelForm({ t, catalog, remote, platform, kind, initial, focusBot, onBack, onClose }) {
4181
+ const bots = useBots()
4182
+ const caps = kind.capabilities ?? {}
4183
+ const modes = chartModesFor(caps, kind.id)
4184
+ const editing = Boolean(initial?.id)
4185
+ const fields = kind.fields ?? []
4186
+ const initBot = useMemo(() => {
4187
+ const b = initial ? botOf(initial, bots) : null
4188
+ return { enabled: Boolean(b?.enabled), allowP2p: b ? b.allowP2p !== false : true, sessionScope: b?.sessionScope ?? 'chat' }
4189
+ }, [])
4190
+ const [form, setForm] = useState(() => {
4191
+ const config = { ...(initial?.config ?? {}) }
4192
+ 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 ?? '') : '')
4193
+ return { name: initial?.name ?? '', description: initial?.description ?? '', isDefault: Boolean(initial?.isDefault), chartMode: modes.includes(initial?.chartMode) ? initial.chartMode : modes[0], config, secrets: {}, bot: initBot }
4194
+ })
4195
+ const pristine = useRef(JSON.stringify(form))
4196
+ const dirty = JSON.stringify(form) !== pristine.current
4197
+ const [errors, setErrors] = useState({})
4198
+ const [general, setGeneral] = useState(null)
4199
+ const [busy, setBusy] = useState(null)
4200
+ const botRef = useRef(null)
4201
+ const formRef = useRef(null)
4202
+ useEffect(() => { if (focusBot) setTimeout(() => { botRef.current?.scrollIntoView?.({ block: 'center' }); botRef.current?.querySelector('input')?.focus() }, 30) }, [])
4203
+ const visible = fields.filter(f => fieldVisible(f, form.config))
4204
+ const setConfig = (k, v) => { setForm(f => ({ ...f, config: { ...f.config, [k]: v } })); setErrors(e => ({ ...e, [k]: undefined })) }
4205
+ const setSecret = (k, v) => { setForm(f => ({ ...f, secrets: { ...f.secrets, [k]: v } })); setErrors(e => ({ ...e, [k]: undefined })) }
4206
+ const setBot = (patch) => setForm(f => ({ ...f, bot: { ...f.bot, ...patch } }))
4207
+ const tf = targetFieldOf(kind)
4208
+ const canPick = caps.listTargets && tf && fieldVisible(tf, form.config) && (form.config.receiveIdType === undefined || form.config.receiveIdType === 'chat_id' || !fields.some(f => f.key === 'receiveIdType'))
4209
+
4210
+ const validate = () => {
4211
+ const errs = {}
4212
+ if (!form.name.trim()) errs.name = t('ch.err.required')
4213
+ for (const f of visible) {
4214
+ const secret = isSecretField(f)
4215
+ const v = String((secret ? form.secrets[f.key] : form.config[f.key]) ?? '').trim()
4216
+ if (!v) { if (f.required && !(secret && initial?.secretsSet?.[f.key])) errs[f.key] = t('ch.err.required'); continue }
4217
+ if (f.pattern) { try { if (!new RegExp(f.pattern).test(v)) errs[f.key] = t('ch.err.pattern') } catch {} }
4218
+ if (f.type === 'url' && !/^https?:\/\//i.test(v)) errs[f.key] = t('ch.err.url')
4219
+ }
4220
+ return errs
3590
4221
  }
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'))))
4222
+ const payload = () => {
4223
+ const config = {}
4224
+ const secrets = {}
4225
+ for (const f of visible) {
4226
+ if (isSecretField(f)) { const v = String(form.secrets[f.key] ?? '').trim(); if (v) secrets[f.key] = v }
4227
+ else config[f.key] = typeof form.config[f.key] === 'string' ? form.config[f.key].trim() : form.config[f.key]
4228
+ }
4229
+ const base = { ...(editing ? { id: initial.id } : {}), name: form.name.trim(), type: kind.id, description: form.description.trim(), isDefault: form.isDefault, chartMode: form.chartMode }
4230
+ // Old hosts only understand the flat Lark payload.
4231
+ if (!remote) return { ...base, ...config, ...secrets }
4232
+ return { ...base, config, secrets, ...(caps.bot ? { bot: form.bot } : {}) }
4233
+ }
4234
+ const showError = (e) => {
4235
+ const msg = e?.message ?? String(e)
4236
+ 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)))
4237
+ if (f) setErrors({ [f.key]: msg }); else setGeneral(msg)
4238
+ }
4239
+ const focusFirstError = (errs) => setTimeout(() => {
4240
+ const k = Object.keys(errs).find(x => errs[x])
4241
+ if (k) formRef.current?.querySelector(`#n2s-chf-${CSS_ID(k)}`)?.focus()
4242
+ }, 0)
4243
+ const save = async (test) => {
4244
+ setGeneral(null)
4245
+ const errs = validate()
4246
+ setErrors(errs)
4247
+ if (Object.keys(errs).length) { focusFirstError(errs); return }
4248
+ setBusy(test ? 'test' : 'save')
4249
+ let id = initial?.id
4250
+ try {
4251
+ const saved = await call('saveChannel', payload())
4252
+ id = saved?.id ?? id
4253
+ if (!remote && caps.bot && id && JSON.stringify(form.bot) !== JSON.stringify(initBot) && (form.bot.enabled || initBot.enabled)) {
4254
+ await saveBotFor(id, form.bot).catch(notifyError)
4255
+ }
4256
+ } catch (e) { setBusy(null); showError(e); return }
4257
+ await refreshChannels()
4258
+ refreshBots()
4259
+ if (test && id) {
4260
+ try {
4261
+ const r = await call('testChannel', { id })
4262
+ if (r && r.ok === false) notify(r.detail ? fmt(t, 'ch.savedTestFailedMsg', { msg: r.detail }) : t('ch.savedTestFailed'), 'err')
4263
+ else notify(r?.detail ? `${t('ch.testOk')} · ${r.detail}` : t('ch.testOk'), 'ok')
4264
+ } catch (e) { notify(fmt(t, 'ch.savedTestFailedMsg', { msg: e.message }), 'err') }
4265
+ } else notify(t('ch.saved'), 'ok')
4266
+ setBusy(null)
4267
+ onClose()
4268
+ }
4269
+ const testOnly = async () => {
4270
+ setBusy('test'); setGeneral(null)
4271
+ try {
4272
+ const r = await call('testChannel', { id: initial.id })
4273
+ if (r && r.ok === false) setGeneral(r.detail ?? t('im.lastFailed'))
4274
+ else notify(r?.detail ? `${t('ch.testOk')} · ${r.detail}` : t('ch.testOk'), 'ok')
4275
+ refreshChannels()
4276
+ } catch (e) { setGeneral(e.message) }
4277
+ setBusy(null)
4278
+ }
4279
+ const steps = L(kind.setupSteps)
4280
+ const stepList = Array.isArray(steps) ? steps : []
4281
+ const idBase = 'n2s-chf'
4282
+ return h('div', { ref: formRef },
4283
+ h('div', { className: 'n2s-wiz-head' },
4284
+ !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))),
4285
+ h(PlatformMark, { t, platform, size: 32 }),
4286
+ h('div', { className: 'n2s-grow' },
4287
+ h('div', { id: 'n2s-wiz-title', className: 'n2s-modal-title' }, editing ? fmt(t, 'ch.editTitle', { name: initial.name }) : t('ch.addTitle')),
4288
+ h('div', { className: 'n2s-muted n2s-small' }, `${platformName(t, platform, catalog)} · ${L(kind.label)}`)),
4289
+ h('button', { type: 'button', className: 'n2s-iconbtn n2s-modal-x', 'aria-label': t('ds.cancel'), onClick: onClose }, Icon.close())),
4290
+ !editing && h('ol', { className: 'n2s-wiz-steps', 'aria-label': t('ch.addTitle') },
4291
+ h('li', { className: 'done' }, t('ch.step.pick')), h('li', { className: 'current', 'aria-current': 'step' }, t('ch.step.config'))),
4292
+ stepList.length > 0 && h('details', { className: 'n2s-setup', open: !editing },
4293
+ h('summary', null, fmt(t, 'ch.setupSteps', { n: stepList.length })),
4294
+ h('ol', null, stepList.map((s, i) => h('li', { key: i }, s)))),
4295
+ h('div', { className: 'n2s-form', style: { marginTop: 14 } },
4296
+ 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 })) } }),
4297
+ h(FieldInput, { t, idBase, f: { key: 'description', label: t('im.description') }, value: form.description, onChange: (v) => setForm(f => ({ ...f, description: v })) }),
4298
+ visible.map(f => h(FieldInput, {
4299
+ key: f.key, t, idBase, f, error: errors[f.key],
4300
+ keep: isSecretField(f) && Boolean(initial?.secretsSet?.[f.key]),
4301
+ value: isSecretField(f) ? (form.secrets[f.key] ?? '') : form.config[f.key],
4302
+ onChange: (v) => (isSecretField(f) ? setSecret(f.key, v) : setConfig(f.key, v)),
4303
+ })),
4304
+ canPick && h(TargetPicker, {
4305
+ 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],
4306
+ 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 })) },
4307
+ }),
4308
+ h('div', { className: 'n2s-field full' },
4309
+ h('span', { className: 'n2s-flabel', id: 'n2s-chf-cm' }, t('im.chartMode')),
4310
+ 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}`)))),
4311
+ h('span', { className: 'n2s-faint n2s-small' }, caps.charts === 'none' ? t('ch.tablesOnly') : modes.includes('native') ? t('im.cmHint') : t('ch.cmImage'))),
4312
+ 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'))),
4313
+ caps.bot && h('fieldset', { ref: botRef, className: `n2s-botbox${focusBot ? ' focus' : ''}` },
4314
+ h('legend', null, t('ch.botSection')),
4315
+ h('div', { className: 'n2s-muted n2s-small', style: { marginBottom: 10 } }, t('ch.botSectionHint')),
4316
+ 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'))),
4317
+ form.bot.enabled && h('div', { className: 'n2s-form', style: { marginTop: 12 } },
4318
+ h('label', { className: 'n2s-field' }, h('span', null, t('bot.scope')),
4319
+ 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}`))))),
4320
+ 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')))),
4321
+ general && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 12 } }, general),
4322
+ h('div', { className: 'n2s-row n2s-wiz-foot' },
4323
+ h('span', { className: 'n2s-faint n2s-small n2s-grow' }, editing && !dirty ? '' : t('ch.testSavesFirst')),
4324
+ h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onClose }, t('ds.cancel')),
4325
+ editing && !dirty
4326
+ ? h('button', { type: 'button', className: 'n2s-btn', disabled: Boolean(busy), onClick: testOnly }, busy === 'test' ? h('span', { className: 'n2s-spin' }) : null, t('ch.testConn'))
4327
+ : h('button', { type: 'button', className: 'n2s-btn', disabled: Boolean(busy), onClick: () => save(true) }, busy === 'test' ? h('span', { className: 'n2s-spin' }) : null, t('im.saveTest')),
4328
+ 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'))))
4329
+ }
4330
+ const CSS_ID = (k) => String(k).replace(/[^\w-]/g, '_')
4331
+
4332
+ /** Add (pick platform → kind → form) or edit (form only) a channel. */
4333
+ function ChannelWizard({ t, initial, focusBot, onClose }) {
4334
+ const { catalog, remote, ready } = useCatalog(t)
4335
+ const editing = Boolean(initial?.id)
4336
+ const [platform, setPlatform] = useState(initial?.platform ?? null)
4337
+ const [type, setType] = useState(initial?.type ?? null)
4338
+ const p = platform ?? catalog[0]?.platform
4339
+ const found = type ? findKind(catalog, type) : null
4340
+ const step = type ? 'form' : 'pick'
4341
+ 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' },
4342
+ !ready ? h('div', { className: 'n2s-skel', style: { height: 220 } }) :
4343
+ step === 'pick' ? h(React.Fragment, null,
4344
+ h('div', { className: 'n2s-wiz-head' },
4345
+ h('div', { className: 'n2s-grow' }, h('div', { className: 'n2s-modal-title' }, t('ch.addTitle')), h('div', { className: 'n2s-muted n2s-small' }, t('ch.addSub'))),
4346
+ h('button', { type: 'button', className: 'n2s-iconbtn n2s-modal-x', 'aria-label': t('ds.cancel'), onClick: onClose }, Icon.close())),
4347
+ 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'))),
4348
+ catalog.length === 0 ? h('div', { className: 'n2s-empty' }, t('ch.noCatalog')) :
4349
+ h(KindPicker, { t, catalog, platform: p, onPlatform: setPlatform, onPick: (pl, k) => { setPlatform(pl); setType(k) } })) :
4350
+ !found ? h('div', null,
4351
+ h('div', { className: 'n2s-notice err' }, fmt(t, 'ch.unknownKind', { type })),
4352
+ h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } }, h('button', { type: 'button', className: 'n2s-btn', onClick: onClose }, t('ds.cancel')))) :
4353
+ h(ChannelForm, { key: type, t, catalog, remote, platform: found.platform.platform, kind: found.kind, initial: editing ? initial : null, focusBot, onBack: () => setType(null), onClose }))
4354
+ }
4355
+
4356
+ // ----------------------------------------------------------------- list
4357
+
4358
+ const shortTime = (ts) => new Date(ts).toLocaleString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
4359
+
4360
+ function ChannelRow({ t, c, catalog, bots, onEdit, act }) {
4361
+ const kind = findKind(catalog, c.type)?.kind
4362
+ const caps = c.capabilities ?? kind?.capabilities
4363
+ const target = targetSummary(t, c, kind)
4364
+ const bot = caps?.bot ? botOf(c, bots) : null
4365
+ return h('div', { className: 'n2s-list2-row', 'data-channel': c.id },
4366
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
4367
+ h('div', { className: 'n2s-row', style: { gap: 6 } },
4368
+ h('span', { className: 'n2s-list2-title' }, c.name),
4369
+ c.isDefault && h('span', { className: 'n2s-badge blue' }, t('im.default')),
4370
+ c.needsSecret && h('span', { className: 'n2s-badge warn', title: t('ds.needsSecretHint') }, t('ds.needsSecret')),
4371
+ c.secretsSet?.signSecret && h('span', { className: 'n2s-badge' }, t('im.signed'))),
4372
+ h('div', { className: 'n2s-list2-sub' }, kindLabel(t, c.type, catalog), target ? ' · ' : '', target && h('span', { className: target === t('im.webhookHidden') ? undefined : 'n2s-mono' }, target)),
4373
+ h('div', { className: 'n2s-row', style: { gap: 8, marginTop: 6 } },
4374
+ h(CapabilityChips, { t, caps, type: c.type }),
4375
+ bot && h(BotDot, { t, bot, prefix: t('ch.cap.bot') })),
4376
+ c.lastError && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)', whiteSpace: 'normal' } }, c.lastError)),
4377
+ h('div', { className: 'n2s-list2-side' },
4378
+ h('span', { className: `n2s-statusdot ${c.lastError ? 'err' : c.lastSentAt ? 'ok' : ''}` }, c.lastError ? t('im.lastFailed') : c.lastSentAt ? t('im.statusOk') : t('im.statusNew')),
4379
+ c.lastSentAt && h('span', { className: 'n2s-faint n2s-small' }, `${t('im.lastSent')} ${shortTime(c.lastSentAt)}`)),
4380
+ h('div', { className: 'n2s-row', style: { gap: 2, flexWrap: 'nowrap' } },
4381
+ h('button', { type: 'button', className: 'n2s-btn small', onClick: () => act(() => call('testChannel', { id: c.id }), t('ch.testOk')) }, t('im.test')),
4382
+ h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: c.name }), trigger: { content: Icon.more() }, items: [
4383
+ { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => onEdit(c, false) },
4384
+ !c.isDefault && { label: t('im.makeDefault'), icon: Icon.check(14), onClick: () => act(() => makeDefault(c), t('ch.defaultSet')) },
4385
+ caps?.bot && { label: t('ch.botSettings'), icon: Icon.spark(14), onClick: () => onEdit(c, true) },
4386
+ { divider: true },
4387
+ { 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')) } },
4388
+ ] })))
4389
+ }
4390
+
4391
+ /** Mark a channel as default; old hosts need the flat payload, new ones the config shape. */
4392
+ function makeDefault(c) {
4393
+ 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 })
4394
+ return call('saveChannel', { id: c.id, name: c.name, type: c.type, description: c.description, chartMode: c.chartMode, config: c.config, secrets: {}, isDefault: true })
4395
+ }
4396
+
4397
+ function DeliveryLog({ t, channels, deliveries }) {
4398
+ const [open, setOpen] = useState(false)
4399
+ const rows = deliveries ?? []
4400
+ if (!rows.length) return null
4401
+ const failed = rows.slice(0, 50).filter(d => d.status !== 'ok').length
4402
+ const platformOf = (d) => d.platform ?? channels.find(c => c.id === d.channelId)?.platform ?? channels.find(c => c.name === d.channelName)?.platform
4403
+ return h('div', { className: 'n2s-collapse' },
4404
+ h('button', { type: 'button', 'aria-expanded': open, 'aria-controls': 'n2s-deliveries', onClick: () => setOpen(v => !v) },
4405
+ 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)),
4406
+ open && h('div', { id: 'n2s-deliveries', className: 'n2s-table-wrap', style: { marginTop: 12 } }, h('table', { className: 'n2s-table' },
4407
+ 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))))),
4408
+ h('tbody', null, rows.slice(0, 50).map(d => h('tr', { key: d.id },
4409
+ h('td', { className: 'n2s-muted' }, shortTime(d.at)),
4410
+ 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)),
4411
+ h('td', { title: d.title }, d.title),
4412
+ h('td', { className: 'n2s-muted' }, tOpt(t, `im.src.${d.source}`) || d.source),
4413
+ h('td', { title: d.error ?? '' }, d.status === 'ok'
4414
+ ? h('span', { className: 'n2s-statusdot ok' }, [t('im.sentOk'), d.results ? fmt(t, 'im.resultsN', { count: d.results }) : ''].filter(Boolean).join(' · '))
4415
+ : h('span', { className: 'n2s-statusdot err' }, d.error ?? t('im.lastFailed')))))))))
3622
4416
  }
3623
4417
 
3624
4418
  function PushTab({ t }) {
3625
4419
  const channels = useChannels()
4420
+ const bots = useBots()
4421
+ const { catalog } = useCatalog(t)
3626
4422
  const deliveries = useAsync(() => call('listDeliveries'), [channels])
3627
4423
  const [editing, setEditing] = useState(null)
3628
- const [notice, setNotice] = useState(null)
4424
+ // Pick up host upgrades (catalog, new channel shape) whenever the page opens.
4425
+ useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
4426
+ useEffect(() => {
4427
+ const id = setInterval(() => { if (!document.hidden) refreshBots() }, 8000)
4428
+ return () => clearInterval(id)
4429
+ }, [])
4430
+ // Opened from the bot overview: jump straight into that channel's bot settings.
4431
+ const nav = useSyncExternalStore(channelNav.subscribe, channelNav.get, channelNav.get)
4432
+ useEffect(() => {
4433
+ if (!nav.edit) return
4434
+ const c = channels.find(x => x.id === nav.edit.id)
4435
+ if (c) { setEditing({ channel: c, focusBot: nav.edit.bot }); channelNav.set({ edit: null }) }
4436
+ }, [nav, channels])
3629
4437
  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 }) }
4438
+ 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
4439
  refreshChannels()
4440
+ refreshBots()
4441
+ }
4442
+ const groups = []
4443
+ for (const c of channels) {
4444
+ let g = groups.find(x => x.platform === c.platform)
4445
+ if (!g) groups.push(g = { platform: c.platform, items: [] })
4446
+ g.items.push(c)
3633
4447
  }
4448
+ const rank = (p) => { const i = catalog.findIndex(x => x.platform === p); return i < 0 ? 99 : i }
4449
+ groups.sort((a, b) => rank(a.platform) - rank(b.platform))
4450
+ const add = () => setEditing({ channel: null })
3634
4451
  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'))))))))))
4452
+ h(SectionHead, { title: t('admin.push'), desc: t('ch.desc') }, h('button', { type: 'button', className: 'n2s-btn primary', onClick: add }, '+ ', t('im.add'))),
4453
+ channels.length === 0
4454
+ ? h('div', { className: 'n2s-empty' },
4455
+ 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 }))),
4456
+ h('h3', null, t('im.empty')), h('div', null, t('ch.emptyHint')),
4457
+ h('button', { type: 'button', className: 'n2s-btn primary', onClick: add }, t('im.add')))
4458
+ : h('div', { className: 'n2s-pgroups' }, groups.map(g => h('section', { key: g.platform, className: 'n2s-pgroup', 'aria-label': platformName(t, g.platform, catalog) },
4459
+ 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)),
4460
+ 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 }) })))))),
4461
+ h(DeliveryLog, { t, channels, deliveries: deliveries.value }),
4462
+ editing && h(ChannelWizard, { key: editing.channel?.id ?? 'new', t, initial: editing.channel, focusBot: editing.focusBot, onClose: () => setEditing(null) }))
3669
4463
  }
3670
4464
 
3671
4465
  const HIDDEN_PIN = { hidden: true }
@@ -3916,7 +4710,7 @@ window.__ModuleLoader__.load({
3916
4710
  h('span', { className: 'n2s-faint n2s-small' }, t(`al.condHint.${c.type}`))),
3917
4711
  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
4712
  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)))),
4713
+ 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
4714
  h('label', { className: 'n2s-check', style: { alignSelf: 'end' } }, h('input', { type: 'checkbox', checked: form.mention, onChange: set('mention') }), t('al.mentionAll'))),
3921
4715
  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
4716
  error && h('div', { className: 'n2s-notice err', style: { marginTop: 10 } }, error),
@@ -4165,25 +4959,86 @@ window.__ModuleLoader__.load({
4165
4959
  h('div', { className: 'n2s-cards' }, group.map(card)))
4166
4960
  }))
4167
4961
  }
4168
- // ------------------------------------------------- members & lark bot
4962
+ // ------------------------------------------------------ members & bots
4963
+
4964
+ /** Identities of a member: the new array, or the legacy Lark fields. */
4965
+ function identitiesOf(u) {
4966
+ 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 } : {}) }))
4967
+ const out = []
4968
+ const id = u?.kind === 'chat' ? u?.larkChatId : u?.larkOpenId
4969
+ if (id || (u?.kind !== 'chat' && u?.email)) out.push({ platform: 'lark', id: id ?? '', ...(u?.kind !== 'chat' && u?.email ? { email: u.email } : {}) })
4970
+ return out
4971
+ }
4972
+
4973
+ /** Platforms members can be identified on: those with a bot-capable kind. */
4974
+ function identityPlatforms(catalog) {
4975
+ const list = catalog.filter(p => (p.kinds ?? []).some(k => k.capabilities?.bot)).map(p => p.platform)
4976
+ return list.length ? list : ['lark']
4977
+ }
4978
+
4979
+ function IdentityEditor({ t, kind, rows, onChange, catalog, errors }) {
4980
+ const platforms = identityPlatforms(catalog)
4981
+ const set = (i, patch) => onChange(rows.map((r, j) => j === i ? { ...r, ...patch } : r))
4982
+ const help = (p) => tOpt(t, `mb.idHelp.${p}.${kind}`)
4983
+ return h('div', { className: 'n2s-field full', role: 'group', 'aria-labelledby': 'n2s-ids-label' },
4984
+ h('span', { id: 'n2s-ids-label', className: 'n2s-flabel' }, kind === 'chat' ? t('mb.ids.chat') : t('mb.ids.person')),
4985
+ h('span', { className: 'n2s-faint n2s-small' }, kind === 'chat' ? t('mb.ids.chatHint') : t('mb.ids.personHint')),
4986
+ h('div', { className: 'n2s-idrows' }, rows.map((r, i) => h('div', { key: i, className: 'n2s-idrow', 'data-identity': i },
4987
+ h('div', { className: 'n2s-idrow-main' },
4988
+ h(PlatformMark, { t, platform: r.platform, size: 30 }),
4989
+ 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 }) },
4990
+ [...new Set([...platforms, r.platform])].map(p => h('option', { key: p, value: p }, platformName(t, p, catalog)))),
4991
+ 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 }) }),
4992
+ 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 }) }),
4993
+ 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())),
4994
+ 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))))),
4995
+ 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')))
4996
+ }
4997
+
4998
+ function IdentityChips({ t, u, catalog }) {
4999
+ const ids = identitiesOf(u)
5000
+ if (!ids.length) return h('span', { className: 'n2s-badge warn' }, t('mb.ids.none'))
5001
+ 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}` : ''}` },
5002
+ h(PlatformMark, { t, platform: x.platform, size: 16 }), h('span', { className: 'n2s-mono' }, x.id || x.email))))
5003
+ }
4169
5004
 
4170
5005
  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 ?? {}) }))
5006
+ const { catalog } = useCatalog(t)
5007
+ const [form, setForm] = useState(() => {
5008
+ const base = { kind: 'person', name: '', role: 'analyst', allDatasources: initial ? initial.datasourceIds === null : false, datasourceIds: [], note: '', ...(initial ?? {}) }
5009
+ const ids = identitiesOf(initial)
5010
+ return { ...base, identities: ids.length ? ids : [{ platform: 'lark', id: '', email: '' }] }
5011
+ })
4172
5012
  const [error, setError] = useState(null)
5013
+ const [idErrors, setIdErrors] = useState({})
4173
5014
  const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
4174
5015
  const ids = form.datasourceIds ?? []
4175
5016
  const rules = form.rowRules ?? []
4176
5017
  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) } }
5018
+ const save = async () => {
5019
+ setError(null)
5020
+ const identities = form.identities
5021
+ .map(r => ({ platform: r.platform, id: String(r.id ?? '').trim(), ...(form.kind === 'person' && String(r.email ?? '').trim() ? { email: String(r.email).trim() } : {}) }))
5022
+ // Rows left empty are dropped; at least one identity is required.
5023
+ const kept = identities.filter(r => r.id || r.email)
5024
+ if (!kept.length) { setIdErrors({ 0: t('mb.ids.needOne') }); return }
5025
+ setIdErrors({})
5026
+ // Legacy mirror for hosts that still read the Lark-only fields.
5027
+ const lark = kept.find(r => r.platform === 'lark' && r.id)
5028
+ const email = kept.find(r => r.email)?.email
5029
+ const legacy = form.kind === 'chat' ? { larkChatId: lark?.id ?? '' } : { larkOpenId: lark?.id ?? '', email: email ?? '' }
5030
+ const { identities: _i, larkOpenId: _o, larkChatId: _c, email: _e, ...rest } = form
5031
+ try {
5032
+ 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())) })
5033
+ onDone()
5034
+ } catch (e) { setError(e.message) }
5035
+ }
4178
5036
  return h('div', { className: 'n2s-card', style: { marginBottom: 12 } },
4179
5037
  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}`))))),
5038
+ 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
5039
  h('label', { className: 'n2s-field' }, h('span', null, t('im.name')), h('input', { className: 'n2s-input', value: form.name, onChange: set('name') })),
4182
5040
  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') })),
5041
+ h(IdentityEditor, { t, kind: form.kind, rows: form.identities, catalog, errors: idErrors, onChange: (rows) => { setForm(f => ({ ...f, identities: rows })); setIdErrors({}) } }),
4187
5042
  h('div', { className: 'n2s-field full' }, h('span', null, t('mb.scope')),
4188
5043
  h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.allDatasources, onChange: set('allDatasources') }), t('mb.allDs')),
4189
5044
  !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 +5052,69 @@ window.__ModuleLoader__.load({
4197
5052
  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
5053
  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
5054
  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),
5055
+ error && h('div', { className: 'n2s-notice err', role: 'alert', style: { marginTop: 10 } }, error),
4201
5056
  h('div', { className: 'n2s-row', style: { marginTop: 12, justifyContent: 'flex-end' } },
4202
5057
  h('button', { type: 'button', className: 'n2s-btn ghost', onClick: onCancel }, t('ds.cancel')),
4203
5058
  h('button', { type: 'button', className: 'n2s-btn primary', onClick: save }, t('ds.save'))))
4204
5059
  }
4205
5060
 
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))))))))))
5061
+ /** 「机器人」: every bot-capable channel with status, enable switch and recent conversations. */
5062
+ function BotsOverview({ t }) {
5063
+ const channels = useChannels()
5064
+ const bots = useBots()
5065
+ const { catalog } = useCatalog(t)
5066
+ const [busy, setBusy] = useState(null)
5067
+ const [open, setOpen] = useState(null)
5068
+ useEffect(() => { loadCatalog(); refreshChannels(); refreshBots() }, [])
5069
+ useEffect(() => {
5070
+ const id = setInterval(() => { if (!document.hidden) refreshBots() }, 5000)
5071
+ return () => clearInterval(id)
5072
+ }, [])
5073
+ const capable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.bot)
5074
+ const toggle = async (c, enabled) => {
5075
+ setBusy(c.id)
5076
+ 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) }
5077
+ refreshChannels()
5078
+ setBusy(null)
5079
+ }
5080
+ const restart = async (c) => {
5081
+ try {
5082
+ if (bots.legacy) await call('restartLarkBot'); else await call('restartBot', { channelId: c.id })
5083
+ await refreshBots()
5084
+ } catch (e) { notifyError(e) }
5085
+ }
5086
+ return h('div', { className: 'n2s-section', style: { marginTop: 0, marginBottom: 24 } },
5087
+ h('div', { className: 'n2s-row', style: { marginBottom: 10 } },
5088
+ h('h2', { className: 'n2s-h2', style: { margin: 0 } }, t('ch.bots')),
5089
+ h('span', { className: 'n2s-faint n2s-small n2s-grow' }, t('ch.botsHint'))),
5090
+ bots.error && h('div', { className: 'n2s-notice err', style: { marginBottom: 8 } }, bots.error),
5091
+ capable.length === 0
5092
+ ? h('div', { className: 'n2s-empty', style: { padding: '24px 20px' } },
5093
+ h('h3', null, t('ch.bots.empty')), h('div', null, t('ch.bots.emptyHint')),
5094
+ h('button', { type: 'button', className: 'n2s-btn', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'push' }) }, t('ch.bots.goAdd')))
5095
+ : h('div', { className: 'n2s-list2' }, capable.map(c => {
5096
+ const b = botOf(c, bots)
5097
+ const convs = b.conversations ?? []
5098
+ const expanded = open === c.id
5099
+ return h('div', { key: c.id, className: 'n2s-botrow', 'data-bot': c.id },
5100
+ h('div', { className: 'n2s-list2-row' },
5101
+ h(PlatformMark, { t, platform: c.platform }),
5102
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5103
+ 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 })),
5104
+ 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')}` : ''}`),
5105
+ b.enabled && b.status?.error && h('div', { className: 'n2s-list2-sub', style: { color: 'var(--n2s-err)', whiteSpace: 'normal' } }, b.status.error)),
5106
+ h('div', { className: 'n2s-row', style: { gap: 4, flexWrap: 'nowrap' } },
5107
+ 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)),
5108
+ b.enabled && botStateOf(b) === 'error' && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => restart(c) }, Icon.refresh(12), t('ch.restart')),
5109
+ h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => openChannelEditor(c.id, true) }, t('ch.botSettings')),
5110
+ 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) })))),
5111
+ expanded && h('div', { style: { padding: '0 16px 14px' } }, h('div', { className: 'n2s-table-wrap' }, h('table', { className: 'n2s-table' },
5112
+ 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)))),
5113
+ h('tbody', null, convs.slice(0, 20).map(cv => h('tr', { key: cv.key },
5114
+ 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 ?? ''),
5115
+ h('td', { className: 'n2s-muted' }, cv.lastAt ? shortTime(cv.lastAt) : ''),
5116
+ 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))))))))))
5117
+ })))
4238
5118
  }
4239
5119
 
4240
5120
  function MembersTab({ t, datasources }) {
@@ -4242,14 +5122,15 @@ window.__ModuleLoader__.load({
4242
5122
  const usage = useAsync(() => call('usageByActor', { days: 30 }), [])
4243
5123
  const settings = useAsync(() => call('getSettings'), [])
4244
5124
  const [editing, setEditing] = useState(null)
5125
+ const { catalog } = useCatalog(t)
4245
5126
  const list = users.value ?? []
4246
5127
  const usageById = new Map((usage.value ?? []).map(u => [u.actorId, u]))
4247
5128
  return h('div', null,
4248
5129
  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 }),
5130
+ h(BotsOverview, { t }),
4250
5131
  editing && h(UserForm, { key: editing.id ?? 'new', t, datasources, initial: editing.id ? editing : null, onCancel: () => setEditing(null), onDone: () => { setEditing(null); users.reload() } }),
4251
5132
  settings.value && h('div', { className: 'n2s-setnote', style: { marginBottom: 10 } },
4252
- t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
5133
+ t('mb.default'), ' ', h('strong', null, t(`mb.defaultRole.${settings.value.botDefaultRole ?? settings.value.larkDefaultRole ?? 'none'}`)), ' · ',
4253
5134
  h('button', { type: 'button', className: 'n2s-link', onClick: () => fbCtx.navigate?.({ tab: 'admin', section: 'settings' }) }, t('set.changeInSettings'))),
4254
5135
  list.length === 0 && !editing ? h('div', { className: 'n2s-empty' }, h('h3', null, t('mb.empty')), h('div', null, t('mb.emptyHint'))) :
4255
5136
  h('div', { className: 'n2s-list2' }, list.map(u => {
@@ -4258,10 +5139,10 @@ window.__ModuleLoader__.load({
4258
5139
  h('span', { className: 'n2s-tile', 'aria-hidden': true }, u.kind === 'chat' ? t('mb.chatShort') : (u.name || '?').slice(0, 1)),
4259
5140
  h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
4260
5141
  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')}`),
5142
+ 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
5143
  (u.rowRules ?? []).length > 0 && h('div', { className: 'n2s-list2-sub' }, `${t('mb.rowRules')}:${u.rowRules.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}`)),
4263
5144
  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: [
5145
+ h(Dropdown, { label: fmt(t, 'ch.actionsFor', { name: u.name }), trigger: { content: Icon.more() }, items: [
4265
5146
  { label: t('ds.edit'), icon: Icon.edit(14), onClick: () => setEditing(u) },
4266
5147
  { 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
5148
  { divider: true },
@@ -4302,8 +5183,7 @@ window.__ModuleLoader__.load({
4302
5183
  }
4303
5184
 
4304
5185
  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)))
5186
+ return h('div', { style: { maxWidth: 380 } }, h(ChannelPicker, { t, value, onChange, noneLabel: t('rep.pushNone'), label: t('set.l.notify') }))
4307
5187
  }
4308
5188
 
4309
5189
  // Questions started from the page bind their new session to the chosen
@@ -4589,7 +5469,7 @@ window.__ModuleLoader__.load({
4589
5469
  privacy: ['sampleMode', 'resultToModel', 'modelRows', 'maskSensitive'],
4590
5470
  query: ['maxRows', 'timeoutMs', 'scanGuardRows', 'cacheTtlSec', 'schemaOverviewMax'],
4591
5471
  automation: ['schemaWatch', 'schemaCheckDaily', 'schemaNotifyChannel'],
4592
- lark: ['larkDefaultRole'],
5472
+ lark: ['botDefaultRole', 'larkDefaultRole'],
4593
5473
  storage: ['retention'],
4594
5474
  about: [],
4595
5475
  }
@@ -4655,6 +5535,8 @@ window.__ModuleLoader__.load({
4655
5535
  h('input', { type: 'radio', name: key, checked: form[key] === v, onChange: () => set(key, v) }),
4656
5536
  h('span', null, h('span', { className: 'title' }, t(`${prefix}.${v}`)), h('span', { className: 'desc' }, t(`${prefix}.${v}.desc`))))))
4657
5537
  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 })
5538
+ // New hosts expose botDefaultRole (larkDefaultRole stays as an alias); old ones only larkDefaultRole.
5539
+ const roleKey = 'botDefaultRole' in base ? 'botDefaultRole' : 'larkDefaultRole'
4658
5540
  const overrides = datasources.filter(d => d.privacy && Object.keys(d.privacy).length)
4659
5541
  const body =
4660
5542
  group === 'privacy' ? h(React.Fragment, null,
@@ -4679,8 +5561,9 @@ window.__ModuleLoader__.load({
4679
5561
  form.schemaCheckDaily && h('button', { type: 'button', className: 'n2s-btn small ghost', onClick: () => set('schemaCheckDaily', '') }, t('set.turnOff')))),
4680
5562
  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
5563
  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'))) :
5564
+ h(SettingRow, { label: t('set.defaultChannel'), hint: t('set.h.defaultChannel'), htmlFor: 'set-defchannel' }, h(DefaultChannelControl, { t, onOpenPush: () => onNavigate({ section: 'push' }) })),
5565
+ h(SettingRow, { label: t('set.l.unknownUser'), hint: t('set.h.botDefaultRole'), changed: changed(roleKey) }, radio(roleKey, ['none', 'viewer', 'analyst'], 'mb.defaultRole')),
5566
+ h(BotSummary, { t, onOpenMembers: () => onNavigate({ section: 'members' }), onOpenPush: () => onNavigate({ section: 'push' }) })) :
4684
5567
  group === 'storage' ? h(StorageSection, { t, form, set, changed, defaults }) :
4685
5568
  h(AboutSection, { t })
4686
5569
  const canReset = GROUP_KEYS[group].length > 0
@@ -4713,20 +5596,49 @@ window.__ModuleLoader__.load({
4713
5596
  h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpen }, t('set.openAlerts')))
4714
5597
  }
4715
5598
 
4716
- function LarkSummaryCard({ t, onOpenMembers, onOpenPush }) {
4717
- const bot = useAsync(() => call('larkBotStatus').catch(() => null), [])
5599
+ /** Default channel: applied immediately (it is a channel property, not a setting). */
5600
+ function DefaultChannelControl({ t, onOpenPush }) {
4718
5601
  const channels = useChannels()
4719
5602
  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'))))
5603
+ 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')))
5604
+ return h('div', { style: { maxWidth: 380 } }, h(ChannelPicker, {
5605
+ t, id: 'set-defchannel', value: def?.id ?? '', noneLabel: def ? undefined : t('set.noDefaultChannel'),
5606
+ onChange: async (id) => {
5607
+ const c = channels.find(x => x.id === id)
5608
+ if (!c) return
5609
+ try { await makeDefault(c); notify(t('ch.defaultSet'), 'ok') } catch (e) { notifyError(e) }
5610
+ refreshChannels()
5611
+ },
5612
+ }))
5613
+ }
5614
+
5615
+ /** Bots per platform: how many are on and whether any is failing. */
5616
+ function BotSummary({ t, onOpenMembers, onOpenPush }) {
5617
+ const channels = useChannels()
5618
+ const bots = useBots()
5619
+ const { catalog } = useCatalog(t)
5620
+ const capable = channels.filter(c => (c.capabilities ?? findKind(catalog, c.type)?.kind?.capabilities)?.bot)
5621
+ const byPlatform = []
5622
+ for (const c of capable) {
5623
+ let g = byPlatform.find(x => x.platform === c.platform)
5624
+ if (!g) byPlatform.push(g = { platform: c.platform, total: 0, on: 0, err: 0, ok: 0 })
5625
+ const b = botOf(c, bots)
5626
+ const s = botStateOf(b)
5627
+ g.total++
5628
+ if (b.enabled) g.on++
5629
+ if (s === 'error') g.err++
5630
+ if (s === 'connected') g.ok++
5631
+ }
5632
+ return h(React.Fragment, null,
5633
+ h('div', { className: 'n2s-setsub' }, t('ch.bots')),
5634
+ byPlatform.length === 0
5635
+ ? h('div', { className: 'n2s-setnote' }, t('ch.bots.emptyHint'), ' ', h('button', { type: 'button', className: 'n2s-link', onClick: onOpenPush }, t('ch.bots.goAdd')))
5636
+ : h('div', { className: 'n2s-infogrid' }, byPlatform.map(g => h('div', { key: g.platform, className: 'n2s-infocard' },
5637
+ h(PlatformMark, { t, platform: g.platform, size: 28 }),
5638
+ h('div', { className: 'n2s-grow', style: { minWidth: 0 } },
5639
+ h('div', { className: 'n2s-small', style: { fontWeight: 500 } }, platformName(t, g.platform, catalog)),
5640
+ 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 }))),
5641
+ h('button', { type: 'button', className: 'n2s-btn small', onClick: onOpenMembers }, t('set.configure'))))))
4730
5642
  }
4731
5643
 
4732
5644
  const RETENTION_KINDS = ['history', 'feedback', 'delivery', 'alertEvent', 'admin', 'evalRun', 'schemaChange']