@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/CHANGELOG.md +22 -2
- package/README.en.md +2 -1
- package/README.md +2 -2
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +3 -3
- package/docs/CHANNELS.md +184 -0
- package/im.js +6 -509
- package/index.js +25 -17
- package/lark-bot.js +7 -604
- package/lib/client.js +1097 -185
- package/package.json +14 -9
- package/service/access.js +116 -22
- package/service/admin.js +29 -13
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/datasources.js +31 -8
- package/service/push.js +5 -156
- package/service/shared.js +1 -1
- package/service/lark-actions.js +0 -223
- package/service/lark.js +0 -250
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':
|
|
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':
|
|
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': "
|
|
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
|
|
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': "
|
|
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': "
|
|
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
|
|
1247
|
+
'set.defaultChannel': "Default channel",
|
|
1046
1248
|
'set.noDefaultChannel': "No default channel",
|
|
1047
|
-
'set.noChannels': "No
|
|
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':
|
|
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':
|
|
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':
|
|
1174
|
-
'im.noChannel':
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
3142
|
-
|
|
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 {
|
|
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('
|
|
3554
|
-
h(
|
|
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
|
-
|
|
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
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
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
|
|
3581
|
-
|
|
3582
|
-
if (
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
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
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
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
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
h('div', { className: 'n2s-
|
|
3642
|
-
h('div', { className: 'n2s-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
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('
|
|
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
|
-
//
|
|
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
|
|
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 () => {
|
|
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',
|
|
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
|
|
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
|
-
|
|
4207
|
-
|
|
4208
|
-
const channels = useChannels()
|
|
4209
|
-
const
|
|
4210
|
-
|
|
4211
|
-
const
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
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(
|
|
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-
|
|
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
|
|
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
|
-
|
|
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(
|
|
4683
|
-
h(SettingRow, { label: t('set.l.
|
|
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
|
-
|
|
4717
|
-
|
|
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
|
-
|
|
4721
|
-
return h('div', {
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
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']
|