feat: 发布 0.6.2,通知渠道照搬 EasySub、使用场景改为全局设置、识别顺序调整(debug12)

- 通知:14 种渠道与 EasySub 字段 / 实现一致(Telegram 代理与 getMe/getUpdates、飞书应用机器人、
  QQ 机器人、企业微信自建应用 + API 代理 + 检查应用、Webhook 超时与重试),旧配置自动迁移
- 场景:家庭 / 企业,企业称“员工 / 部门”;在 系统设置 → 场景与网络 全局切换,本地缓存避免菜单闪错
- 识别:国内域名列表先于官方 IP 段;IP 段重叠按最长前缀匹配;自动更新开关覆盖 ASN 与补充库

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Su YijunandClaude Opus 5.5 committed 2026-10-24 01:21:45 +08:00
1 parent 965ccc59fc
commit 2585d15d53
23 files changed
+1285 -418

No files matched your search

+28
View File
@@ -1,5 +1,33 @@
# Changelog
## [0.6.2] - 2026-10-24
通知渠道照搬 EasySub、使用场景重做、识别顺序调整(debug12)
通知渠道(与 EasySub 完全一致)
- 14 种渠道,磁贴顺序、字段名、发送实现都和 EasySub 相同:Telegram Bot、飞书 Bot、QQ Bot、Bark、Email、Pushplus、Server酱、企业微信、钉钉、Discord、Slack、ntfy、Gotify、Webhook。
- **Telegram**:新增 ADMIN ID、HTTP 代理;API 反代可以不带 https://;新增「验证机器人」(getMe)和「获取 Chat ID」(getUpdates,自动填入最近一条消息的会话)。
- **飞书**改为应用机器人(App ID + App Secret + 多个 Chat ID),和 EasySub 一样。**旧的飞书群机器人 Webhook 配置需要重新填写。**
- **新增 QQ 机器人**:App ID / Secret,可同时推送多个群聊和私聊 OpenID。
- **企业微信**:可选「群机器人」或「自建应用」。自建应用填企业ID、AgentId、Secret,可设 API 代理(应对企业可信 IP 限制),按成员 / 部门 / 标签推送,消息类型支持文本、Markdown、文本卡片(可点击跳转);新增「检查应用」(返回应用名和可见范围)、access_token 缓存和失效自动重取、接收人无效时报错,以及可展开的配置说明(含 Nginx / socat 反代示例)。
- Bark 新增图标;Email 改为 SSL/TLS 开关(关闭时自动尝试 STARTTLS);Pushplus 可选渠道(微信 / 邮件 / Webhook / 企业微信);Webhook 新增请求超时(毫秒)和最大重试次数,模板支持 event 变量。
- 0.6.1 及更早保存的配置自动迁移(邮件加密方式、发件人、Webhook 重试次数)。免打扰、规则选择渠道、上次发送结果保留不变。
使用场景
- 两个场景命名为 **家庭** 和 **企业**。企业场景下菜单、页面、按钮统一称“员工 / 部门”(之前是“人员”),家庭场景是“家庭成员 / 分组”。
- 场景改为**全局设置**,在 **系统设置 → 场景与网络** 切换(原来藏在人员页的分析设置里);切换后菜单、人员页、审计模板立即跟着变。侧边栏底部和人员、审计页头显示当前场景。
- 浏览器记住上次的场景:企业场景刷新页面时,菜单不会再先闪出“家庭成员”。
- 删除还有人的分组 / 部门时,提示语按场景用“成员 / 分组”或“员工 / 部门”。
识别顺序与自动更新
- **有域名时先查完所有域名库再按 IP**:国内域名列表移到官方 IP 段之前。国内网站托管在 Cloudflare / AWS 上时归「国内网站」,而不是 IP 所属的 CDN。
- **IP 段重叠按最长前缀匹配**:AWS 中国区段落在国内 IP 段里、GitHub 段落在 AWS 段里时,更具体的小段优先;修复了小段之外、仍属于大段的地址查不到的问题。
- 「识别库」面板按匹配顺序编号,并写明完整的匹配顺序。
- 「自动更新」开关现在同时控制 ASN 库和补充库(国内域名列表、官方 IP 段)的定期下载;补充库每 6 小时检查一次、满一天才重新下载(之前 24 小时一次,可能因几秒误差拖到两天)。
## [0.6.1] - 2026-10-24
识别率(未识别占比较高,补充更强的识别数据源)
+3 -2
View File
@@ -1,6 +1,6 @@
# ROS-DPI
RouterOS 终端流量、应用统计与上网审计面板。按 今日 / 昨日 / 本周 / 本月 统计每台终端、每个人的流量和应用使用情况(类似 Panabit / 维盟),记录访问日志并按规则告警(参照深信服 AC);分家用和企业两种场景。
RouterOS 终端流量、应用统计与上网审计面板。按 今日 / 昨日 / 本周 / 本月 统计每台终端、每个人的流量和应用使用情况(类似 Panabit / 维盟),记录访问日志并按规则告警(参照深信服 AC);分家庭和企业两种场景。
Per-device and per-person traffic, per-application (DPI-style) statistics and access auditing for MikroTik RouterOS, built on IPFIX + DNS / SNI sniffing.
@@ -8,6 +8,7 @@ Source & docs: https://github.com/suyijun8182/ros-dpi
## What's new
- **0.6.2**: notification channels now match EasySub exactly — 14 channels with the same fields: Telegram (API proxy, HTTP proxy, check bot / get Chat ID), Feishu app bot, new QQ bot, Bark icon, email SSL switch, Pushplus channel, WeCom group robot **or self-built app** (CorpID / AgentId / Secret, API proxy, user / department / tag, text card, "check app"), webhook timeout and retries. The scenario (Home / Business) is now a global setting under **Settings → Scenario & network**; in Business mode the menu says "Employees" instead of "Family members", and the last scenario is cached so the menu no longer flashes the wrong label. Identification order: domain fallbacks (China domain list) are tried before IP-range fallbacks, and overlapping IP ranges use longest-prefix match (before, a small range inside a big one could hide the big one). The "Auto update" switch now also controls the ASN and fallback list downloads. Note: Feishu now uses an app bot (App ID / Secret / Chat IDs) like EasySub; an old Feishu group-webhook config has to be re-entered.
- **0.6.1**: much higher identification rate. New fallback sources, all downloaded at runtime and auto-updated daily: the dnsmasq-china-list Chinese domain list (~110k domains, WTFPL) catches long-tail .cn domains as "Chinese websites" (sampling showed v2fly + built-in rules only matched 5.6% of them); official Google / Cloudflare / AWS / GitHub IP ranges plus Apple's 17.0.0.0/8 attribute domain-less traffic (DoH, ECH, raw IPs) to the owning company; China IP ranges (chnroutes2, CC-BY-SA 4.0) group domain-less domestic traffic as "China networks". Purely additive — existing rules always take priority. Disable with `CHINA_DB=false` / `IPLIST_DB=false`.
- **0.6.0**: reliability & hardening. A failed database write no longer loses that minute's traffic and access log (kept in memory and retried, giving up only after 2 hours); retention cleanup deletes in batches of 5000 rows instead of one long transaction, and `domain_geo` is now covered. Security: login rate limiting (5 failures locks the IP+username for 15 min), tokens are revoked immediately when a password or role changes, setup race protected, 500 errors no longer echo exception details. Performance: destination ranking aggregated in SQL (new `base` column for merging by registered domain, auto-backfilled), the people page scans the access log once instead of twice, the overview reuses today's app ranking. Frontend: polling race fixed (stale responses can no longer overwrite fresh data) and a dozen loads that could spin forever on error now show the error. CI builds the image on PRs; the container process runs as a non-root user; the bundled-MySQL compose now requires real passwords.
- **0.5.1**: people must belong to a group / department (no duplicates within a group; groups with members can't be deleted), and existing people can be edited and deleted again. Countries are shown on a flow map from your location with a ranked list; a domain's country is now stored when traffic is recorded, so it no longer turns "unlocated" after a restart. README brought up to date.
@@ -90,7 +91,7 @@ Open `http://<host>:8080`. The setup wizard has two steps. 打开网页后按两
- Change database later: delete `./data/db_config.json` and restart. 更换数据库:删除该文件后重启容器。
- Setting `DATABASE_URL` skips the wizard. 设置环境变量 `DATABASE_URL` 可跳过向导。
- RouterOS commands: **Settings → Setup & check / 系统设置 → 接入与自检**. Full guide: https://github.com/suyijun8182/ros-dpi
- Then choose the scenario under **People → Settings**, assign devices to people, and set up notifications under **Audit → Notifications**. 之后在 家庭成员 / 人员 → 分析设置 选择场景,把设备分给成员,在 审计日志 → 通知渠道 配置推送。
- Then choose the scenario under **Settings → Scenario & network**, assign devices to people, and set up notifications under **Audit → Notifications**. 之后在 系统设置 → 场景与网络 选择场景(家庭 / 企业),把设备分给成员或员工,在 审计日志 → 通知渠道 配置推送。
## Ports
+8 -8
View File
@@ -1,6 +1,6 @@
# ROS-DPI
**RouterOS 终端流量、应用统计与上网审计面板**:像 Panabit / 维盟那样,按 今日 / 昨日 / 本周 / 本月 统计 RouterOS 下每台终端、每个人用了多少流量、用在了哪些应用上;像深信服 AC 那样记录访问日志、按规则告警。支持家用(孩子上网健康)和企业(效率与合规)两种场景。
**RouterOS 终端流量、应用统计与上网审计面板**:像 Panabit / 维盟那样,按 今日 / 昨日 / 本周 / 本月 统计 RouterOS 下每台终端、每个人用了多少流量、用在了哪些应用上;像深信服 AC 那样记录访问日志、按规则告警。支持家庭(孩子上网健康)和企业(效率与合规)两种场景。
[中文](#中文) · [English](#english)
@@ -16,13 +16,13 @@
- **主机监控**:在线终端 / 全部终端 / DHCP 租约 / 命名管理四个标签。每台终端的类型(手机、电脑、电视、摄像头……自动识别)、IP、MAC、主机名、归属人、在线时长、连接数、当前速率、今日 / 本周 / 本月流量,可导出 CSV;可以按 MAC 或按 IP 给设备命名。详情页有应用排行与构成、按大类的趋势、访问过的域名、访问日志和上网时间线。
- **应用统计**:参照 Panabit / 维盟分三级(应用大类 → 小类 → 应用,逐级“查看详情”下钻)。大类和小类固定列出,每级显示上传 / 下载速度和数据及占比;应用构成用矩形树图一眼看两层。共 10 个大类、43 个小类、2800 多个内置应用(抖音、B 站、微信视频号、Steam、银行、券商、ToDesk、DeepSeek……,其中约 2500 个来自 v2fly 开源域名库)。识别不出具体应用的流量,会按 CDN 的 CNAME 归到阿里云 / 腾讯云 / Akamai 等厂商。
- **流量流向**:**实时连接**(此刻哪台终端连到哪个域名 / IP、什么应用、多快;走 sing-box 的连接显示策略组和出口节点)、**访问目标排行**(流量最大的远端域名和 IP;流向图从本地飞向各国,看流量去了哪些国家 / 地区;可按主域名合并,看是哪些终端在访问)、**代理分流**(sing-box 各策略组、各节点的流量和主要应用)。
- **家庭成员 / 人员**:把一个人的手机、平板、电脑归到一起,按人统计流量、在线时长、常用应用,以及最近 7 天的行为标签(附依据)。按使用场景:
- **家用**:关注孩子的上网健康,包括每日屏幕时间、游戏和短视频时长上限,熬夜,不良内容,学习时间玩娱乐应用。
- **企业**:关注效率与合规,包括工作时间娱乐占比、求职倾向、上班炒股,按部门统计。
- **家庭成员 / 员工**:把一个人的手机、平板、电脑归到一起,按人统计流量、在线时长、常用应用,以及最近 7 天的行为标签(附依据)。使用场景在 **系统设置 → 场景与网络** 全局切换,菜单、称呼、默认时间段、标签和审计模板跟着变:
- **家庭**:菜单显示“家庭成员”,按分组管理;关注孩子的上网健康,包括每日屏幕时间、游戏和短视频时长上限,熬夜,不良内容,学习时间玩娱乐应用。
- **企业**:菜单显示“员工”,按部门管理;关注效率与合规,包括工作时间娱乐占比、求职倾向、上班炒股,按部门统计。
- **审计日志**:
- **访问日志**:每台终端什么时候访问了什么、多久、多少流量,可按人员 / 部门 / 关键字筛选,导出 CSV,默认保留 30 天。
- **审计规则**:按应用、域名、时段、流量、**大流量外发**、**私接路由**、新设备设置,可限定人员 / 部门,带按场景的常用模板。命中后推送通知。
- **通知渠道**:企业微信、钉钉、飞书、Bark、Telegram、邮件、PushPlus、Server酱、ntfy、Gotify、Discord、Slack、通用 Webhook。每条规则可以选渠道,可设免打扰。
- **通知渠道**(与 EasySub 相同的 14 种):Telegram Bot(可填 API 反代、HTTP 代理,一键获取 Chat ID)、飞书 Bot、QQ Bot、Bark、Email、Pushplus、Server酱、企业微信(群机器人或自建应用,支持 API 代理、指定成员 / 部门 / 标签、文本卡片)、钉钉、Discord、Slack、ntfy、Gotify、通用 Webhook。每条规则可以选渠道,可设免打扰。
- **私接检测**:根据数据包 TTL 发现后面接了路由器或多种系统共享的设备。
- **日报 / 周报**和**操作日志**。
- **管控命令**:在人员或终端详情里,按应用、时段生成 RouterOS 命令(封禁应用、定时断网、限速),附撤销命令。ROS-DPI 只生成命令,不会修改路由器。
@@ -151,7 +151,7 @@ MySQL 第一次初始化需要 20~60 秒。
接下来可以:
1. 在 **家庭成员 / 人员 → 分析设置** 选择使用场景(家用 / 企业),设置学习或工作时间、每日时长上限;
1. 在 **系统设置 → 场景与网络** 选择使用场景(家庭 / 企业),再到 **家庭成员 / 员工 → 分析设置** 设置学习或工作时间、每日时长上限;
2. 添加成员并把设备分给他们;
3. 在 **审计日志 → 通知渠道** 配置推送,在 **审计规则** 里从模板新建规则;
4. 需要只读账号时,在 **系统设置 → 用户管理** 添加普通用户。
@@ -261,13 +261,13 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5
- **Hosts**: every device with its detected type (phone, computer, TV, camera…), IP, MAC, hostname, owner, online state, live rate and daily / weekly / monthly totals. Name devices by MAC or by IP. Each device has an app breakdown, visited domains, an access log and a daily timeline.
- **Applications**: a three-level drill-down (app group → category → app) with fixed groups and categories, upload/download rates and totals with shares at each level, and a treemap of the mix. It covers 10 groups, 43 categories and 2800+ built-in apps (about 2500 converted from v2fly/domain-list-community). Traffic that can't be tied to an app is attributed to its CDN via CNAME.
- **Flows**: live connections (sing-box connections show their policy group and outbound node), top destinations with a flow map from your location to each country, and proxy routing (traffic and top apps per sing-box policy group and node).
- **People / Family**: group each person's devices and see their traffic, online time, top apps and behaviour tags for the last 7 days, with the evidence behind each tag.
- **Family members / Employees**: pick the Home or Business scenario under **Settings → Scenario & network**; menus, wording, default hours, tags and audit templates follow it. Group each person's devices and see their traffic, online time, top apps and behaviour tags for the last 7 days, with the evidence behind each tag.
- **Home**: daily screen-time, gaming and short-video limits, late nights, adult content, and leisure during homework time.
- **Business**: leisure share in working hours, job hunting and trading at work, with departments.
- **Audit**:
- **Access log**: filter by person, department or keyword, export to CSV; kept 30 days by default.
- **Audit rules**: by app, domain, time window, traffic, **large upload**, **rogue router** or new device; can be scoped to people or departments, with scenario-specific templates.
- **Notifications**: WeCom, DingTalk, Feishu, Bark, Telegram, email, PushPlus, ServerChan, ntfy, Gotify, Discord, Slack or webhooks, with per-rule channel choice and quiet hours.
- **Notifications** (the same 14 channels as EasySub): Telegram bot (API proxy, HTTP proxy, one-click Chat ID), Feishu bot, QQ bot, Bark, email, Pushplus, ServerChan, WeCom (group robot or self-built app with API proxy, user / department / tag targets and text cards), DingTalk, Discord, Slack, ntfy, Gotify or webhooks, with per-rule channel choice and quiet hours.
- **Shared-access check**: TTL-based detection of devices with a router behind them.
- **Daily / weekly reports** and an **operation log**.
- **Control commands**: generate RouterOS commands that block apps, block internet on a schedule or set rate limits for a person or device, with undo commands. ROS-DPI never writes to the router.
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.6.1"
__version__ = "0.6.2"
+6 -6
View File
@@ -2,7 +2,8 @@
分三级:应用大类(group)→ 应用小类(category)→ 应用(app),与 Panabit 的应用分类方式一致。
优先级:自定义规则 > 域名(先查询名再 CNAME;精确匹配 > 最长后缀)> CIDR > 专用端口 > IP 所属 ASN
> 官方 IP 段 / 国内域名列表(app/classify/sources.py,兜底)> 通用端口(HTTPS 等)> IP 协议号 > other。
> 国内域名列表 > 官方 IP 段(最长前缀优先)/ 国内 IP 段(app/classify/sources.py,兜底)
> 通用端口(HTTPS 等)> IP 协议号 > other。
内置规则两份:手工维护的 apps.yaml,以及从 v2fly/domain-list-community 生成的 apps_v2fly.yaml(同一域名以前者为准)。
"""
import ipaddress
@@ -183,13 +184,12 @@ class Classifier:
if hit:
app = self.asn_apps.get(hit[0])
if app is None:
# 官方 IP 段兜底:完全没有域名的流量(加密 DNS、ECH、直连 IP)按 Google/Cloudflare/Apple/国内段 认归属
from app.classify.sources import sources as extra
app = extra.ip_app(remote_ip)
if app is None:
# 国内域名兜底:域名规则没认出、但在国内域名列表里 → 国内网站(好于落到 HTTPS/其他)
from app.classify.sources import sources as extra
# 先按域名兜底、再按 IP:有域名时域名更准(国内网站托管在 Cloudflare/AWS 上时,IP 只能说明是哪家 CDN)
# 国内域名列表:域名规则没认出、但在列表里 → 国内网站
app = extra.fallback_domain(qname) or extra.fallback_domain(cname)
# 官方 IP 段:完全没有域名的流量(加密 DNS、ECH、直连 IP)按 Google/Cloudflare/AWS/Apple/国内段 认归属
app = app or extra.ip_app(remote_ip)
if app is None:
app = generic or self.protos.get(proto)
app = app or UNKNOWN_APP
+39 -13
View File
@@ -11,6 +11,7 @@
都是运行时下载到数据目录(不进镜像)、每天检查一次更新;单个来源失败不影响其它来源。
环境变量 CHINA_DB / IPLIST_DB 可分别关闭(默认开)。
"""
import heapq
import ipaddress
import json
import os
@@ -62,11 +63,16 @@ def _parse_github(text: str) -> list[str]:
class _Ranges:
"""CIDR → 排序区间数组(bisect 查找)。重叠时先加入的赢:官方段在前,国内段在后。"""
"""CIDR → 不重叠的排序区间数组(bisect 查找)。
段之间会重叠(AWS 中国区的段落在 chnroutes 的大段里、GitHub 的段落在 AWS 段里),
和路由表一样按最长前缀匹配:更小(更具体)的段赢,一样大时先加入的赢。
"""
def __init__(self):
self._v4 = (array("Q"), array("Q"), [])
self._v6 = ([], [], [])
self._raw = {4: [], 6: []}
self.count = 0
def add(self, cidr: str, app: str):
@@ -74,22 +80,42 @@ class _Ranges:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError:
return False
t = self._v4 if net.version == 4 else self._v6
t[0].append(int(net.network_address))
t[1].append(int(net.broadcast_address))
t[2].append(app)
raw = self._raw[net.version]
raw.append((int(net.network_address), int(net.broadcast_address), net.num_addresses, len(raw), app))
self.count += 1
return True
@staticmethod
def _flatten(raw: list) -> list[tuple[int, int, str]]:
"""扫描线:在每个边界点之间取覆盖它的优先级最高的段,再合并相邻同归属的区间。"""
cuts = sorted({x for lo, hi, *_ in raw for x in (lo, hi + 1)})
by_lo = sorted(raw)
heap, out, j = [], [], 0
for a, b in zip(cuts, cuts[1:]):
while j < len(by_lo) and by_lo[j][0] <= a:
lo, hi, size, idx, app = by_lo[j]
heapq.heappush(heap, (size, idx, hi, app))
j += 1
while heap and heap[0][2] < a:
heapq.heappop(heap)
if not heap:
continue
app = heap[0][3]
if out and out[-1][2] == app and out[-1][1] == a - 1:
out[-1] = (out[-1][0], b - 1, app)
else:
out.append((a, b - 1, app))
return out
def build(self):
for t in (self._v4, self._v6):
order = sorted(range(len(t[0])), key=lambda i: t[0][i])
s0, s1 = [t[0][i] for i in order], [t[1][i] for i in order]
if hasattr(t[0], "typecode"): # v4 是 array("Q"),切片赋值只接受 array
t[0][:], t[1][:] = array("Q", s0), array("Q", s1)
else: # v6 是 list
t[0][:], t[1][:] = s0, s1
t[2][:] = [t[2][i] for i in order]
for ver, t in ((4, self._v4), (6, self._v6)):
segs = self._flatten(self._raw[ver])
if ver == 4: # v4 用 array("Q") 省内存
t[0][:], t[1][:] = array("Q", [s[0] for s in segs]), array("Q", [s[1] for s in segs])
else:
t[0][:], t[1][:] = [s[0] for s in segs], [s[1] for s in segs]
t[2][:] = [s[2] for s in segs]
self._raw = {4: [], 6: []}
def lookup(self, ip: str) -> str | None:
try:
+22 -7
View File
@@ -46,17 +46,30 @@ def _on_db_ready():
print("[startup] 数据库已连接", flush=True)
def _auto_update_rules():
"""识别库自动更新(默认开启,可在识别规则页关闭);更新器内部保证一天最多检查一次"""
def _auto_update_on() -> bool:
"""识别规则页的「自动更新」开关(默认开启):管 v2fly、ASN 库和补充库的定期更新"""
if not database.is_configured():
return
from app.classify.updater import updater
return False
with database.session() as db:
row = db.get(Setting, "rules_auto_update")
if row is None or row.value != "0":
return row is None or row.value != "0"
def _auto_update_rules():
"""v2fly 规则自动更新;更新器内部保证一天最多检查一次"""
if _auto_update_on():
from app.classify.updater import updater
updater.check(reload=engine.reload_classifier)
def _auto_update_db(fn):
"""ASN / 补充库的定期下载跟随同一个开关;关掉后只用已下载的数据,手动「立即检查更新」仍然可用"""
def run():
if _auto_update_on():
fn()
return run
def _reconnect():
# 有配置但连接断开 / 启动时没连上:后台定期重试,不需要用户重走安装向导
if not database.is_configured() and database.configured_url():
@@ -94,12 +107,14 @@ async def lifespan(app: FastAPI):
asyncio.get_running_loop().call_later(120, lambda: asyncio.get_running_loop().run_in_executor(
None, _safe(_auto_update_rules, "rules-update")))
if s.asn_db:
sched.add_job(_safe(asndb.update, "asn-db"), "interval", hours=6, max_instances=1, coalesce=True)
sched.add_job(_safe(_auto_update_db(asndb.update), "asn-db"), "interval", hours=6, max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, _safe(asndb.update, "asn-db"))
# 补充识别库:国内域名列表 + 官方 IP 段(sources.py,只做兜底;没配置的来源直接跳过)
from app.classify.sources import sources as extra_sources
if s.china_db or s.iplist_db:
sched.add_job(_safe(extra_sources.update, "extra-db"), "interval", hours=24, max_instances=1, coalesce=True)
# 每 6 小时看一次,文件满一天才重新下载(间隔和过期时间都是 24 小时会因几秒误差拖成两天)
sched.add_job(_safe(_auto_update_db(extra_sources.update), "extra-db"), "interval", hours=6,
max_instances=1, coalesce=True)
asyncio.get_running_loop().run_in_executor(None, _safe(extra_sources.update, "extra-db"))
if poller.enabled:
sched.add_job(_safe(poller.sample, "ros-rate"), "interval", seconds=max(s.ros_rate_seconds, 2),
+449 -142
View File
@@ -1,4 +1,4 @@
"""通知渠道(参照 EasySub:每种渠道一份配置,磁贴选择、可用未保存的配置测试)。
"""通知渠道(照搬 EasySub:14 种渠道、字段名、发送实现都和 EasySub 一致,磁贴选择、可用未保存的配置测试)。
配置保存在 settings 表 notify_config:{渠道: {enabled, ...字段}};免打扰在 notify_prefs:{quiet_from, quiet_to}。
返回给前端时密钥类字段只显示开头并以“…”结尾,保存时以“…”结尾的值表示沿用原值。
@@ -8,36 +8,48 @@ import hashlib
import hmac
import json
import re
import smtplib
import ssl
import threading
import time
import urllib.parse
from datetime import datetime
from email.header import Header
from email.mime.text import MIMEText
from email.utils import formataddr
import httpx
from app import database
from app.models import Setting
APP_NAME = "ROS-DPI"
# 渠道与字段同 EasySub(顺序即磁贴顺序)
DEFAULTS: dict[str, dict] = {
"wecom": {"url": "", "msg_type": "text"}, # 企业微信群机器人
"dingtalk": {"url": "", "secret": ""}, # 钉钉群机器人,secret 为加签密钥
"feishu": {"url": "", "secret": ""}, # 飞书群机器人,secret 为签名校验密钥
"bark": {"urls": [], "group": "ROS-DPI", "level": "active"},
"telegram": {"bot_token": "", "chat_id": "", "api_base": "https://api.telegram.org"},
"email": {"host": "", "port": 465, "security": "ssl", "username": "", "password": "", "from_addr": "", "to": ""},
"pushplus": {"token": "", "topic": ""},
"telegram": {"bot_token": "", "chat_id": "", "admin_id": "", "api_base": "", "proxy": ""},
"feishu": {"app_id": "", "app_secret": "", "chat_ids": ""},
"qq": {"app_id": "", "app_secret": "", "group_ids": "", "user_ids": ""},
"bark": {"urls": [], "group": APP_NAME, "level": "active", "icon": ""},
"email": {"host": "", "port": 465, "ssl": True, "username": "", "password": "", "from": "", "to": ""},
"pushplus": {"token": "", "topic": "", "channel": "wechat"},
"serverchan": {"sendkey": ""},
"ntfy": {"server": "https://ntfy.sh", "topic": "", "token": ""},
"gotify": {"server": "", "token": "", "priority": 5},
# 企业微信:mode=webhook 群机器人;mode=app 自建应用(企业ID + 应用ID + Secret,可走 API 代理)
"wecom": {"mode": "webhook", "url": "", "corp_id": "", "agent_id": "", "secret": "", "proxy_base": "",
"to_user": "@all", "to_party": "", "to_tag": "", "msg_type": "text", "card_url": ""},
"dingtalk": {"url": "", "secret": ""},
"discord": {"url": ""},
"slack": {"url": ""},
"webhook": {"urls": [], "secret": "", "headers": [], "template": "", "retries": 2},
"ntfy": {"server": "https://ntfy.sh", "topic": "", "token": ""},
"gotify": {"server": "", "token": "", "priority": 5},
"webhook": {"urls": [], "secret": "", "headers": [], "template": "", "timeout_ms": 5000, "max_retries": 3},
}
CHANNEL_TYPES = tuple(DEFAULTS)
SECRET = {"url", "urls", "secret", "bot_token", "password", "token", "sendkey"}
REQUIRED = {"wecom": ["url"], "dingtalk": ["url"], "feishu": ["url"], "bark": ["urls"], "telegram": ["bot_token", "chat_id"],
"email": ["host", "to"], "pushplus": ["token"], "serverchan": ["sendkey"], "ntfy": ["topic"],
"gotify": ["server", "token"], "discord": ["url"], "slack": ["url"], "webhook": ["urls"]}
SECRET = {"url", "urls", "secret", "bot_token", "password", "token", "sendkey", "app_secret"}
REQUIRED = {"telegram": ["bot_token", "chat_id"], "feishu": ["app_id", "app_secret", "chat_ids"],
"qq": ["app_id", "app_secret"], "bark": ["urls"], "email": ["host", "to"], "pushplus": ["token"],
"serverchan": ["sendkey"], "dingtalk": ["url"], "discord": ["url"], "slack": ["url"],
"ntfy": ["topic"], "gotify": ["server", "token"], "webhook": ["urls"]}
_last: dict[str, dict] = {} # 渠道 -> 上次发送结果 {ok, error, ts}
_lock = threading.Lock()
@@ -53,6 +65,21 @@ def _get(key: str, default):
return default
def _migrate(t: str, c: dict) -> dict:
"""0.5.x / 0.6.x 的字段名改成 EasySub 的"""
c = dict(c)
if t == "email":
if "security" in c:
c.setdefault("ssl", c.pop("security") == "ssl")
if "from_addr" in c:
c.setdefault("from", c.pop("from_addr"))
elif t == "webhook" and "retries" in c:
c.setdefault("max_retries", c.pop("retries"))
elif t == "telegram" and c.get("api_base") == "https://api.telegram.org":
c["api_base"] = ""
return c
def load_config() -> dict[str, dict]:
"""各渠道配置(补齐默认字段)。兼容 0.4.0 的通道列表格式 notify_channels"""
cfg = _get("notify_config", None)
@@ -64,18 +91,21 @@ def load_config() -> dict[str, dict]:
continue
if t == "telegram":
cfg[t] = {"bot_token": c.get("token", ""), "chat_id": c.get("chat_id", "")}
elif t == "bark":
cfg[t] = {"urls": [c.get("url", "")]}
elif t == "webhook":
elif t in ("bark", "webhook"):
cfg[t] = {"urls": [c.get("url", "")]}
elif t == "email":
host, _, port = (c.get("url") or "").partition(":")
cfg[t] = {"host": host, "port": int(port or 465), "security": c.get("security", "ssl"),
cfg[t] = {"host": host, "port": int(port or 465), "ssl": c.get("security", "ssl") == "ssl",
"username": c.get("user", ""), "password": c.get("token", ""), "to": c.get("to", "")}
else:
elif t != "feishu": # 飞书改成应用机器人,旧的群机器人地址用不上
cfg[t] = {"url": c.get("url", "")}
cfg[t]["enabled"] = c.get("enabled", True)
return {t: {"enabled": False, **DEFAULTS[t], **(cfg.get(t) or {})} for t in DEFAULTS}
if t in cfg:
cfg[t]["enabled"] = c.get("enabled", True)
out = {}
for t, d in DEFAULTS.items():
saved = _migrate(t, cfg.get(t) or {})
out[t] = {"enabled": bool(saved.get("enabled")), **d, **{k: v for k, v in saved.items() if k in d}}
return out
def enabled_types() -> list[str]:
@@ -83,7 +113,16 @@ def enabled_types() -> list[str]:
def missing(t: str, c: dict) -> list[str]:
return [f for f in REQUIRED[t] if not (c.get(f) and (c[f] != [] and c[f] != [""]))]
def empty(v):
return not v or v == [] or v == [""]
if t == "wecom":
need = ["corp_id", "agent_id", "secret"] if c.get("mode") == "app" else ["url"]
else:
need = REQUIRED[t]
out = [f for f in need if empty(c.get(f))]
if t == "qq" and not out and empty(c.get("group_ids")) and empty(c.get("user_ids")):
out.append("group_ids / user_ids")
return out
def prefs() -> dict:
@@ -123,132 +162,400 @@ def unmask(new: dict, old: dict) -> dict:
return out
# ---------------- 发送 ----------------
# ---------------- 发送(各渠道实现同 EasySub,失败抛异常) ----------------
def _check(r: httpx.Response, ok_codes=(0, 200, None)):
r.raise_for_status()
try:
j = r.json()
except ValueError:
def _split(csv: str) -> list[str]:
return [x.strip() for x in (csv or "").replace(",", ",").split(",") if x.strip()]
def _body(subject: str, text: str) -> str:
return f"{subject}\n\n{text}" if subject else text
def _tg_url(conf: dict, method: str) -> str:
base = (conf.get("api_base") or "https://api.telegram.org").strip().rstrip("/")
if not base.startswith(("http://", "https://")):
base = "https://" + base
return f"{base}/bot{conf.get('bot_token')}/{method}"
def _tg_client(conf: dict) -> httpx.Client:
return httpx.Client(timeout=15, proxy=conf.get("proxy") or None)
def _tg_json(r: httpx.Response) -> dict:
data = r.json()
if not data.get("ok"):
raise RuntimeError(data.get("description") or f"HTTP {r.status_code}")
return data
def _send_telegram(conf: dict, subject: str, text: str) -> None:
if not conf.get("bot_token") or not conf.get("chat_id"):
raise RuntimeError("Telegram 未配置 Bot Token 或 Chat ID")
with _tg_client(conf) as c:
_tg_json(c.post(_tg_url(conf, "sendMessage"), json={"chat_id": conf["chat_id"], "text": _body(subject, text)}))
def telegram_call(conf: dict, method: str) -> dict:
"""「验证机器人」(getMe)和「获取 Chat ID」(getUpdates)"""
if not conf.get("bot_token"):
raise RuntimeError("请先填写 Bot Token")
with _tg_client(conf) as c:
return _tg_json(c.get(_tg_url(conf, method)))
def _send_feishu(conf: dict, subject: str, text: str) -> None:
app_id, secret = conf.get("app_id"), conf.get("app_secret")
chat_ids = _split(conf.get("chat_ids", ""))
if not app_id or not secret or not chat_ids:
raise RuntimeError("飞书未配置 App ID / Secret / Chat IDs")
with httpx.Client(timeout=15) as c:
r = c.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": secret})
r.raise_for_status()
token = r.json().get("tenant_access_token")
if not token:
raise RuntimeError(f"飞书获取 token 失败:{r.text[:200]}")
for cid in chat_ids:
data = c.post("https://open.feishu.cn/open-apis/im/v1/messages", params={"receive_id_type": "chat_id"},
headers={"Authorization": f"Bearer {token}"},
json={"receive_id": cid, "msg_type": "text",
"content": json.dumps({"text": _body(subject, text)}, ensure_ascii=False)}).json()
if data.get("code", 0) != 0:
raise RuntimeError(f"飞书发送失败({cid}):{data.get('msg')}")
def _send_qq(conf: dict, subject: str, text: str) -> None:
app_id, secret = conf.get("app_id"), conf.get("app_secret")
groups, users = _split(conf.get("group_ids", "")), _split(conf.get("user_ids", ""))
if not app_id or not secret:
raise RuntimeError("QQ 未配置 App ID / Secret")
if not groups and not users:
raise RuntimeError("QQ 未填写任何群聊或私聊 OpenID")
with httpx.Client(timeout=15) as c:
r = c.post("https://bots.qq.com/app/getAppAccessToken", json={"appId": app_id, "clientSecret": secret})
r.raise_for_status()
token = r.json().get("access_token")
if not token:
raise RuntimeError(f"QQ 获取 access_token 失败:{r.text[:200]}")
headers = {"Authorization": f"QQBot {token}", "Content-Type": "application/json"}
for kind, ids in (("groups", groups), ("users", users)):
for oid in ids:
resp = c.post(f"https://api.sgroup.qq.com/v2/{kind}/{oid}/messages", headers=headers,
json={"content": _body(subject, text), "msg_type": 0})
if resp.status_code >= 300:
raise RuntimeError(f"QQ {'群' if kind == 'groups' else '用户'} {oid} 发送失败:{resp.text[:200]}")
def _send_bark(conf: dict, subject: str, text: str) -> None:
urls = [u for u in (conf.get("urls") or []) if u]
if not urls:
raise RuntimeError("Bark 未配置任何目标 URL")
payload = {"title": subject or APP_NAME, "body": text}
for k in ("group", "level", "icon"):
if conf.get(k):
payload[k] = conf[k]
with httpx.Client(timeout=15) as c:
for url in urls:
resp = c.post(url.rstrip("/"), json=payload)
resp.raise_for_status()
if resp.json().get("code", 200) not in (200, 0):
raise RuntimeError(f"Bark 推送失败:{resp.text[:200]}")
def _send_email(conf: dict, subject: str, text: str) -> None:
host = conf.get("host")
to_list = [x.strip() for x in re.split(r"[,,;;]", conf.get("to") or "") if x.strip()]
sender = (conf.get("from") or conf.get("username") or "").strip()
if not host or not to_list or not sender:
raise RuntimeError("Email 未配置 SMTP 主机 / 发件人 / 收件人")
port = int(conf.get("port") or (465 if conf.get("ssl") else 587))
msg = MIMEText(text, "plain", "utf-8")
msg["Subject"] = Header(subject or f"{APP_NAME} 通知", "utf-8")
msg["From"] = formataddr((APP_NAME, sender))
msg["To"] = ", ".join(to_list)
user, pwd = conf.get("username"), conf.get("password")
if conf.get("ssl"):
with smtplib.SMTP_SSL(host, port, timeout=20, context=ssl.create_default_context()) as s:
if user:
s.login(user, pwd)
s.sendmail(sender, to_list, msg.as_string())
else:
with smtplib.SMTP(host, port, timeout=20) as s:
try:
s.starttls(context=ssl.create_default_context())
except smtplib.SMTPException:
pass
if user:
s.login(user, pwd)
s.sendmail(sender, to_list, msg.as_string())
def _send_pushplus(conf: dict, subject: str, text: str) -> None:
if not conf.get("token"):
raise RuntimeError("Pushplus 未配置 Token")
body = {"token": conf["token"], "title": subject or f"{APP_NAME} 通知", "content": text, "template": "txt"}
for k in ("topic", "channel"):
if conf.get(k):
body[k] = conf[k]
with httpx.Client(timeout=15) as c:
resp = c.post("https://www.pushplus.plus/send", json=body)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 200:
raise RuntimeError(f"Pushplus 发送失败:{data.get('msg')}")
def _send_serverchan(conf: dict, subject: str, text: str) -> None:
key = (conf.get("sendkey") or "").strip()
if not key:
raise RuntimeError("Server酱未配置 SendKey")
m = re.match(r"^sctp(\d+)t", key) # Server酱³ 的 SendKey 里带 uid
url = f"https://{m.group(1)}.push.ft07.com/send/{key}.send" if m else f"https://sctapi.ftqq.com/{key}.send"
with httpx.Client(timeout=15) as c:
r = c.post(url, data={"title": subject or APP_NAME, "desp": text})
r.raise_for_status()
if r.json().get("code", 0) != 0:
raise RuntimeError(f"Server酱发送失败:{r.text[:200]}")
# ---- 企业微信(群机器人 / 自建应用) ----
WECOM_DEFAULT_BASE = "https://qyapi.weixin.qq.com"
# 自建应用的 access_token 缓存:key -> (token, 过期时间戳)。gettoken 有频率限制,必须复用(有效期 7200s)
_WECOM_TOKENS: dict[str, tuple[str, float]] = {}
_WECOM_TOKEN_ERRS = {40014, 42001, 41001} # 不合法 / 已过期 / 缺少 access_token:刷新后重试一次
def _trunc_bytes(text: str, limit: int) -> str:
"""按 UTF-8 字节数截断(text 上限 2048B、markdown 4096B、textcard 描述 512B)"""
raw = (text or "").encode("utf-8")
if len(raw) <= limit:
return text or ""
return raw[: limit - 3].decode("utf-8", "ignore") + "..."
def wecom_api_base(conf: dict) -> str:
"""API 代理地址;留空用官方域名。容忍把 /cgi-bin 一起粘进来"""
base = (conf.get("proxy_base") or "").strip().rstrip("/")
if not base:
return WECOM_DEFAULT_BASE
if not base.startswith(("http://", "https://")):
base = "https://" + base
return base[: -len("/cgi-bin")] if base.endswith("/cgi-bin") else base
def wecom_token(conf: dict, force: bool = False) -> str:
corp_id, secret = (conf.get("corp_id") or "").strip(), (conf.get("secret") or "").strip()
if not corp_id or not secret:
raise RuntimeError("企业微信自建应用未配置企业ID / 应用Secret")
base = wecom_api_base(conf)
key = f"{base}|{corp_id}|{hashlib.sha256(secret.encode()).hexdigest()[:16]}"
hit = _WECOM_TOKENS.get(key)
if hit and not force and hit[1] > time.time():
return hit[0]
with httpx.Client(timeout=15) as c:
r = c.get(f"{base}/cgi-bin/gettoken", params={"corpid": corp_id, "corpsecret": secret})
r.raise_for_status()
data = r.json()
token = data.get("access_token")
if data.get("errcode", 0) != 0 or not token:
raise RuntimeError(f"企业微信获取 access_token 失败:{data.get('errcode')} {data.get('errmsg')}"
"(请检查企业ID / 应用Secret,以及是否已在「企业可信IP」放行服务器出口 IP)")
_WECOM_TOKENS[key] = (token, time.time() + max(60, int(data.get("expires_in") or 7200) - 200))
return token
def wecom_agent_info(conf: dict) -> dict:
"""「检查应用」:读取自建应用名与可见范围"""
agent_id = str(conf.get("agent_id") or "").strip()
if not agent_id:
raise RuntimeError("企业微信自建应用未配置应用ID(AgentId)")
base = wecom_api_base(conf)
for attempt in (0, 1):
token = wecom_token(conf, force=bool(attempt))
with httpx.Client(timeout=15) as c:
r = c.get(f"{base}/cgi-bin/agent/get", params={"access_token": token, "agentid": agent_id})
r.raise_for_status()
data = r.json()
code = data.get("errcode", 0)
if code in _WECOM_TOKEN_ERRS and attempt == 0:
continue
if code != 0:
raise RuntimeError(f"企业微信读取应用失败:{code} {data.get('errmsg')}")
allow = data.get("allow_userinfos") or {}
return {"agentid": data.get("agentid"), "name": data.get("name"),
"users": len(allow.get("user") or []),
"parties": len((data.get("allow_partys") or {}).get("partyid") or []),
"tags": len((data.get("allow_tags") or {}).get("tagid") or [])}
raise RuntimeError("企业微信读取应用失败:access_token 反复失效")
def _wecom_payload(conf: dict, subject: str, text: str) -> dict:
msg_type = (conf.get("msg_type") or "text").strip()
if msg_type == "markdown":
return {"msgtype": "markdown", "markdown": {"content": _trunc_bytes(_body(subject, text), 4096)}}
if msg_type == "textcard":
return {"msgtype": "textcard", "textcard": {
"title": _trunc_bytes(subject or APP_NAME, 128), "description": _trunc_bytes(text, 512),
"url": conf.get("card_url") or "https://github.com/suyijun8182/ros-dpi", "btntxt": "查看详情"}}
return {"msgtype": "text", "text": {"content": _trunc_bytes(_body(subject, text), 2048)}}
def _send_wecom_robot(conf: dict, subject: str, text: str) -> None:
url = (conf.get("url") or "").strip()
if not url:
raise RuntimeError("企业微信未配置群机器人 Webhook")
payload = _wecom_payload(conf, subject, text)
if payload["msgtype"] == "textcard": # 群机器人不支持 textcard,降级为 text
payload = {"msgtype": "text", "text": {"content": _trunc_bytes(_body(subject, text), 2048)}}
with httpx.Client(timeout=15) as c:
r = c.post(url, json=payload)
r.raise_for_status()
data = r.json()
if data.get("errcode", 0) != 0:
raise RuntimeError(f"企业微信群机器人发送失败:{data.get('errcode')} {data.get('errmsg')}")
def _send_wecom_app(conf: dict, subject: str, text: str) -> None:
agent_id = str(conf.get("agent_id") or "").strip()
if not agent_id:
raise RuntimeError("企业微信自建应用未配置应用ID(AgentId)")
to_user, to_party, to_tag = ((conf.get(k) or "").strip() for k in ("to_user", "to_party", "to_tag"))
if not (to_user or to_party or to_tag):
to_user = "@all" # 三者全空则发给应用可见范围内全部成员
payload = _wecom_payload(conf, subject, text)
payload.update({"touser": to_user, "toparty": to_party, "totag": to_tag,
"agentid": int(agent_id) if agent_id.isdigit() else agent_id,
"safe": 0, "enable_duplicate_check": 0})
base = wecom_api_base(conf)
for attempt in (0, 1):
token = wecom_token(conf, force=bool(attempt))
with httpx.Client(timeout=15) as c:
r = c.post(f"{base}/cgi-bin/message/send", params={"access_token": token}, json=payload)
r.raise_for_status()
data = r.json()
code = data.get("errcode", 0)
if code in _WECOM_TOKEN_ERRS and attempt == 0:
continue
if code != 0:
hint = {81013: "(接收人不在应用可见范围内,请到「应用管理→可见范围」添加成员)",
60020: "(服务器 IP 未在「企业可信IP」白名单中,或需要配置 API 代理)",
301014: "(服务器 IP 未在「企业可信IP」白名单中,或需要配置 API 代理)",
40056: "(应用ID AgentId 不正确)"}.get(code, "")
raise RuntimeError(f"企业微信发送失败:{code} {data.get('errmsg')}{hint}")
# errcode=0 但接收人被剔除时,消息实际没人收到
invalid = [v for v in (str(data.get(k) or "").strip("| ") for k in ("invaliduser", "invalidparty", "invalidtag")) if v]
if invalid:
raise RuntimeError(f"企业微信部分接收人无效:{' / '.join(invalid)}(请核对成员 UserID,并确认其在应用可见范围内)")
return
if isinstance(j, dict):
code = j.get("errcode", j.get("code", j.get("StatusCode")))
if code not in ok_codes:
raise RuntimeError(str(j.get("errmsg") or j.get("msg") or j.get("message") or j)[:160])
def _sign_dingtalk(url: str, secret: str) -> str:
ts = str(int(time.time() * 1000))
sig = base64.b64encode(hmac.new(secret.encode(), f"{ts}\n{secret}".encode(), hashlib.sha256).digest()).decode()
return f"{url}{'&' if '?' in url else '?'}timestamp={ts}&sign={urllib.parse.quote_plus(sig)}"
def _send_wecom(conf: dict, subject: str, text: str) -> None:
(_send_wecom_app if conf.get("mode") == "app" else _send_wecom_robot)(conf, subject, text)
def _send_dingtalk(conf: dict, subject: str, text: str) -> None:
url = conf.get("url")
if not url:
raise RuntimeError("钉钉未配置机器人 Webhook")
secret = conf.get("secret")
if secret:
ts = str(round(time.time() * 1000))
sign = urllib.parse.quote_plus(base64.b64encode(
hmac.new(secret.encode(), f"{ts}\n{secret}".encode(), hashlib.sha256).digest()))
url = f"{url}{'&' if '?' in url else '?'}timestamp={ts}&sign={sign}"
with httpx.Client(timeout=15) as c:
r = c.post(url, json={"msgtype": "text", "text": {"content": _body(subject, text)}})
r.raise_for_status()
if r.json().get("errcode", 0) != 0:
raise RuntimeError(f"钉钉发送失败:{r.text[:200]}")
def _send_discord(conf: dict, subject: str, text: str) -> None:
if not conf.get("url"):
raise RuntimeError("Discord 未配置 Webhook URL")
content = f"**{subject}**\n{text}" if subject else text
with httpx.Client(timeout=15) as c:
c.post(conf["url"], json={"content": content[:1900]}).raise_for_status()
def _send_slack(conf: dict, subject: str, text: str) -> None:
if not conf.get("url"):
raise RuntimeError("Slack 未配置 Webhook URL")
with httpx.Client(timeout=15) as c:
c.post(conf["url"], json={"text": f"*{subject}*\n{text}" if subject else text}).raise_for_status()
def _send_ntfy(conf: dict, subject: str, text: str) -> None:
server = (conf.get("server") or "https://ntfy.sh").rstrip("/")
if not conf.get("topic"):
raise RuntimeError("ntfy 未配置 Topic")
headers = {}
if conf.get("token"):
headers["Authorization"] = f"Bearer {conf['token']}"
with httpx.Client(timeout=15) as c:
c.post(f"{server}/{conf['topic']}", content=_body(subject, text).encode("utf-8"), headers=headers).raise_for_status()
def _send_gotify(conf: dict, subject: str, text: str) -> None:
server = (conf.get("server") or "").rstrip("/")
if not server or not conf.get("token"):
raise RuntimeError("Gotify 未配置 Server / Token")
with httpx.Client(timeout=15) as c:
c.post(f"{server}/message", params={"token": conf["token"]},
json={"title": subject or APP_NAME, "message": text,
"priority": int(conf.get("priority") or 5)}).raise_for_status()
def _render_template(tpl: str, ctx: dict) -> str:
return re.sub(r"\{\{\s*(\w+)\s*\}\}", lambda m: str(ctx.get(m.group(1).strip(), m.group(0))), tpl)
def _send_webhook(conf: dict, subject: str, text: str, event: str = "audit") -> None:
"""POST JSON {text, subject, event, timestamp};可选模板、HMAC 签名(X-ROSDPI-Signature)、自定义请求头、超时与重试"""
urls = [u for u in (conf.get("urls") or []) if u]
if not urls:
raise RuntimeError("Webhook 未配置任何目标 URL")
ts = int(time.time())
ctx = {"text": text, "subject": subject, "event": event, "timestamp": ts}
rendered = _render_template(conf["template"], ctx) if conf.get("template") else text
raw = json.dumps({"text": rendered, "subject": subject, "event": event, "timestamp": ts, "source": "ros-dpi"},
ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json"}
for h in conf.get("headers") or []:
k, v = (h.get("key"), h.get("value")) if isinstance(h, dict) else (None, None)
if k and k.lower() not in ("content-type", "x-rosdpi-signature"):
headers[k] = v or ""
if conf.get("secret"):
headers["X-ROSDPI-Signature"] = hmac.new(conf["secret"].encode(), raw, hashlib.sha256).hexdigest()
timeout = max(1, int(conf.get("timeout_ms") or 5000)) / 1000
retries = max(0, min(10, int(conf.get("max_retries") or 0)))
for url in urls:
last_err = None
for _ in range(retries + 1):
try:
with httpx.Client(timeout=timeout) as c:
c.post(url, content=raw, headers=headers).raise_for_status()
last_err = None
break
except Exception as e: # noqa: BLE001
last_err = e
if last_err:
raise RuntimeError(f"Webhook 发送失败({url}):{last_err}")
_SENDERS = {"telegram": _send_telegram, "feishu": _send_feishu, "qq": _send_qq, "bark": _send_bark,
"email": _send_email, "pushplus": _send_pushplus, "serverchan": _send_serverchan, "wecom": _send_wecom,
"dingtalk": _send_dingtalk, "discord": _send_discord, "slack": _send_slack, "ntfy": _send_ntfy,
"gotify": _send_gotify, "webhook": _send_webhook}
def send_one(t: str, c: dict, title: str, text: str):
body = f"{title}\n{text}"
with httpx.Client(timeout=15) as h:
if t == "wecom":
if c.get("msg_type") == "markdown":
_check(h.post(c["url"], json={"msgtype": "markdown", "markdown": {"content": f"**{title}**\n{text}"[:4000]}}))
else:
_check(h.post(c["url"], json={"msgtype": "text", "text": {"content": body[:2000]}}))
elif t == "dingtalk":
url = _sign_dingtalk(c["url"], c["secret"]) if c.get("secret") else c["url"]
_check(h.post(url, json={"msgtype": "text", "text": {"content": body}}))
elif t == "feishu":
p = {"msg_type": "text", "content": {"text": body}}
if c.get("secret"):
ts = str(int(time.time()))
key = f"{ts}\n{c['secret']}".encode()
p.update(timestamp=ts, sign=base64.b64encode(hmac.new(key, b"", hashlib.sha256).digest()).decode())
_check(h.post(c["url"], json=p))
elif t == "bark":
for u in [x for x in c.get("urls") or [] if x]:
_check(h.post(u.rstrip("/"), json={"title": title, "body": text, "group": c.get("group") or "ROS-DPI",
"level": c.get("level") or "active"}))
elif t == "telegram":
base = (c.get("api_base") or "https://api.telegram.org").rstrip("/")
r = h.post(f"{base}/bot{c['bot_token']}/sendMessage", json={"chat_id": c["chat_id"], "text": body})
r.raise_for_status()
if not r.json().get("ok"):
raise RuntimeError(r.json().get("description", "Telegram 返回失败"))
elif t == "email":
_send_mail(c, title, text)
elif t == "pushplus":
p = {"token": c["token"], "title": title, "content": text, "template": "txt"}
if c.get("topic"):
p["topic"] = c["topic"]
_check(h.post("https://www.pushplus.plus/send", json=p), (200,))
elif t == "serverchan":
key = c["sendkey"].strip()
m = re.match(r"^sctp(\d+)t", key) # Server酱³ 的 SendKey 里带 uid
url = f"https://{m.group(1)}.push.ft07.com/send/{key}.send" if m else f"https://sctapi.ftqq.com/{key}.send"
_check(h.post(url, data={"title": title, "desp": text}))
elif t == "ntfy":
hd = {"Title": title.encode("utf-8").decode("latin-1", "ignore"), "Tags": "shield"}
if c.get("token"):
hd["Authorization"] = f"Bearer {c['token']}"
h.post(f"{(c.get('server') or 'https://ntfy.sh').rstrip('/')}/{c['topic']}", content=body.encode(),
headers=hd).raise_for_status()
elif t == "gotify":
h.post(f"{c['server'].rstrip('/')}/message", params={"token": c["token"]},
json={"title": title, "message": text, "priority": int(c.get("priority") or 5)}).raise_for_status()
elif t == "discord":
h.post(c["url"], json={"content": body[:1900]}).raise_for_status()
elif t == "slack":
h.post(c["url"], json={"text": f"*{title}*\n{text}"}).raise_for_status()
elif t == "webhook":
_send_webhook(h, c, title, text)
def _send_mail(c: dict, title: str, text: str):
import smtplib
from email.header import Header
from email.mime.text import MIMEText
from email.utils import formataddr
sec = c.get("security") or "ssl"
port = int(c.get("port") or (465 if sec == "ssl" else 587 if sec == "starttls" else 25))
sender = (c.get("from_addr") or c.get("username") or "").strip()
to = [x.strip() for x in (c.get("to") or "").replace(";", ";").replace(",", ";").replace(",", ";").split(";") if x.strip()]
msg = MIMEText(text, "plain", "utf-8")
msg["Subject"] = Header(title, "utf-8")
msg["From"] = formataddr(("ROS-DPI", sender))
msg["To"] = ", ".join(to)
cls = smtplib.SMTP_SSL if sec == "ssl" else smtplib.SMTP
with cls(c["host"], port, timeout=15) as s:
if sec == "starttls":
s.starttls()
if c.get("username") and c.get("password"):
s.login(c["username"], c["password"])
s.sendmail(sender, to, msg.as_string())
def _send_webhook(h: httpx.Client, c: dict, title: str, text: str):
"""通用 Webhook:POST JSON {subject, text, event, timestamp};可选模板替换 text、HMAC 签名、自定义请求头、重试"""
ts = int(time.time())
msg = text
if c.get("template"):
msg = c["template"].replace("{{text}}", text).replace("{{subject}}", title).replace("{{timestamp}}", str(ts))
raw = json.dumps({"subject": title, "text": msg, "event": "audit", "timestamp": ts, "source": "ros-dpi"},
ensure_ascii=False).encode()
hd = {"Content-Type": "application/json"}
for x in c.get("headers") or []:
k = (x.get("key") or "").strip()
if k and k.lower() not in ("content-type", "x-rosdpi-signature"):
hd[k] = x.get("value") or ""
if c.get("secret"):
hd["X-ROSDPI-Signature"] = hmac.new(c["secret"].encode(), raw, hashlib.sha256).hexdigest()
for u in [x for x in c.get("urls") or [] if x]:
err = None
for _ in range(int(c.get("retries") or 0) + 1):
try:
h.post(u, content=raw, headers=hd).raise_for_status()
err = None
break
except Exception as e: # noqa: BLE001
err = e
time.sleep(1)
if err:
raise err
fn = _SENDERS.get(t)
if not fn:
raise RuntimeError(f"未知渠道:{t}")
fn(c, title, text)
def send(title: str, text: str, types: list[str] | None = None, wait: bool = False, config: dict | None = None) -> list[dict]:
+39 -5
View File
@@ -266,7 +266,9 @@ def _clean(t: str, c: dict, old: dict) -> dict:
out = {"enabled": bool(c.get("enabled"))}
for k, dv in base.items():
v = c.get(k, dv)
if isinstance(dv, list):
if isinstance(dv, bool):
v = bool(v)
elif isinstance(dv, list):
v = [x for x in (v or []) if (x.get("key") if isinstance(x, dict) else str(x).strip())]
if k != "headers":
v = [str(x).strip() for x in v]
@@ -278,7 +280,9 @@ def _clean(t: str, c: dict, old: dict) -> dict:
else:
v = str(v or "").strip()
out[k] = v
for k in ("url", "server", "api_base"):
if t == "wecom" and out["mode"] not in ("webhook", "app"):
out["mode"] = "webhook"
for k in ("url", "server", "card_url", "icon"):
if out.get(k) and not str(out[k]).startswith(("http://", "https://")):
raise HTTPException(400, f"{notify_name(t)}:地址要以 http:// 或 https:// 开头")
for u in out.get("urls") or []:
@@ -290,9 +294,9 @@ def _clean(t: str, c: dict, old: dict) -> dict:
def notify_name(t: str) -> str:
return {"wecom": "企业微信", "dingtalk": "钉钉", "feishu": "飞书", "bark": "Bark", "telegram": "Telegram", "email": "邮件",
"pushplus": "PushPlus", "serverchan": "Server酱", "ntfy": "ntfy", "gotify": "Gotify", "discord": "Discord",
"slack": "Slack", "webhook": "Webhook"}.get(t, t)
return {"wecom": "企业微信", "dingtalk": "钉钉", "feishu": "飞书", "qq": "QQ", "bark": "Bark", "telegram": "Telegram",
"email": "Email", "pushplus": "Pushplus", "serverchan": "Server酱", "ntfy": "ntfy", "gotify": "Gotify",
"discord": "Discord", "slack": "Slack", "webhook": "Webhook"}.get(t, t)
@router.get("/notify")
@@ -332,6 +336,36 @@ def test_notify(req: NotifyTest):
return {"ok": True}
def _draft(channel: str, config: dict | None) -> dict:
"""前端还没保存的配置:只保留认识的字段,密钥以“…”结尾时沿用已保存的值(不校验必填)"""
old = notify.load_config()[channel]
return _clean(channel, {**(config or old), "enabled": False}, old)
@router.post("/notify/telegram/{action}")
def telegram_helper(action: str, req: NotifyTest):
"""同 EasySub:「验证机器人」= getMe,「获取 Chat ID」= getUpdates(用户先给机器人发一条消息)"""
method = {"me": "getMe", "updates": "getUpdates"}.get(action)
if not method:
raise HTTPException(404, "未知操作")
try:
return notify.telegram_call(_draft("telegram", req.config), method)
except Exception as e: # noqa: BLE001
raise HTTPException(502, f"Telegram {method} 失败:{e}")
@router.post("/notify/wecom/check")
def wecom_check(req: NotifyTest):
"""校验企业微信自建应用:拉取 access_token 并读取应用名与可见范围"""
conf = _draft("wecom", req.config)
if conf.get("mode") != "app":
raise HTTPException(400, "该校验仅适用于「自建应用」模式")
try:
return {"ok": True, "agent": notify.wecom_agent_info(conf)}
except Exception as e: # noqa: BLE001
raise HTTPException(502, str(e))
# ---------------- 报表 ----------------
class ReportSettings(BaseModel):
+3 -1
View File
@@ -221,7 +221,9 @@ def delete_dept(did: int, request: Request, user: str = Depends(current_user), d
raise HTTPException(404, "部门不存在")
n = db.scalar(select(func.count()).select_from(Person).where(Person.dept_id == did)) or 0
if n:
raise HTTPException(400, f"“{d.name}”下还有 {n} 个成员,先把他们移到别的分组或删除")
home = insight.get_config(db)["scenario"] == "home"
who, where = ("成员", "分组") if home else ("员工", "部门")
raise HTTPException(400, f"“{d.name}”下还有 {n} 个{who},先把他们移到别的{where}或删除")
name = d.name
db.delete(d)
oplog.log(db, request, user, "dept_delete", {"id": did, "name": name})
+92 -2
View File
@@ -39,7 +39,7 @@ def test_payloads_and_signatures(monkeypatch):
cfg = {
"dingtalk": {"url": "https://oapi.dingtalk.com/robot/send?access_token=x", "secret": "SECabc"},
"webhook": {"urls": ["https://example.com/hook"], "secret": "s3", "headers": [{"key": "X-A", "value": "1"}],
"template": "[{{subject}}] {{text}}", "retries": 0},
"template": "[{{subject}}] {{text}}", "max_retries": 0},
"bark": {"urls": ["https://api.day.app/KEY/"], "group": "ROS-DPI", "level": "timeSensitive"},
"serverchan": {"sendkey": "sctp123tABC"},
}
@@ -48,7 +48,7 @@ def test_payloads_and_signatures(monkeypatch):
by = {c["url"].split("?")[0]: c for c in Recorder.calls}
d = next(c for c in Recorder.calls if "dingtalk" in c["url"])
q = urllib.parse.parse_qs(urllib.parse.urlparse(d["url"]).query)
assert "timestamp" in q and "sign" in q and d["json"]["text"]["content"] == "标题\n正文"
assert "timestamp" in q and "sign" in q and d["json"]["text"]["content"] == "标题\n\n正文"
w = by["https://example.com/hook"]
body = json.loads(w["content"])
assert body["text"] == "[标题] 正文" and w["headers"]["X-A"] == "1"
@@ -109,3 +109,93 @@ def test_rule_channels_and_quiet_hours(monkeypatch):
assert "免打扰" in mine[0]["json"]["text"]
c.put("/api/audit/notify", json={"config": {"wecom": {"enabled": False}, "slack": {"enabled": False}},
"quiet_from": "", "quiet_to": ""}, headers=h)
class Recorder2(Recorder):
"""在 Recorder 的基础上支持 GET,并按地址返回各家 API 的成功应答"""
def post(self, url, json=None, content=None, headers=None, data=None, params=None):
Recorder.calls.append({"url": str(url), "json": json, "content": content, "headers": headers or {}, "data": data, "params": params})
body = {"errcode": 0, "code": 0, "ok": True}
if "tenant_access_token" in str(url):
body = {"code": 0, "tenant_access_token": "FT"}
elif "getAppAccessToken" in str(url):
body = {"access_token": "QT"}
elif "pushplus" in str(url):
body = {"code": 200}
return httpx.Response(200, json=body, request=httpx.Request("POST", str(url)))
def get(self, url, params=None):
Recorder.calls.append({"url": str(url), "params": params})
body = {"errcode": 0, "access_token": "WT", "expires_in": 7200} if "gettoken" in str(url) else \
{"errcode": 0, "agentid": 1000002, "name": "ROS-DPI", "allow_userinfos": {"user": [{"userid": "a"}]}}
return httpx.Response(200, json=body, request=httpx.Request("GET", str(url)))
def test_easysub_channels(monkeypatch):
"""照搬 EasySub 的渠道:飞书应用机器人、QQ 机器人、企业微信自建应用、Pushplus 渠道、Bark 图标"""
monkeypatch.setattr(notify.httpx, "Client", Recorder2)
notify._WECOM_TOKENS.clear()
Recorder.calls = []
cfg = {
"feishu": {"app_id": "cli_1", "app_secret": "S", "chat_ids": "oc_a,oc_b"},
"qq": {"app_id": "1", "app_secret": "S", "group_ids": "G1", "user_ids": ""},
"wecom": {"mode": "app", "corp_id": "ww1", "agent_id": "1000002", "secret": "S", "to_user": "", "msg_type": "textcard"},
"pushplus": {"token": "P", "channel": "mail"},
"bark": {"urls": ["https://api.day.app/K"], "icon": "https://x/i.png"},
}
res = notify.send("标题", "正文", wait=True, config={t: {**notify.DEFAULTS[t], **c} for t, c in cfg.items()})
assert [r for r in res if not r["ok"]] == [] and len(res) == 5
urls = [c["url"] for c in Recorder.calls]
assert sum("im/v1/messages" in u for u in urls) == 2 # 两个飞书群
assert any(u.endswith("/v2/groups/G1/messages") for u in urls)
send = next(c for c in Recorder.calls if c["url"].endswith("/cgi-bin/message/send"))
assert send["params"] == {"access_token": "WT"} and send["json"]["touser"] == "@all"
assert send["json"]["msgtype"] == "textcard" and send["json"]["agentid"] == 1000002
assert next(c for c in Recorder.calls if "pushplus" in c["url"])["json"]["channel"] == "mail"
assert next(c for c in Recorder.calls if "day.app" in c["url"])["json"]["icon"] == "https://x/i.png"
# token 缓存:再发一次不会再调 gettoken
n = sum("gettoken" in c["url"] for c in Recorder.calls)
notify.send("x", "y", wait=True, config={"wecom": {**notify.DEFAULTS["wecom"], **cfg["wecom"]}})
assert sum("gettoken" in c["url"] for c in Recorder.calls) == n
assert notify.wecom_agent_info({**notify.DEFAULTS["wecom"], **cfg["wecom"]})["users"] == 1
# 必填项随模式变化
assert notify.missing("wecom", {**notify.DEFAULTS["wecom"], "mode": "app"}) == ["corp_id", "agent_id", "secret"]
assert notify.missing("wecom", dict(notify.DEFAULTS["wecom"])) == ["url"]
assert notify.missing("qq", {"app_id": "1", "app_secret": "s"}) == ["group_ids / user_ids"]
def test_old_field_names_migrate():
"""0.6.x 存的 email.security / from_addr、webhook.retries 读出来改成 EasySub 的字段"""
with database.session() as db:
row = db.get(Setting, "notify_config") or Setting(key="notify_config")
old = row.value
row.value = json.dumps({"email": {"enabled": True, "host": "smtp.x", "security": "starttls", "from_addr": "a@x", "to": "b@x"},
"webhook": {"urls": ["https://h"], "retries": 1}})
db.merge(row)
db.commit()
try:
cfg = notify.load_config()
assert cfg["email"]["ssl"] is False and cfg["email"]["from"] == "a@x" and "security" not in cfg["email"]
assert cfg["webhook"]["max_retries"] == 1 and "retries" not in cfg["webhook"]
assert list(cfg) == list(notify.DEFAULTS)[:] and list(cfg)[0] == "telegram"
finally:
with database.session() as db:
row = db.get(Setting, "notify_config")
if old is None:
db.delete(row)
else:
row.value = old
db.commit()
def test_notify_api_bool_and_helpers(monkeypatch):
monkeypatch.setattr(notify.httpx, "Client", Recorder2)
notify._WECOM_TOKENS.clear()
with TestClient(app) as c:
h = _h(c)
r = c.put("/api/audit/notify", json={"config": {"email": {"enabled": False, "host": "smtp.x", "ssl": False, "to": "a@x"}}}, headers=h)
assert r.status_code == 200 and r.json()["config"]["email"]["ssl"] is False
r = c.post("/api/audit/notify/wecom/check", json={"channel": "wecom", "config": {"mode": "app", "corp_id": "ww", "agent_id": "1000002", "secret": "S"}}, headers=h)
assert r.status_code == 200 and r.json()["agent"]["name"] == "ROS-DPI"
assert c.post("/api/audit/notify/wecom/check", json={"channel": "wecom", "config": {"mode": "webhook"}}, headers=h).status_code == 400
assert c.post("/api/audit/notify/telegram/nope", json={"channel": "telegram"}, headers=h).status_code == 404
+36
View File
@@ -70,3 +70,39 @@ def test_sources_rows_for_library_panel(loaded_sources):
assert rows["github"]["ranges"] == 2
assert rows["cn"]["ranges"] == 1
assert all(r["license"] and r["url"] for r in rows.values())
def test_domain_fallback_before_ip_fallback(loaded_sources):
"""有域名时先按域名兜底:国内网站托管在 Google/国内段的 IP 上,仍归「国内网站」而不是 IP 所属的 CDN。"""
c = Classifier()
assert c.classify("192.0.2.1", 6, 443, "www.example-test.cn", None) == "cn_web"
assert c.classify("198.51.100.7", 6, 443, "www.example-test.cn", None) == "cn_web"
# 域名不在任何库里时才按 IP
assert c.classify("192.0.2.1", 6, 443, "not-in-list.example.org", None) == "google"
def test_overlapping_ranges_longest_prefix():
"""IP 段重叠时按最长前缀:小段赢;小段之外仍落在大段里(旧实现在这里会漏查返回 None)。"""
from app.classify.sources import _Ranges
r = _Ranges()
r.add("10.0.0.0/8", "cn_net") # 先加入的大段
r.add("10.1.0.0/16", "aws") # 后加入、更具体
r.add("10.1.2.0/24", "github") # 再嵌套一层
r.add("10.200.0.0/16", "cn_net") # 同归属的嵌套,合并后不影响
r.add("2001:db8::/32", "google")
r.add("2001:db8:1::/48", "cloudflare")
r.build()
assert r.lookup("10.1.2.3") == "github"
assert r.lookup("10.1.3.1") == "aws"
assert r.lookup("10.2.0.1") == "cn_net" # 在小段后面、仍属于大段
assert r.lookup("10.255.255.255") == "cn_net"
assert r.lookup("10.0.0.0") == "cn_net"
assert r.lookup("11.0.0.1") is None
assert r.lookup("2001:db8:1::5") == "cloudflare"
assert r.lookup("2001:db8:2::5") == "google"
# 一样大时先加入的赢
r2 = _Ranges()
r2.add("192.0.2.0/24", "google")
r2.add("192.0.2.0/24", "aws")
r2.build()
assert r2.lookup("192.0.2.9") == "google"
+6 -3
View File
@@ -23,11 +23,13 @@
<div class="scrim" v-if="drawer" @click="drawer = false" />
<aside class="side" :aria-label="t('shell.menu')">
<nav class="nav">
<router-link v-for="n in nav" :key="n.to" :to="n.to" :class="{ on: isOn(n) }" :title="ui.side === 'mini' ? t(n.label) : null" @click="drawer = false">
<router-link v-for="n in nav" :key="n.to" :to="n.to" :class="{ on: isOn(n) }" :title="ui.side === 'mini' ? navLabel(n) : null" @click="drawer = false">
<Icon :name="n.icon" :size="19" /><span>{{ navLabel(n) }}</span>
</router-link>
</nav>
<div class="side-foot"><span class="dot" :class="status" /><span>v{{ version }}</span></div>
<div class="side-foot"><span class="dot" :class="status" /><span>v{{ version }}</span>
<router-link v-if="auth.role !== 'user'" to="/settings?tab=system" class="pill grey scene" :title="t('people.sceneGo')">{{ L('badge') }}</router-link>
<span v-else class="pill grey scene">{{ L('badge') }}</span></div>
</aside>
<main class="main">
@@ -151,7 +153,8 @@ function logout() {
.mini .nav a.on::before { left: -8px; }
.side-foot { margin-top: auto; display: flex; align-items: center; gap: 8px; font: 500 12px/1 var(--cond); color: var(--faint); padding: 0 14px; }
.mini .side-foot { justify-content: center; padding: 0; }
.mini .side-foot span:last-child { display: none; }
.mini .side-foot span:last-child, .mini .side-foot .scene { display: none; }
.side-foot .scene { margin-left: auto; text-decoration: none; font: inherit; }
.main { margin-left: var(--side); padding: 16px 18px 12px; min-width: 0; }
.mini .main { margin-left: 64px; }
+306 -124
View File
@@ -1,117 +1,244 @@
<template>
<!-- 通知渠道(参照 EasySub):上面一排磁贴选渠道(绿点 = 已启用),下面是该渠道的配置面板;可以用还没保存的配置测试 -->
<!-- 通知渠道(照搬 EasySub):上面一排磁贴选渠道(绿点 = 已启用),下面是该渠道的配置面板;可以用还没保存的配置测试 -->
<div class="panel">
<div class="panel-h"><h2>{{ t('nc.title') }}</h2><span class="faint small">{{ t('nc.hint') }}</span><span class="sp" />
<button class="btn sm primary" @click="save" :disabled="!cfg">{{ saved ? t('common.saved') : t('nc.save') }}</button></div>
<div class="panel-h"><h2>{{ t('nc.title') }}</h2><span class="faint small">Telegram / 飞书 / QQ / Bark / Email / Pushplus / Webhook</span><span class="sp" />
<button class="btn sm primary" @click="save" :disabled="!cfg || saving">{{ saved ? t('common.saved') : t('nc.save') }}</button></div>
<div class="panel-b" v-if="cfg">
<div class="tiles">
<button v-for="k in types" :key="k" class="tile" :class="{ on: cur === k }" @click="cur = k">
<span>{{ t('nc.types.' + k) }}</span><i class="st" :class="{ ok: cfg[k].enabled, bad: last[k] && !last[k].ok }" /></button>
<p class="faint small hint">{{ t('nc.hint') }}</p>
<div class="tiles" role="tablist">
<button v-for="k in types" :key="k" type="button" role="tab" class="tile" :class="{ on: cur === k }" :aria-selected="cur === k" @click="cur = k">
<span class="tile-n">{{ t('nc.types.' + k) }}</span><i class="st" :class="{ ok: cfg[k].enabled, bad: last[k] && !last[k].ok }" /></button>
</div>
<div class="pane">
<div class="pane" role="tabpanel">
<div class="pane-h">
<h3>{{ t('nc.enableX', { x: t('nc.types.' + cur) }) }}</h3><span class="sp" />
<h3>{{ t('nc.enable.' + cur) }}</h3><span class="sp" />
<button class="btn sm" @click="test" :disabled="testing">{{ testing ? t('nc.testing') : t('nc.test') }}</button>
<label class="sw"><input type="checkbox" v-model="c.enabled" /><span /></label>
<label class="sw" :title="t('nc.enable.' + cur)"><input type="checkbox" v-model="c.enabled" :aria-label="t('nc.enable.' + cur)" /><span /></label>
</div>
<div class="help small muted">{{ t('nc.help.' + cur) }}</div>
<!-- 单个地址的群机器人 -->
<template v-if="['wecom', 'dingtalk', 'feishu', 'discord', 'slack'].includes(cur)">
<label :for="'nc-url'">{{ t('nc.f.url') }}</label>
<input id="nc-url" class="input addr" v-model="c.url" :placeholder="t('nc.ph.' + cur)" />
<template v-if="cur === 'dingtalk' || cur === 'feishu'">
<label for="nc-secret">{{ t('nc.f.signSecret') }}</label>
<input id="nc-secret" class="input addr" v-model="c.secret" :placeholder="t('nc.f.optional')" />
</template>
<template v-if="cur === 'wecom'">
<label>{{ t('nc.f.msgType') }}</label>
<div class="seg"><button v-for="m in ['text', 'markdown']" :key="m" type="button" :class="{ on: c.msg_type === m }" @click="c.msg_type = m">{{ t('nc.msg.' + m) }}</button></div>
</template>
</template>
<!-- 多个地址 -->
<template v-if="cur === 'bark' || cur === 'webhook'">
<label>{{ t('nc.f.urls') }}</label>
<div v-for="(u, i) in c.urls" :key="i" class="row">
<input class="input addr grow" v-model="c.urls[i]" :placeholder="t('nc.ph.' + cur)" :aria-label="t('nc.f.urls')" />
<button class="btn sm ghost" @click="c.urls.splice(i, 1)" :aria-label="t('common.delete')"><Icon name="close" :size="14" /></button>
</div>
<div v-if="!c.urls.length" class="dash small faint">{{ t('nc.noUrl') }}</div>
<div><button class="btn sm" @click="c.urls.push('')">+ URL</button></div>
</template>
<div class="two" v-if="cur === 'bark'">
<div><label for="nc-group">{{ t('nc.f.group') }}</label><input id="nc-group" class="input" v-model="c.group" /></div>
<div><label for="nc-level">{{ t('nc.f.level') }}</label>
<select id="nc-level" class="input" v-model="c.level"><option v-for="l in ['active', 'timeSensitive', 'passive', 'critical']" :key="l" :value="l">{{ l }}</option></select></div>
</div>
<template v-if="cur === 'webhook'">
<label for="nc-wsec">{{ t('nc.f.hmac') }}</label>
<input id="nc-wsec" class="input addr" v-model="c.secret" :placeholder="t('nc.f.optional')" />
<label>{{ t('nc.f.headers') }}</label>
<div v-for="(h, i) in c.headers" :key="i" class="row">
<input class="input" v-model="h.key" placeholder="Header" aria-label="Header" />
<input class="input grow" v-model="h.value" placeholder="Value" aria-label="Value" />
<button class="btn sm ghost" @click="c.headers.splice(i, 1)" :aria-label="t('common.delete')"><Icon name="close" :size="14" /></button>
</div>
<div><button class="btn sm" @click="c.headers.push({ key: '', value: '' })">+ Header</button></div>
<label for="nc-tpl">{{ t('nc.f.template') }}</label>
<textarea id="nc-tpl" class="input code" rows="3" v-model="c.template" placeholder="{{subject}}: {{text}}" />
<div class="faint small">{{ t('nc.tplHint') }}</div>
<label for="nc-retry">{{ t('nc.f.retries') }}</label>
<input id="nc-retry" class="input w" type="number" min="0" max="5" v-model.number="c.retries" />
</template>
<!-- Telegram -->
<template v-if="cur === 'telegram'">
<div class="f"><label for="nc-tok">BOT TOKEN</label><input id="nc-tok" class="input addr" v-model="c.bot_token" placeholder="123456:ABC-..." /></div>
<div class="two">
<div><label for="nc-tok">Bot Token</label><input id="nc-tok" class="input addr" v-model="c.bot_token" placeholder="123456:ABC-DEF…" /></div>
<div><label for="nc-chat">Chat ID</label><input id="nc-chat" class="input addr" v-model="c.chat_id" placeholder="123456789" /></div>
<div class="f"><label for="nc-chat">CHAT ID</label><input id="nc-chat" class="input" v-model="c.chat_id" :placeholder="t('nc.ph.chatId')" /></div>
<div class="f"><label for="nc-admin">ADMIN ID</label><input id="nc-admin" class="input" v-model="c.admin_id" /></div>
</div>
<label for="nc-api">{{ t('nc.f.apiBase') }}</label>
<input id="nc-api" class="input addr" v-model="c.api_base" placeholder="https://api.telegram.org" />
</template>
<template v-if="cur === 'email'">
<div class="three">
<div><label for="nc-host">{{ t('nc.f.smtpHost') }}</label><input id="nc-host" class="input addr" v-model="c.host" placeholder="smtp.qq.com" /></div>
<div><label for="nc-port">{{ t('nc.f.port') }}</label><input id="nc-port" class="input" type="number" v-model.number="c.port" /></div>
<div><label for="nc-sec">{{ t('nc.f.security') }}</label>
<select id="nc-sec" class="input" v-model="c.security"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">{{ t('nc.f.none') }}</option></select></div>
</div>
<div class="two">
<div><label for="nc-user">{{ t('login.username') }}</label><input id="nc-user" class="input" v-model="c.username" autocomplete="off" /></div>
<div><label for="nc-pw">{{ t('login.password') }}</label><input id="nc-pw" class="input" type="password" v-model="c.password" :placeholder="t('nc.f.authCode')" autocomplete="new-password" /></div>
</div>
<label for="nc-from">{{ t('nc.f.from') }}</label><input id="nc-from" class="input" v-model="c.from_addr" :placeholder="t('nc.f.fromHint')" />
<label for="nc-to">{{ t('nc.f.to') }}</label><input id="nc-to" class="input" v-model="c.to" placeholder="a@example.com; b@example.com" />
</template>
<template v-if="cur === 'pushplus'">
<div class="two">
<div><label for="nc-pt">Token</label><input id="nc-pt" class="input addr" v-model="c.token" /></div>
<div><label for="nc-topic">{{ t('nc.f.topic') }}</label><input id="nc-topic" class="input" v-model="c.topic" :placeholder="t('nc.f.optional')" /></div>
</div>
</template>
<template v-if="cur === 'serverchan'">
<label for="nc-sk">SendKey</label><input id="nc-sk" class="input addr" v-model="c.sendkey" placeholder="SCT… / sctp…" />
</template>
<template v-if="cur === 'ntfy'">
<div class="three">
<div><label for="nc-ns">{{ t('nc.f.server') }}</label><input id="nc-ns" class="input addr" v-model="c.server" /></div>
<div><label for="nc-nt">{{ t('nc.f.topic') }}</label><input id="nc-nt" class="input" v-model="c.topic" /></div>
<div><label for="nc-nk">Token</label><input id="nc-nk" class="input addr" v-model="c.token" :placeholder="t('nc.f.optional')" /></div>
</div>
</template>
<template v-if="cur === 'gotify'">
<div class="three">
<div><label for="nc-gs">{{ t('nc.f.server') }}</label><input id="nc-gs" class="input addr" v-model="c.server" placeholder="https://gotify.example.com" /></div>
<div><label for="nc-gt">{{ t('nc.f.appToken') }}</label><input id="nc-gt" class="input addr" v-model="c.token" /></div>
<div><label for="nc-gp">{{ t('nc.f.priority') }}</label><input id="nc-gp" class="input" type="number" min="0" max="10" v-model.number="c.priority" /></div>
<div class="f"><label for="nc-api">{{ t('nc.f.tgApiBase') }}</label><input id="nc-api" class="input addr" v-model="c.api_base" placeholder="api.telegram.org" /></div>
<div class="f"><label for="nc-proxy">{{ t('nc.f.httpProxy') }}</label><input id="nc-proxy" class="input addr" v-model="c.proxy" placeholder="http://127.0.0.1:7890" /></div>
<div class="row">
<button class="btn sm" type="button" @click="tgAction('me')">{{ t('nc.checkBot') }}</button>
<button class="btn sm" type="button" @click="tgAction('updates')">{{ t('nc.getUpdates') }}</button>
</div>
</template>
<div class="res small" v-if="msg" :class="msgOk ? 'okc' : 'err'">{{ msg }}</div>
<!-- 飞书 -->
<template v-else-if="cur === 'feishu'">
<div class="two">
<div class="f"><label for="nc-fid">APP ID</label><input id="nc-fid" class="input" v-model="c.app_id" placeholder="cli_xxxx" /></div>
<div class="f"><label for="nc-fsec">APP SECRET</label><input id="nc-fsec" class="input" type="password" v-model="c.app_secret" autocomplete="new-password" /></div>
</div>
<div class="f"><label for="nc-fchat">CHAT IDS</label><input id="nc-fchat" class="input addr" v-model="c.chat_ids" :placeholder="t('nc.ph.feishuChats')" />
<small class="faint">{{ t('nc.h.feishu') }}</small></div>
</template>
<!-- QQ -->
<template v-else-if="cur === 'qq'">
<div class="two">
<div class="f"><label for="nc-qid">APP ID</label><input id="nc-qid" class="input" v-model="c.app_id" placeholder="QQ Bot App ID" /></div>
<div class="f"><label for="nc-qsec">APP SECRET</label><input id="nc-qsec" class="input" type="password" v-model="c.app_secret" autocomplete="new-password" /></div>
</div>
<div class="two">
<div class="f"><label for="nc-qg">{{ t('nc.f.qqGroups') }}</label><input id="nc-qg" class="input" v-model="c.group_ids" :placeholder="t('nc.ph.qqGroups')" /></div>
<div class="f"><label for="nc-qu">{{ t('nc.f.qqUsers') }}</label><input id="nc-qu" class="input" v-model="c.user_ids" :placeholder="t('nc.ph.qqUsers')" /></div>
</div>
<small class="faint">{{ t('nc.h.qq') }}</small>
</template>
<!-- Bark -->
<template v-else-if="cur === 'bark'">
<div class="f">
<div class="f-h"><label>{{ t('nc.f.targetUrls') }}</label><button class="btn sm" type="button" @click="c.urls.push('')">+ URL</button></div>
<div v-if="!c.urls.length" class="dash small faint">{{ t('nc.noBarkUrl') }}</div>
<div v-for="(u, i) in c.urls" :key="i" class="row">
<input class="input addr grow" v-model="c.urls[i]" :placeholder="t('nc.ph.bark')" :aria-label="t('nc.f.targetUrls')" />
<button class="btn sm ghost" type="button" @click="c.urls.splice(i, 1)" :aria-label="t('common.delete')"><Icon name="close" :size="14" /></button>
</div>
</div>
<div class="two">
<div class="f"><label for="nc-group">{{ t('nc.f.barkGroup') }}</label><input id="nc-group" class="input" v-model="c.group" placeholder="ROS-DPI" /></div>
<div class="f"><label for="nc-level">{{ t('nc.f.barkLevel') }}</label>
<select id="nc-level" class="input" v-model="c.level"><option v-for="l in ['active', 'timeSensitive', 'passive', 'critical']" :key="l" :value="l">{{ l }}</option></select></div>
</div>
<div class="f"><label for="nc-icon">ICON</label><input id="nc-icon" class="input addr" v-model="c.icon" :placeholder="t('nc.ph.icon')" /></div>
</template>
<!-- Email -->
<template v-else-if="cur === 'email'">
<div class="three">
<div class="f"><label for="nc-host">SMTP {{ t('nc.f.host') }}</label><input id="nc-host" class="input addr" v-model="c.host" placeholder="smtp.example.com" /></div>
<div class="f"><label for="nc-port">SMTP {{ t('nc.f.port') }}</label><input id="nc-port" class="input" type="number" v-model.number="c.port" /></div>
<div class="f"><label>SSL/TLS</label><label class="sw"><input type="checkbox" v-model="c.ssl" aria-label="SSL/TLS" /><span /></label></div>
</div>
<div class="two">
<div class="f"><label for="nc-user">{{ t('nc.f.username') }}</label><input id="nc-user" class="input" v-model="c.username" :placeholder="t('nc.ph.mailUser')" autocomplete="off" /></div>
<div class="f"><label for="nc-pw">{{ t('nc.f.password') }}</label><input id="nc-pw" class="input" type="password" v-model="c.password" :placeholder="t('nc.ph.mailPw')" autocomplete="new-password" /></div>
</div>
<div class="f"><label for="nc-from">{{ t('nc.f.from') }}</label><input id="nc-from" class="input" v-model="c.from" placeholder="noreply@example.com" /></div>
<div class="f"><label for="nc-to">{{ t('nc.f.to') }}</label><input id="nc-to" class="input" v-model="c.to" :placeholder="t('nc.ph.mailTo')" /></div>
</template>
<!-- Pushplus -->
<template v-else-if="cur === 'pushplus'">
<div class="f"><label for="nc-pt">TOKEN</label><input id="nc-pt" class="input addr" v-model="c.token" :placeholder="t('nc.ph.ppToken')" /></div>
<div class="two">
<div class="f"><label for="nc-topic">{{ t('nc.f.ppTopic') }}</label><input id="nc-topic" class="input" v-model="c.topic" :placeholder="t('nc.ph.ppTopic')" /></div>
<div class="f"><label for="nc-ppc">{{ t('nc.f.ppChannel') }}</label>
<select id="nc-ppc" class="input" v-model="c.channel">
<option value="wechat">{{ t('nc.pp.wechat') }}</option><option value="mail">{{ t('nc.pp.mail') }}</option>
<option value="webhook">Webhook</option><option value="cp">{{ t('nc.pp.cp') }}</option>
</select></div>
</div>
</template>
<!-- Server酱 -->
<template v-else-if="cur === 'serverchan'">
<div class="f"><label for="nc-sk">SENDKEY</label><input id="nc-sk" class="input addr" v-model="c.sendkey" placeholder="SCT... / SendKey" />
<small class="faint">{{ t('nc.h.serverchan') }}</small></div>
</template>
<!-- 企业微信:群机器人 / 自建应用 -->
<template v-else-if="cur === 'wecom'">
<div class="f">
<label>{{ t('nc.wc.mode') }}</label>
<div class="seg wide">
<button type="button" :class="{ on: c.mode !== 'app' }" @click="c.mode = 'webhook'">{{ t('nc.wc.robot') }}</button>
<button type="button" :class="{ on: c.mode === 'app' }" @click="c.mode = 'app'">{{ t('nc.wc.app') }}</button>
</div>
<small class="faint">{{ c.mode === 'app' ? t('nc.wc.appTip') : t('nc.wc.robotTip') }}</small>
</div>
<template v-if="c.mode !== 'app'">
<div class="f"><label for="nc-wurl">{{ t('nc.f.robotUrl') }}</label>
<input id="nc-wurl" class="input addr" v-model="c.url" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
<small class="faint">{{ t('nc.h.wecom') }}</small></div>
</template>
<template v-else>
<div class="f"><label for="nc-corp">{{ t('nc.wc.corpId') }}</label><input id="nc-corp" class="input" v-model="c.corp_id" placeholder="ww1234567890abcdef" />
<small class="faint">{{ t('nc.wc.corpIdHint') }}</small></div>
<div class="two">
<div class="f"><label for="nc-agent">{{ t('nc.wc.agentId') }}</label><input id="nc-agent" class="input" v-model="c.agent_id" placeholder="1000002" /></div>
<div class="f"><label for="nc-wsecret">{{ t('nc.wc.secret') }}</label><input id="nc-wsecret" class="input" type="password" v-model="c.secret" :placeholder="t('nc.wc.secret')" autocomplete="new-password" /></div>
</div>
<small class="faint pull-up">{{ t('nc.wc.agentHint') }}</small>
<div class="f"><label for="nc-wproxy">{{ t('nc.wc.proxy') }}</label><input id="nc-wproxy" class="input addr" v-model="c.proxy_base" :placeholder="t('nc.ph.wcProxy')" />
<small class="faint">{{ t('nc.wc.proxyHint') }}</small></div>
<div class="three e">
<div class="f"><label for="nc-tu">{{ t('nc.wc.toUser') }}</label><input id="nc-tu" class="input" v-model="c.to_user" :placeholder="t('nc.ph.toUser')" /></div>
<div class="f"><label for="nc-tp">{{ t('nc.wc.toParty') }}</label><input id="nc-tp" class="input" v-model="c.to_party" :placeholder="t('nc.ph.toParty')" /></div>
<div class="f"><label for="nc-tt">{{ t('nc.wc.toTag') }}</label><input id="nc-tt" class="input" v-model="c.to_tag" :placeholder="t('nc.ph.toTag')" /></div>
</div>
</template>
<div class="two">
<div class="f"><label for="nc-mt">{{ t('nc.wc.msgType') }}</label>
<select id="nc-mt" class="input" v-model="c.msg_type">
<option value="text">{{ t('nc.wc.msgText') }}</option>
<option value="markdown">{{ t('nc.wc.msgMd') }}</option>
<option value="textcard">{{ t('nc.wc.msgCard') }}</option>
</select></div>
<div class="f" v-if="c.msg_type === 'textcard'"><label for="nc-card">{{ t('nc.wc.cardUrl') }}</label>
<input id="nc-card" class="input addr" v-model="c.card_url" :placeholder="t('nc.ph.cardUrl')" /></div>
</div>
<div class="row" v-if="c.mode === 'app'">
<button class="btn sm" type="button" :disabled="checking" @click="wecomCheck">{{ t('nc.wc.check') }}</button>
</div>
<!-- 配置说明 -->
<div class="help" :class="{ open: wecomHelp }">
<button type="button" class="help-h" :aria-expanded="wecomHelp" @click="wecomHelp = !wecomHelp">
<span>{{ t('nc.wc.guide') }}</span><Icon name="chevron" :size="14" class="chev" />
</button>
<div v-show="wecomHelp" class="help-b">
<!-- 说明文案含 <b> 标记,来自 i18n 静态词条(非用户输入),故用 v-html 渲染 -->
<ol><li v-for="(s, i) in wecomSteps" :key="i" v-html="s" /></ol>
<template v-if="c.mode === 'app'">
<p class="warn">{{ t('nc.wc.ipWarn') }}</p>
<details>
<summary>{{ t('nc.wc.proxyExample') }}</summary>
<pre>{{ nginxSnippet }}</pre>
<p class="faint">{{ t('nc.wc.socatTip') }}</p>
<pre>{{ socatSnippet }}</pre>
</details>
<p class="faint">{{ t('nc.wc.aesTip') }}</p>
</template>
<p class="faint">{{ t('nc.wc.ref') }}
<a class="link" href="https://github.com/guyue2005/CMSHelp/wiki/8.%E6%B6%88%E6%81%AF%E9%85%8D%E7%BD%AE" target="_blank" rel="noreferrer">CMSHelp Wiki · 消息配置</a></p>
</div>
</div>
</template>
<!-- 钉钉 -->
<template v-else-if="cur === 'dingtalk'">
<div class="f"><label for="nc-durl">{{ t('nc.f.robotUrl') }}</label><input id="nc-durl" class="input addr" v-model="c.url" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." /></div>
<div class="f"><label for="nc-dsec">{{ t('nc.f.signSecret') }}</label><input id="nc-dsec" class="input addr" v-model="c.secret" :placeholder="t('nc.ph.dingSecret')" />
<small class="faint">{{ t('nc.h.dingtalk') }}</small></div>
</template>
<!-- Discord / Slack -->
<template v-else-if="cur === 'discord' || cur === 'slack'">
<div class="f"><label for="nc-hook">Webhook URL</label>
<input id="nc-hook" class="input addr" v-model="c.url" :placeholder="cur === 'discord' ? 'https://discord.com/api/webhooks/...' : 'https://hooks.slack.com/services/...'" />
<small class="faint">{{ t('nc.h.' + cur) }}</small></div>
</template>
<!-- ntfy -->
<template v-else-if="cur === 'ntfy'">
<div class="two">
<div class="f"><label for="nc-ns">{{ t('nc.f.server') }}</label><input id="nc-ns" class="input addr" v-model="c.server" placeholder="https://ntfy.sh" /></div>
<div class="f"><label for="nc-nt">Topic</label><input id="nc-nt" class="input" v-model="c.topic" placeholder="your-topic" /></div>
</div>
<div class="f"><label for="nc-nk">{{ t('nc.f.tokenOptional') }}</label><input id="nc-nk" class="input addr" v-model="c.token" :placeholder="t('nc.ph.ntfyToken')" />
<small class="faint">{{ t('nc.h.ntfy') }}</small></div>
</template>
<!-- Gotify -->
<template v-else-if="cur === 'gotify'">
<div class="two">
<div class="f"><label for="nc-gs">{{ t('nc.f.server') }}</label><input id="nc-gs" class="input addr" v-model="c.server" placeholder="https://gotify.example.com" /></div>
<div class="f"><label for="nc-gt">App Token</label><input id="nc-gt" class="input addr" v-model="c.token" :placeholder="t('nc.ph.gotifyToken')" /></div>
</div>
<div class="f narrow"><label for="nc-gp">{{ t('nc.f.priority') }}</label><input id="nc-gp" class="input" type="number" v-model.number="c.priority" /></div>
</template>
<!-- Webhook -->
<template v-else-if="cur === 'webhook'">
<div class="f">
<div class="f-h"><label>{{ t('nc.f.targetUrls') }}</label><button class="btn sm" type="button" @click="c.urls.push('')">+ URL</button></div>
<div v-if="!c.urls.length" class="dash small faint">{{ t('nc.noWebhookUrl') }}</div>
<div v-for="(u, i) in c.urls" :key="i" class="row">
<input class="input addr grow" v-model="c.urls[i]" placeholder="https://example.com/hook" :aria-label="t('nc.f.targetUrls')" />
<button class="btn sm ghost" type="button" @click="c.urls.splice(i, 1)" :aria-label="t('common.delete')"><Icon name="close" :size="14" /></button>
</div>
</div>
<div class="f"><label for="nc-wsec">{{ t('nc.f.whSecret') }}</label><input id="nc-wsec" class="input addr" v-model="c.secret" :placeholder="t('nc.ph.whSecret')" />
<small class="faint">{{ t('nc.h.whSecret') }}</small></div>
<div class="f">
<div class="f-h"><label>{{ t('nc.f.whHeaders') }}</label><button class="btn sm" type="button" @click="c.headers.push({ key: '', value: '' })">+ Header</button></div>
<div v-for="(h, i) in c.headers" :key="i" class="row">
<input class="input" v-model="h.key" placeholder="Authorization" aria-label="Header" />
<input class="input grow" v-model="h.value" :placeholder="t('nc.ph.value')" aria-label="Value" />
<button class="btn sm ghost" type="button" @click="c.headers.splice(i, 1)" :aria-label="t('common.delete')"><Icon name="close" :size="14" /></button>
</div>
</div>
<div class="f"><label for="nc-tpl">{{ t('nc.f.whTemplate') }}</label>
<textarea id="nc-tpl" class="input code" rows="2" v-model="c.template" :placeholder="tplPlaceholder" />
<small class="faint">{{ t('nc.h.whTemplate') }}</small></div>
<div class="two">
<div class="f"><label for="nc-to-ms">{{ t('nc.f.whTimeout') }}</label><input id="nc-to-ms" class="input" type="number" v-model.number="c.timeout_ms" /></div>
<div class="f"><label for="nc-retry">{{ t('nc.f.whRetries') }}</label><input id="nc-retry" class="input" type="number" min="0" max="10" v-model.number="c.max_retries" /></div>
</div>
</template>
<div class="res small" v-if="msg" role="status" :class="msgOk ? 'okc' : 'err'">{{ msg }}</div>
<div class="res small faint" v-else-if="last[cur]">{{ t('nc.last', { t: ago(last[cur].ts) }) }}:<span :class="last[cur].ok ? 'okc' : 'err'">{{ last[cur].ok ? t('nc.ok') : last[cur].error }}</span></div>
</div>
@@ -139,16 +266,35 @@ const { t } = useI18n()
const cfg = ref(null)
const types = ref([])
const last = ref({})
const cur = ref('wecom')
const cur = ref('telegram')
const quiet = reactive({ from: '', to: '' })
const err = ref('')
const saved = ref(false)
const saving = ref(false)
const testing = ref(false)
const checking = ref(false)
const wecomHelp = ref(false)
const msg = ref('')
const msgOk = ref(false)
const c = computed(() => cfg.value[cur.value])
const tplPlaceholder = '{{subject}} {{text}}'
const wecomSteps = computed(() => (c.value.mode === 'app' ? [1, 2, 3, 4, 5, 6].map(i => t(`nc.wc.step${i}`)) : [1, 2, 3].map(i => t(`nc.wc.rstep${i}`))))
const nginxSnippet = `location ^~ /cgi-bin/gettoken { proxy_pass https://qyapi.weixin.qq.com; }
location ^~ /cgi-bin/message/send { proxy_pass https://qyapi.weixin.qq.com; }
location ^~ /cgi-bin/agent/get { proxy_pass https://qyapi.weixin.qq.com; }`
const socatSnippet = `services:
wxproxy:
image: alpine/socat
command: TCP-LISTEN:9090,fork,reuseaddr TCP:qyapi.weixin.qq.com:443
ports: ["8543:9090"]
restart: always`
watch(cur, () => { msg.value = '' })
function flash(good, text) {
msgOk.value = good
msg.value = (good ? '✓ ' : '⚠ ') + text
setTimeout(() => (msg.value = ''), 6000)
}
function fill(r) {
cfg.value = r.config
types.value = r.types
@@ -161,66 +307,102 @@ function fill(r) {
async function load() { fill(await http.get('/audit/notify')) }
async function save() {
err.value = ''
saving.value = true
try {
fill(await http.put('/audit/notify', { config: cfg.value, quiet_from: quiet.from, quiet_to: quiet.to }))
saved.value = true
setTimeout(() => (saved.value = false), 1500)
emit('saved', Object.keys(cfg.value).filter(k => cfg.value[k].enabled))
} catch (e) { err.value = e.message }
} catch (e) { err.value = e.message } finally { saving.value = false }
}
async function test() {
msg.value = ''
testing.value = true
try {
await http.post('/audit/notify/test', { channel: cur.value, config: c.value }, { timeout: 60000 })
msg.value = '✓ ' + t('nc.sent')
msgOk.value = true
} catch (e) {
msg.value = '⚠ ' + e.message
msgOk.value = false
} finally {
testing.value = false
setTimeout(() => (msg.value = ''), 6000)
}
flash(true, t('nc.sent'))
} catch (e) { flash(false, e.message) } finally { testing.value = false }
}
async function tgAction(kind) {
msg.value = ''
try {
const r = await http.post(`/audit/notify/telegram/${kind}`, { channel: 'telegram', config: c.value })
if (kind === 'me') return flash(true, `${t('nc.botOk')}: @${r.result && r.result.username}`)
const ids = (r.result || []).map(u => (u.message || u.channel_post || {}).chat).filter(Boolean).map(x => x.id)
if (!ids.length) return flash(false, t('nc.noUpdates'))
c.value.chat_id = String(ids[ids.length - 1])
flash(true, 'Chat IDs: ' + [...new Set(ids)].join(', '))
} catch (e) { flash(false, e.message) }
}
async function wecomCheck() {
msg.value = ''
checking.value = true
try {
const a = (await http.post('/audit/notify/wecom/check', { channel: 'wecom', config: c.value })).agent || {}
flash(true, `${t('nc.wc.checkOk')}「${a.name}」· AgentId ${a.agentid} · ${t('nc.wc.scope')}: ${a.users} / ${a.parties} / ${a.tags}`)
} catch (e) { flash(false, e.message) } finally { checking.value = false }
}
onMounted(load)
</script>
<style scoped>
.hint { margin: 0 0 10px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 8px; }
.tile { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 6px; background: var(--sunk); border: 1px solid transparent;
border-radius: 10px; padding: 9px 12px; font: inherit; font-size: 13px; color: var(--ink); cursor: pointer; text-align: left; }
border-radius: 10px; padding: 9px 12px; font: inherit; font-size: 13px; color: var(--ink); cursor: pointer; text-align: left; min-height: 40px; }
.tile.on { border-color: var(--brand-ink); background: var(--brand-weak); color: var(--brand-ink); }
.tile-n { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st { width: 8px; height: 8px; border-radius: 50%; background: var(--line-strong); flex: none; }
.st.ok { background: var(--ok); }
.st.bad { box-shadow: 0 0 0 2px color-mix(in srgb, var(--bad) 45%, transparent); }
.pane { margin-top: 14px; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; display: grid; gap: 8px; }
.pane-h { display: flex; align-items: center; gap: 10px; }
.pane { margin-top: 14px; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
.pane-h { display: flex; align-items: center; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.pane-h h3 { margin: 0; font-size: 15px; }
.pane-h .sp { flex: 1; }
.pane > label, .pane label[for] { font-size: 13px; color: var(--muted); }
.two, .three { display: grid; gap: 10px; }
.two { grid-template-columns: 1fr 1fr; }
.three { grid-template-columns: 2fr 1fr 1fr; }
.two > div, .three > div { display: grid; gap: 4px; min-width: 0; }
.row { display: flex; gap: 8px; align-items: center; }
.f { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.f label { font-size: 13px; color: var(--muted); }
.f small, .pane > small { font-size: 12.5px; line-height: 1.55; overflow-wrap: anywhere; }
.f.narrow { max-width: 160px; }
.f-h { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.pull-up { margin-top: -6px; }
.two, .three { display: grid; gap: 12px; }
.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.three { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; align-items: end; }
.three.e { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.grow { flex: 1; min-width: 0; }
.w { width: 120px; }
.dash { border: 1px dashed var(--line-strong); border-radius: 8px; padding: 10px; text-align: center; }
.help { line-height: 1.6; }
.res { margin-top: 4px; }
.res { margin-top: 2px; overflow-wrap: anywhere; }
.okc { color: var(--ok); }
.code { font-family: var(--mono, monospace); font-size: 12px; }
.sw { position: relative; width: 38px; height: 22px; flex: none; cursor: pointer; }
.seg.wide { display: flex; width: 100%; }
.seg.wide button { flex: 1; white-space: normal; line-height: 1.3; }
.sw { position: relative; width: 38px; height: 22px; flex: none; cursor: pointer; display: inline-block; }
.sw input { opacity: 0; width: 0; height: 0; }
.sw span { position: absolute; inset: 0; background: var(--line-strong); border-radius: 11px; transition: background .15s; }
.sw span::after { content: ""; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
.sw input:checked + span { background: var(--ok); }
.sw input:checked + span::after { transform: translateX(16px); }
.sw input:focus-visible + span { outline: 2px solid var(--brand-ink); outline-offset: 2px; }
.help { border: 1px solid var(--line); border-radius: 10px; }
.help-h { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; min-height: 40px; border: 0; background: transparent;
padding: 0 14px; cursor: pointer; font: inherit; font-size: 13px; color: var(--muted); text-align: left; }
.help-h:hover { color: var(--ink); }
.help .chev { transition: transform .2s; }
.help.open .chev { transform: rotate(90deg); }
.help-b { padding: 2px 14px 14px; font-size: 13px; line-height: 1.75; color: var(--muted); overflow-wrap: anywhere; }
.help-b ol { margin: 0 0 10px; padding-left: 20px; }
.help-b :deep(b) { color: var(--ink); font-weight: 600; }
.help-b .warn { margin: 0 0 10px; padding: 9px 11px; border-radius: 8px; background: color-mix(in srgb, var(--warn, #d97706) 14%, transparent); color: var(--ink); }
.help-b summary { cursor: pointer; color: var(--ink); min-height: 32px; display: flex; align-items: center; }
.help-b pre { margin: 8px 0; padding: 10px 12px; border-radius: 8px; background: var(--sunk); border: 1px solid var(--line); overflow-x: auto;
font-size: 12px; line-height: 1.6; color: var(--ink); white-space: pre; font-family: var(--mono, monospace); max-width: 100%; }
.help-b p { margin: 6px 0 0; }
.quiet { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 14px; }
@media (max-width: 640px) {
.tiles { grid-template-columns: repeat(2, 1fr); }
.two, .three { grid-template-columns: 1fr; }
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.two, .three, .three.e { grid-template-columns: minmax(0, 1fr); }
.f.narrow { max-width: none; }
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<!-- 使用场景(全局):家庭 / 企业。切换后菜单、人员页、审计模板的称呼和默认值一起变 -->
<div class="panel">
<div class="panel-h"><h2>{{ t('people.scenario') }}</h2><span class="faint small">{{ t('people.scenarioHint') }}</span></div>
<div class="panel-b">
<div class="scn" role="radiogroup" :aria-label="t('people.scenario')">
<label v-for="k in ['home', 'enterprise']" :key="k" class="scn-c" :class="{ on: scene.id === k }">
<input type="radio" name="scene" :value="k" :checked="scene.id === k" :disabled="busy" @change="pick(k)" />
<Icon :name="k === 'home' ? 'people' : 'users'" :size="22" />
<div>
<b>{{ t('scn.' + k + '.name') }}</b>
<span class="faint small">{{ t('scn.' + k + '.desc') }}</span>
<span class="small words">{{ t('scn.' + k + '.nav') }} · {{ t('scn.' + k + '.dept') }} · {{ t('scn.' + k + '.workTime') }}</span>
</div>
</label>
</div>
<div class="small okc" v-if="done" role="status">{{ t('people.sceneSwitched', { x: t('scn.' + scene.id + '.name') }) }}</div>
<div class="err" v-if="err">{{ err }}</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { scene, setScene } from '../scenario.js'
import Icon from './Icon.vue'
const emit = defineEmits(['changed'])
const { t } = useI18n()
const busy = ref(false)
const done = ref(false)
const err = ref('')
async function pick(k) {
if (k === scene.id) return
err.value = ''
busy.value = true
try {
emit('changed', await setScene(k))
done.value = true
setTimeout(() => (done.value = false), 3000)
} catch (e) { err.value = e.message } finally { busy.value = false }
}
</script>
<style scoped>
.scn { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
.scn-c { display: flex; gap: 12px; align-items: flex-start; border: 1px solid var(--line); border-radius: 12px; padding: 14px; cursor: pointer; }
.scn-c input { position: absolute; opacity: 0; pointer-events: none; }
.scn-c:focus-within { outline: 2px solid var(--brand-ink); outline-offset: 2px; }
.scn-c :deep(svg) { color: var(--brand-ink); flex: none; margin-top: 2px; }
.scn-c div { display: grid; gap: 4px; }
.scn-c.on { border-color: var(--brand-ink); background: var(--brand-weak); }
.words { color: var(--muted); }
.okc { color: var(--ok); margin-top: 10px; }
.err { margin-top: 10px; }
</style>
+70 -37
View File
@@ -119,7 +119,7 @@ export default {
desc: { curated: 'Hand-maintained popular apps with Chinese names and fine categories; highest priority; updated with each release.',
v2fly: 'Domain lists from v2fly/domain-list-community, maintained daily by the community; converted into apps and categories.',
asn: 'IP range ownership from iptoasn.com, updated hourly; attributes domain-less traffic to the owning company.',
cnlist: 'Chinese domains from dnsmasq-china-list (~80k): unmatched domains in the list become "Chinese websites".',
cnlist: 'Chinese domains from dnsmasq-china-list (~110k): unmatched domains in the list become "Chinese websites".',
google: 'All Google ranges as published officially: domain-less traffic becomes Google (incl. YouTube). Also the Apple range 17.0.0.0/8 registered by IANA.',
cloudflare: 'Cloudflare ranges as published officially: domain-less traffic becomes Cloudflare CDN.',
aws: 'AWS ip-ranges.json as published officially: domain-less traffic becomes AWS/CloudFront.',
@@ -128,6 +128,8 @@ export default {
fromUpdate: 'Auto-updated', fromImage: 'Bundled with image', ranges: '{n} ranges', asnApps: '{n} ASNs mapped to apps',
apps: '{n} apps', domains: '{n} domains', withRelease: 'Ships with releases', never: 'Never checked', notLoaded: 'Not loaded',
checked: 'Checked {t}', result: { updated: 'Updated', uptodate: 'Up to date', rejected: 'Self-test failed, kept old', failed: 'Check failed', loaded: 'Loaded' },
orderTitle: 'Match order: ', orderTip: 'Rows are listed in match order; once a source matches, later ones are skipped',
order: 'Custom rules → built-in rules → v2fly domains (exact, then longest suffix) → dedicated ports → IP owner (ASN) → China domain list → official IP ranges / China IP ranges (the more specific range wins on overlap) → generic ports (HTTPS etc.). With a domain, all domain sources are tried before any IP source; later sources are coarser fallbacks.',
note: 'Auto update checks the latest v2fly commit once a day, converts it and runs a self-test (sample domains must match; the app count must not collapse) before switching over live, without a restart. On failure or without internet, the current rules stay. Custom rules always win.',
},
sources: {
@@ -214,13 +216,14 @@ export default {
unassigned: 'Unassigned devices', unassignedHint: '{n} devices; pick a person to assign', dtype: 'Device type', dtypeAuto: 'Auto ({t})', note: 'Note',
addDevice: 'Add device…', unbind: 'Remove', last7: 'Last 7 days', days: '{n} days', nobody: 'Nobody', game: 'Gaming', shortVideo: 'Short video',
night: 'Late night', nightShort: 'late night', appTime: 'Time per app', duration: 'Time', leisureTag: 'Leisure', noTags: 'Nothing unusual in the last 7 days',
scenario: 'Scenario', scenarioHint: 'Sets the wording, default hours, tags and audit templates', addRange: 'Add time range', nightWin: 'Late-night window',
scenario: 'Scenario', scenarioHint: 'Global: sets the wording in menus and pages (family members / employees), default hours, behaviour tags and audit templates',
sceneNow: 'Scenario: {x}', sceneGo: 'Change it in Settings → Scenario & network', sceneSwitched: 'Switched to {x}; wording, tags and audit templates updated', addRange: 'Add time range', nightWin: 'Late-night window',
leisure: 'Counted as leisure', limits: 'Daily limits', lim: { screen: 'Screen time', game: 'Gaming', short_video: 'Short video' }, hoursUnit: 'h',
limitsHint: 'Days over a limit become tags; 0 means no limit.',
},
scn: {
home: {
name: 'Home', desc: 'Children\'s online health: screen time, gaming and short-video time, late nights, adult content, leisure during homework.',
name: 'Home', badge: 'Home', desc: 'Children\'s online health: screen time, gaming and short-video time, late nights, adult content, leisure during homework.',
nav: 'Family', title: 'Family members', person: 'Member', dept: 'Group', add: 'Add member', edit: 'Edit member', manageDept: 'Manage groups',
deptName: 'Group name, e.g. Kids', noDept: 'No group', pickDept: 'Pick a group', newDept: 'New group', allPeople: 'All members', assignTo: 'Assign to',
empty: 'No family members yet. Add one and assign their phone, tablet and computer to see per-person screen time and health alerts.',
@@ -231,15 +234,15 @@ export default {
tab: { list: 'Family', insight: 'Health alerts', settings: 'Settings' },
},
enterprise: {
name: 'Business', desc: 'Productivity and compliance: leisure at work, job hunting, trading at work, large uploads, rogue routers.',
nav: 'People', title: 'People', person: 'Person', dept: 'Department', add: 'Add person', edit: 'Edit person', manageDept: 'Manage departments',
deptName: 'Department name, e.g. R&D', noDept: 'No department', pickDept: 'Pick a department', newDept: 'New department', allPeople: 'Everyone', assignTo: 'Assign to',
empty: 'No people yet. Add a person and assign their computer and phone to report and audit per person.',
name: 'Business', badge: 'Business', desc: 'Productivity and compliance: leisure at work, job hunting, trading at work, large uploads, rogue routers.',
nav: 'Employees', title: 'Employees', person: 'Employee', dept: 'Department', add: 'Add employee', edit: 'Edit employee', manageDept: 'Manage departments',
deptName: 'Department name, e.g. R&D', noDept: 'No department', pickDept: 'Pick a department', newDept: 'New department', allPeople: 'All employees', assignTo: 'Assign to',
empty: 'No employees yet. Add an employee and assign their computer and phone to report and audit per person.',
info: 'Group each person\'s devices to see traffic, online time, leisure share during working hours and behaviour tags for the last 7 days (modelled on Sangfor AC productivity and risk analysis).',
insightInfo: 'Behaviour tags and productivity for each person over the last 7 days, based only on which apps were used and for how long. Hover a tag for details.',
workTime: 'Working hours', workTimeShort: 'In working hours', workHint: 'Leisure apps used in working hours count towards the leisure share',
workOnline: 'Online in working hours', leisureShare: 'Leisure at work', leisureInWork: 'Leisure at work', rankTitle: 'Productivity ranking',
tab: { list: 'People', insight: 'Insights', settings: 'Settings' },
tab: { list: 'Employees', insight: 'Insights', settings: 'Settings' },
},
},
tag: {
@@ -254,35 +257,65 @@ export default {
},
dev: { phone: 'Phone', tablet: 'Tablet', computer: 'Computer', tv: 'TV', console: 'Game console', camera: 'Camera', iot: 'Smart device', nas: 'NAS / server', network: 'Network device', unknown: 'Unknown' },
nc: {
title: 'Notification channels', hint: 'Audit alerts and daily/weekly reports go here; a rule can pick just some of them', save: 'Save notifications', enable: 'Enable', enableX: 'Enable {x}',
test: 'Send test', testing: 'Sending…', sent: 'Sent', ok: 'OK', last: 'Last sent ({t})', noUrl: 'No URL yet; add one with +URL below',
quiet: 'Quiet hours', quietHint: 'Alerts in this window are queued and sent as one summary afterwards; leave empty to disable',
tplHint: 'Variables subject, text, timestamp in double curly braces; leave empty to send the text as is',
types: { wecom: 'WeCom', dingtalk: 'DingTalk', feishu: 'Feishu / Lark', bark: 'Bark', telegram: 'Telegram', email: 'Email', pushplus: 'PushPlus',
serverchan: 'ServerChan', ntfy: 'ntfy', gotify: 'Gotify', discord: 'Discord', slack: 'Slack', webhook: 'Webhook' },
help: {
wecom: 'WeCom group → Group settings → Message push (group bot) → Add, then copy the webhook URL.',
dingtalk: 'DingTalk group → Settings → Bots → Add custom bot. If you chose "Additional signature", paste the SEC… secret below.',
feishu: 'Feishu group → Settings → Group bots → Add custom bot. Paste the secret below if signature verification is on.',
bark: 'Install Bark on the iPhone and copy its push URL (https://api.day.app/your-key). Several are allowed.',
telegram: "Create a bot with {'@'}BotFather to get the token; message the bot, then get your chat ID from {'@'}userinfobot. You can point the API base at a reverse proxy.",
email: 'For QQ / 163 mail, enable SMTP in the mailbox settings and use the authorisation code as the password. Separate recipients with semicolons.',
pushplus: 'Copy your token from pushplus.plus; fill in the topic code for group pushes.',
serverchan: 'Copy the SendKey from sct.ftqq.com (ServerChan³ keys starting with sctp also work).',
ntfy: 'Subscribe to the same topic in the ntfy app; change the server if you self-host.',
gotify: 'Create an application in Gotify and copy its token.',
discord: 'Channel settings → Integrations → Webhooks → New, then copy the URL.',
slack: 'Enable Incoming Webhooks in your Slack app and copy the URL.',
webhook: 'POSTs JSON (fields subject, text, event, timestamp) to these URLs. With a secret, an X-ROSDPI-Signature header (HMAC-SHA256) is added.',
title: 'Notification channels', hint: 'Audit alerts and daily/weekly reports go here; a rule can pick just some of them. Channels and fields match EasySub.', save: 'Save notifications',
test: 'Test', testing: 'Sending…', sent: 'Test notification sent', ok: 'OK', last: 'Last sent ({t})',
quiet: 'Do not disturb', quietHint: 'Alerts in this window are held and sent as one summary when it ends; leave empty to disable',
checkBot: 'Check bot', getUpdates: 'Get Chat ID', botOk: 'Bot OK', noUpdates: 'No messages yet: send the bot a message first',
noBarkUrl: 'No Bark URL yet — click Add on the right.', noWebhookUrl: 'No Webhook URL yet — click Add on the right.',
types: { telegram: 'Telegram Bot', feishu: 'Feishu Bot', qq: 'QQ Bot', bark: 'Bark', email: 'Email', pushplus: 'Pushplus',
serverchan: 'ServerChan', wecom: 'WeCom', dingtalk: 'DingTalk', discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', gotify: 'Gotify', webhook: 'Webhook' },
enable: { telegram: 'Enable Telegram bot', feishu: 'Enable Feishu bot', qq: 'Enable QQ bot', bark: 'Enable Bark', email: 'Enable Email',
pushplus: 'Enable Pushplus', serverchan: 'Enable ServerChan', wecom: 'Enable WeCom', dingtalk: 'Enable DingTalk bot', discord: 'Enable Discord',
slack: 'Enable Slack', ntfy: 'Enable ntfy', gotify: 'Enable Gotify', webhook: 'Enable Webhook' },
f: { tgApiBase: 'TG API proxy (optional)', httpProxy: 'HTTP proxy (optional)', qqGroups: 'GROUP IDS', qqUsers: 'USER IDS',
targetUrls: 'Target URLs', barkGroup: 'Group', barkLevel: 'Level', host: 'host', port: 'port', username: 'Username', password: 'Password',
from: 'From', to: 'To', ppTopic: 'Topic', ppChannel: 'Channel', robotUrl: 'Robot Webhook',
signSecret: 'Sign secret', server: 'Server', tokenOptional: 'Token (optional)', priority: 'Priority',
whSecret: 'Signing secret', whHeaders: 'Custom headers', whTemplate: 'Text template', whTimeout: 'Timeout (ms)', whRetries: 'Max retries' },
ph: { chatId: '-100xxxx / number', feishuChats: 'oc_xxxx, comma-separated', qqGroups: 'Group OpenIDs, comma-separated', qqUsers: 'User OpenIDs, comma-separated',
bark: 'https://api.day.app/yourKey', icon: 'Icon URL, optional', mailUser: 'Mail account', mailPw: 'Password or app code', mailTo: 'Comma-separate multiple recipients',
ppToken: 'Pushplus user token', ppTopic: 'Group code; empty = yourself', dingSecret: 'SEC... sign secret, optional', ntfyToken: 'tk_... access token, optional',
gotifyToken: 'App token', whSecret: 'HMAC-SHA256 secret, optional', value: 'Value', wcProxy: 'https://qyapi.weixin.qq.com (empty = official)',
toUser: "{'@'}all or UserIDs separated by {'|'}", toParty: 'Department ID, optional', toTag: 'Tag ID, optional', cardUrl: 'Link when the card is tapped, e.g. https://your-host:8443/audit' },
pp: { wechat: 'WeChat (wechat)', mail: 'Mail (mail)', cp: 'WeCom (cp)' },
h: {
feishu: 'Feishu chat ID (oc_xxxx) from the open platform; comma-separate multiple groups.',
qq: 'After messaging the bot, find the OpenID in the QQ open platform message log; the bot only pushes to matching chats.',
serverchan: 'Get your SendKey at sct.ftqq.com (ServerChan Turbo; ServerChan³ keys starting with sctp also work).',
wecom: 'WeCom group → top-right → Group robots → Add robot → copy the Webhook URL.',
dingtalk: 'DingTalk group → add custom robot; recommended security: sign, put the secret above.',
discord: 'Channel settings → Integrations → Webhooks → copy the Webhook URL.', slack: 'Slack App → Incoming Webhooks → copy the Webhook URL.',
ntfy: 'Self-hosted or public ntfy.sh; subscribe to the same topic to receive pushes.',
whSecret: 'If set, payload is signed via the X-ROSDPI-Signature header.',
whTemplate: 'Placeholders: text / subject / event / timestamp (wrap each in double curly braces, see the input example). Empty = raw text.',
},
wc: {
mode: 'Delivery mode', robot: 'Group robot', app: 'Self-built app (recommended)',
robotTip: 'Simplest: just one group-robot Webhook URL. Alerts land in that group; no admin rights needed.',
appTip: 'Same fields as the CMSHelp WeCom setup: CorpID + AgentId + Secret. Targets specific members / departments / tags, and with the WeChat plugin you can receive alerts in WeChat itself.',
corpId: 'Corp ID', agentId: 'Agent ID', secret: 'App secret',
corpIdHint: 'WeCom admin console → My Company → Company info; the Corp ID is at the bottom of the page.',
agentHint: 'App management → Apps → Custom → open your app; AgentId and Secret are shown there.',
proxy: 'API proxy (optional)',
proxyHint: 'Empty = official qyapi.weixin.qq.com. If your egress IP is dynamic or cannot be allowlisted, enter your own reverse proxy (must proxy /cgi-bin/gettoken and /cgi-bin/message/send).',
toUser: 'To user', toParty: 'To department', toTag: 'To tag',
msgType: 'Message type', msgText: 'Text (universal)', msgMd: 'Markdown (WeCom client only)', msgCard: 'Text card (clickable)', cardUrl: 'Card link URL',
check: 'Check app', checkOk: 'App connected:', scope: 'scope users/departments/tags', guide: 'WeCom setup guide (click to expand)',
rstep1: 'In WeCom open the target group → “···” at the top right → <b>Group robots</b> → <b>Add robot</b>, and give it a name (e.g. ROS-DPI).',
rstep2: 'Copy the generated <b>Webhook URL</b> (like https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx) into the field above.',
rstep3: 'Turn the switch on and hit <b>Test</b> — a message in the group means it works. Group robots need no admin rights and are not limited by visible scope or IP allowlists.',
step1: 'Sign in to the <b>WeCom admin console</b> (work.weixin.qq.com) → <b>My Company → Company info</b> and copy the <b>Corp ID</b> at the bottom.',
step2: 'Go to <b>App management → Apps → Custom</b>, click “Create app”, upload a logo, name it (e.g. ROS-DPI alerts) and set <b>visible scope</b> to the members/departments that should get alerts.',
step3: 'Open the new app and copy <b>AgentId</b> and <b>Secret</b> into the fields above (clicking “view” on the Secret sends a code to WeCom/WeChat).',
step4: "Set <b>To user</b> to <b>{'@'}all</b> for everyone in scope, or list member <b>UserIDs</b> separated by <b>{'|'}</b> (found on the member page in Contacts). Departments/tags work the same way; leaving all three empty falls back to {'@'}all.",
step5: 'To receive alerts in <b>WeChat</b> itself: <b>My Company → WeChat plugin</b> and scan the QR code — no WeCom client required.',
step6: 'Save, then hit <b>Check app</b> (returns the app name and scope), then <b>Test</b> at the top right to send a real message.',
ipWarn: "Custom apps created after June 2022 are IP-restricted: add this server's public egress IP under “App → Trusted enterprise IPs”. If the IP is dynamic or the server is outside China, use the reverse-proxy / relay below and put its address in “API proxy”.",
proxyExample: 'Reverse-proxy examples (Nginx / Docker socat)',
socatTip: 'Or relay TCP with socat: point qyapi.weixin.qq.com at this host via /etc/hosts. The example maps port 8543, so set API proxy to https://qyapi.weixin.qq.com:8543 .',
aesTip: 'Note: Token and EncodingAESKey are only for WeCom inbound-message callbacks. ROS-DPI only pushes alerts, so you can leave them out.',
ref: 'Field reference:',
},
ph: { wecom: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…', dingtalk: 'https://oapi.dingtalk.com/robot/send?access_token=…',
feishu: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', discord: 'https://discord.com/api/webhooks/…', slack: 'https://hooks.slack.com/services/…',
bark: 'https://api.day.app/your-key', webhook: 'https://example.com/hook' },
msg: { text: 'Plain text', markdown: 'Markdown' },
f: { url: 'Webhook URL', urls: 'URLs', signSecret: 'Signing secret', optional: 'Optional', msgType: 'Message format', group: 'Group', level: 'Level',
hmac: 'Signing secret (HMAC)', headers: 'Custom headers', template: 'Text template', retries: 'Retries', apiBase: 'API base (reverse proxy allowed)',
smtpHost: 'SMTP server', port: 'Port', security: 'Encryption', none: 'None', authCode: 'Password or app code', from: 'From address', fromHint: 'Defaults to the username',
to: 'To', topic: 'Topic / group', server: 'Server', appToken: 'App token', priority: 'Priority' },
},
cp: {
syslog: 'Syslog forwarding', syslogHint: 'Sends the access log and audit events to a log platform (Graylog, ELK, a SIEM…) as they happen: one line per visit when it ends.',
@@ -299,7 +332,7 @@ export default {
quic: 'Also block QUIC (udp/443) for these devices; otherwise apps using QUIC get through', generate: 'Generate', script: 'Commands', undo: 'Undo', copy: 'Copy', copied: 'Copied',
},
settings: {
tab: { status: 'Status', access: 'Setup & check', sources: 'External sources', system: 'Network & account', users: 'Users', compliance: 'Logging & retention', account: 'My account' },
tab: { status: 'Status', access: 'Setup & check', sources: 'External sources', system: 'Scenario & network', users: 'Users', compliance: 'Logging & retention', account: 'My account' },
title: 'Settings', network: 'Network', lan: 'LAN ranges', lanHint: 'One per line. Addresses in these ranges are treated as LAN hosts. Ranges on non-WAN router interfaces are added automatically (grey).',
lanAuto: 'Auto-detected', wan: 'WAN interface', wanHint: 'Used for the WAN rate. Leave empty to detect from the default route.',
wanDetected: 'Detected: {name}', status: 'Status', collector: 'Flow collector (IPFIX)', dns: 'App identification: DNS + SNI (TZSP)',
+72 -39
View File
@@ -119,7 +119,7 @@ export default {
desc: { curated: '手工维护的常用应用,带中文名和细分类,优先级最高;随 ROS-DPI 版本更新。',
v2fly: 'v2fly/domain-list-community 的域名列表,社区每天维护;转换成应用并按分类归类。',
asn: 'iptoasn.com 的 IP 段归属,每小时更新;没有域名的流量按 IP 认出所属公司。',
cnlist: 'dnsmasq-china-list 的国内域名(约 8 万条):规则库没认出的域名在列表里就归「国内网站」。',
cnlist: 'dnsmasq-china-list 的国内域名(约 11 万条):规则库没认出的域名在列表里就归「国内网站」。',
google: 'Google 官方发布的全部 IP 段:没有域名时认作 Google 服务(含 YouTube)。另含 IANA 注册给 Apple 的 17.0.0.0/8。',
cloudflare: 'Cloudflare 官方发布的 IP 段:没有域名的流量认作 Cloudflare CDN。',
aws: 'AWS 官方发布的 ip-ranges.json:没有域名的流量认作 AWS/CloudFront。',
@@ -128,7 +128,9 @@ export default {
fromUpdate: '自动更新的版本', fromImage: '镜像自带', ranges: '{n} 个地址段', asnApps: '{n} 个 ASN 对应到应用',
apps: '{n} 个应用', domains: '{n} 条域名', withRelease: '随版本发布', never: '还没检查过', notLoaded: '未加载',
checked: '{t}检查', result: { updated: '已更新', uptodate: '已是最新', rejected: '自检未通过,保留旧版', failed: '检查失败', loaded: '已加载' },
note: '自动更新:每天检查一次 v2fly 的最新提交,下载转换后先自检(抽样域名必须识别正确、应用数不能骤减),通过才替换并立即生效,不需要重启;不通过或没有外网时继续用现有规则。自定义规则始终优先。',
orderTitle: '匹配顺序:', orderTip: '表格按匹配顺序排列,前面的库认出来就不再往下查',
order: '自定义规则 → 内置规则库 → v2fly 域名库(先精确、再最长后缀)→ 专用端口 → IP 归属库(ASN)→ 国内域名列表 → 官方 IP 段 / 国内 IP 段(IP 段重叠时更具体的小段优先)→ 通用端口(HTTPS 等)。有域名时先查完所有域名库再按 IP,越往后越粗略,只做兜底。',
note: '自动更新:v2fly 每天检查一次最新提交,下载转换后先自检(抽样域名必须识别正确、应用数不能骤减),通过才替换并立即生效,不需要重启;ASN 库、国内域名列表和各家官方 IP 段也每天自动下载,单个来源失败时继续用旧数据。可以关掉「自动更新」改为手动点「立即检查更新」。',
},
sources: {
title: '外部数据源', hint: '可选。家里有旁路代理(sing-box)或内网 DNS(MosDNS)时接入,识别会准很多;地址和密钥按你自己的环境填写',
@@ -214,13 +216,14 @@ export default {
unassigned: '未分配的设备', unassignedHint: '共 {n} 台,选一个人就归给他', dtype: '终端类型', dtypeAuto: '自动识别({t})', note: '备注',
addDevice: '添加设备…', unbind: '移出', last7: '最近 7 天', days: '{n} 天', nobody: '没有人', game: '游戏', shortVideo: '短视频',
night: '深夜', nightShort: '其中深夜', appTime: '应用使用时长', duration: '时长', leisureTag: '娱乐', noTags: '最近 7 天没有异常',
scenario: '使用场景', scenarioHint: '决定称呼、默认的时间段、标签和审计模板', addRange: '添加时间段', nightWin: '深夜时段(熬夜)',
scenario: '使用场景', scenarioHint: '全局设置:决定菜单和页面上的称呼(家庭成员 / 员工)、默认时间段、行为标签和审计模板',
sceneNow: '当前场景:{x}', sceneGo: '在 系统设置 → 场景与网络 里切换', sceneSwitched: '已切换到{x},称呼、标签和审计模板已同步更新', addRange: '添加时间段', nightWin: '深夜时段(熬夜)',
leisure: '算作娱乐的应用类别', limits: '每日时长上限', lim: { screen: '屏幕时间', game: '游戏', short_video: '短视频' }, hoursUnit: '小时',
limitsHint: '超过上限的天数会记成标签;填 0 表示不限制。',
},
scn: {
home: {
name: '家用', desc: '关注孩子的上网健康:屏幕时间、游戏和短视频时长、熬夜、不良内容、学习时间玩娱乐应用。',
name: '家庭', badge: '家庭场景', desc: '关注孩子的上网健康:屏幕时间、游戏和短视频时长、熬夜、不良内容、学习时间玩娱乐应用。',
nav: '家庭成员', title: '家庭成员', person: '成员', dept: '分组', add: '添加成员', edit: '编辑成员', manageDept: '管理分组',
deptName: '分组名称,例如 孩子', noDept: '未分组', pickDept: '请选择分组', newDept: '新建分组', allPeople: '全部成员', assignTo: '归属成员',
empty: '还没有家庭成员。添加成员并把手机、平板、电脑分给他,就能按人看上网时长和健康提醒。',
@@ -231,15 +234,15 @@ export default {
tab: { list: '家庭成员', insight: '健康提醒', settings: '分析设置' },
},
enterprise: {
name: '企业', desc: '关注工作效率和合规:上班娱乐占比、求职倾向、上班炒股、大流量外发、私接路由。',
nav: '人员', title: '人员', person: '人员', dept: '部门', add: '添加人员', edit: '编辑人员', manageDept: '管理部门',
deptName: '部门名称,例如 研发部', noDept: '未分部门', pickDept: '请选择部门', newDept: '新建部门', allPeople: '全部人员', assignTo: '归属人员',
empty: '还没有人员。添加人员并把他的电脑、手机分给他,就能按人统计和审计。',
info: '把每个人的设备归到一起,按人统计流量、在线时长、工作时间的娱乐占比,以及最近 7 天的行为标签(参照深信服 AC 的效率分析和风险洞察)。',
insightInfo: '最近 7 天每个人的行为标签和工作效率。标签只依据访问了哪些应用、用了多久,悬停可以看依据。',
name: '企业', badge: '企业场景', desc: '关注工作效率和合规:上班娱乐占比、求职倾向、上班炒股、大流量外发、私接路由。',
nav: '员工', title: '员工', person: '员工', dept: '部门', add: '添加员工', edit: '编辑员工', manageDept: '管理部门',
deptName: '部门名称,例如 研发部', noDept: '未分部门', pickDept: '请选择部门', newDept: '新建部门', allPeople: '全部员工', assignTo: '归属员工',
empty: '还没有员工。添加员工并把他的电脑、手机分给他,就能按人统计和审计。',
info: '把每位员工的设备归到一起,按人统计流量、在线时长、工作时间的娱乐占比,以及最近 7 天的行为标签(参照深信服 AC 的效率分析和风险洞察)。',
insightInfo: '最近 7 天每位员工的行为标签和工作效率。标签只依据访问了哪些应用、用了多久,悬停可以看依据。',
workTime: '工作时间', workTimeShort: '工作时间内', workHint: '工作时间里用娱乐应用会被统计为娱乐占比',
workOnline: '工作时间在线', leisureShare: '工作时间娱乐占比', leisureInWork: '工作时间娱乐', rankTitle: '工作效率排行',
tab: { list: '人员', insight: '行为洞察', settings: '分析设置' },
tab: { list: '员工', insight: '行为洞察', settings: '分析设置' },
},
},
tag: {
@@ -254,35 +257,65 @@ export default {
},
dev: { phone: '手机', tablet: '平板', computer: '电脑', tv: '电视', console: '游戏机', camera: '摄像头', iot: '智能设备', nas: 'NAS / 服务器', network: '网络设备', unknown: '未知设备' },
nc: {
title: '通知渠道', hint: '审计提醒和日报、周报发到这里;规则里可以只选其中几个', save: '保存通知配置', enable: '启用', enableX: '启用 {x} 推送',
test: '测试通知', testing: '发送中…', sent: '已发送', ok: '成功', last: '上次发送({t})', noUrl: '还没有地址,点下面的 +URL 添加',
title: '通知渠道', hint: '审计提醒和日报、周报发到这里;规则里可以只选其中几个。渠道、字段与 EasySub 一致。', save: '保存通知配置',
test: '测试通知', testing: '发送中…', sent: '测试通知已发送', ok: '成功', last: '上次发送({t})',
quiet: '免打扰', quietHint: '这段时间的提醒先记下,结束后汇总推送一次;留空表示不启用',
tplHint: '可用变量 subject、text、timestamp(用两层花括号包起来);留空则直接发正文',
types: { wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书', bark: 'Bark', telegram: 'Telegram', email: '邮件', pushplus: 'PushPlus',
serverchan: 'Server酱', ntfy: 'ntfy', gotify: 'Gotify', discord: 'Discord', slack: 'Slack', webhook: 'Webhook' },
help: {
wecom: '企业微信群 → 群设置 → 消息推送(群机器人)→ 添加,复制 Webhook 地址。',
dingtalk: '钉钉群 → 设置 → 机器人 → 添加自定义机器人。安全设置选“加签”时,把 SEC 开头的密钥填到下面。',
feishu: '飞书群 → 设置 → 群机器人 → 添加自定义机器人。开启“签名校验”时把密钥填到下面。',
bark: 'iPhone 安装 Bark,复制 App 里的推送地址(https://api.day.app/你的key),可以填多个。',
telegram: "找 {'@'}BotFather 创建机器人拿到 Token;给机器人发条消息后,用 {'@'}userinfobot 查 Chat ID。国内网络可以填一个 API 反代地址。",
email: 'QQ、163 邮箱要先在邮箱设置里开启 SMTP,用授权码当密码。多个收件人用分号隔开。',
pushplus: '登录 pushplus.plus 复制 Token;群组推送时填群组编码。',
serverchan: '登录 sct.ftqq.com 复制 SendKey(Server酱³ 以 sctp 开头的也可以)。',
ntfy: '在 ntfy App 里订阅同一个主题名;自建服务器时改服务器地址。',
gotify: '在 Gotify 里新建一个应用,复制它的 Token。',
discord: '频道设置 → 整合 → Webhook → 新建,复制地址。',
slack: '在 Slack 应用里开启 Incoming Webhooks,复制地址。',
webhook: '向这些地址 POST JSON(字段 subject、text、event、timestamp)。填了签名密钥会带上 X-ROSDPI-Signature 请求头(HMAC-SHA256)。',
checkBot: '验证机器人', getUpdates: '获取 Chat ID', botOk: '机器人可用', noUpdates: '还没有收到消息:先给机器人发一条消息再点',
noBarkUrl: '尚未配置任何 Bark URL,点右侧添加。', noWebhookUrl: '尚未配置任何 Webhook URL,点右侧添加。',
types: { telegram: 'Telegram Bot', feishu: '飞书 Bot', qq: 'QQ Bot', bark: 'Bark', email: 'Email', pushplus: 'Pushplus',
serverchan: 'Server酱', wecom: '企业微信', dingtalk: '钉钉', discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', gotify: 'Gotify', webhook: 'Webhook' },
enable: { telegram: '启用 Telegram 机器人', feishu: '启用飞书机器人', qq: '启用 QQ 机器人', bark: '启用 Bark 推送', email: '启用 Email 推送',
pushplus: '启用 Pushplus 推送', serverchan: '启用 Server酱', wecom: '启用企业微信通知', dingtalk: '启用钉钉机器人', discord: '启用 Discord',
slack: '启用 Slack', ntfy: '启用 ntfy', gotify: '启用 Gotify', webhook: '启用 Webhook 推送' },
f: { tgApiBase: 'TG API 反代(可选)', httpProxy: 'HTTP 代理(可选)', qqGroups: 'GROUP IDS(群聊)', qqUsers: 'USER IDS(私聊)',
targetUrls: '目标 URLS', barkGroup: '分组 (GROUP)', barkLevel: '通知级别 (LEVEL)', host: '主机', port: '端口', username: '用户名', password: '密码',
from: '发件人地址 (FROM)', to: '收件人地址 (TO)', ppTopic: '群组编码 (TOPIC)', ppChannel: '渠道 (CHANNEL)', robotUrl: '机器人 Webhook',
signSecret: '加签密钥 (SECRET)', server: '服务器地址', tokenOptional: '令牌(可选)', priority: '优先级',
whSecret: '数字签名密钥 (SECRET)', whHeaders: '自定义请求头 (HEADERS)', whTemplate: '文本模板 (TEXT TEMPLATE)', whTimeout: '请求超时 (MS)', whRetries: '最大重试次数' },
ph: { chatId: '-100xxxx / 数字', feishuChats: 'oc_xxxx,多个逗号分隔', qqGroups: '群聊 OpenID,多个逗号分隔', qqUsers: '用户 OpenID,多个逗号分隔',
bark: 'https://api.day.app/你的Key', icon: '图标 URL,可选', mailUser: '邮箱账号', mailPw: '密码或授权码', mailTo: '多个收件人用英文逗号分隔',
ppToken: 'Pushplus 用户 Token', ppTopic: '群组编码,不填发给个人', dingSecret: 'SEC... 加签密钥,选填', ntfyToken: 'tk_... 访问令牌,选填',
gotifyToken: '应用 Token', whSecret: 'HMAC-SHA256 签名密钥,选填', value: '值', wcProxy: 'https://qyapi.weixin.qq.com(留空用官方域名)',
toUser: "{'@'}all 或 UserID,多个用 {'|'} 分隔", toParty: '部门ID,可选', toTag: '标签ID,可选', cardUrl: '点击卡片跳转地址,如 https://你的域名:8443/audit' },
pp: { wechat: '微信 (wechat)', mail: '邮件 (mail)', cp: '企业微信 (cp)' },
h: {
feishu: '飞书群聊的 Chat ID (oc_xxxx),可通过飞书开放平台获取,支持逗号分隔多个群组。',
qq: '向机器人发送消息后,在 QQ 开放平台的消息日志里查看 OpenID,填入后 Bot 只对匹配的会话推送。',
serverchan: '在 sct.ftqq.com 获取 SendKey(Server酱·Turbo;Server酱³ 以 sctp 开头的也可以)。',
wecom: '企业微信群 → 右上角 → 群机器人 → 添加机器人 → 复制 Webhook 地址。',
dingtalk: '钉钉群 → 智能群助手 → 添加自定义机器人,安全设置建议用「加签」并把密钥填到上方。',
discord: '频道设置 → 整合 → Webhook → 复制 Webhook URL。', slack: 'Slack App → Incoming Webhooks → 复制 Webhook URL。',
ntfy: '自建或用公共 ntfy.sh;订阅同名 Topic 即可收到推送。',
whSecret: '若配置,将通过请求头 X-ROSDPI-Signature 提供 payload 验证。',
whTemplate: '支持占位符 text / subject / event / timestamp(各用双花括号包裹,见输入框示例)。留空则发送原始文本。',
},
wc: {
mode: '推送方式', robot: '群机器人', app: '自建应用(推荐)',
robotTip: '最简单:只要一个群机器人 Webhook 地址,提醒发到该群,无需企业管理员权限。',
appTip: '与 CMSHelp「企业微信配置」一致:企业ID + 应用ID + 应用Secret,可精确推送给成员 / 部门 / 标签,配合「微信插件」还能直接在微信里收提醒。',
corpId: '企业ID (CorpID)', agentId: '应用ID (AgentId)', secret: '应用Secret',
corpIdHint: '企业微信管理后台 → 我的企业 → 企业信息,页面最下方即为企业ID。',
agentHint: '应用管理 → 应用 → 自建 → 打开你的应用,页面内可见 AgentId 与 Secret。',
proxy: 'API 代理(可选)',
proxyHint: '留空走官方 qyapi.weixin.qq.com。服务器出口 IP 不固定 / 无法加白名单时,填写自建反代地址(需反代 /cgi-bin/gettoken 与 /cgi-bin/message/send)。',
toUser: '接收成员 (ToUser)', toParty: '接收部门 (ToParty)', toTag: '接收标签 (ToTag)',
msgType: '消息类型', msgText: '文本 text(通用)', msgMd: 'Markdown(仅企业微信客户端)', msgCard: '文本卡片 textcard(可点击跳转)', cardUrl: '卡片跳转地址',
check: '检查应用', checkOk: '应用连接成功:', scope: '可见范围 成员/部门/标签', guide: '企业微信配置说明(点击展开)',
rstep1: '在企业微信里打开目标群 → 右上角「···」→ <b>群机器人</b> → <b>添加机器人</b>,起个名字(如 ROS-DPI)。',
rstep2: '复制生成的 <b>Webhook 地址</b>(形如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx)粘贴到上方。',
rstep3: '打开右上角开关并点 <b>测试通知</b>,群里收到消息即成功。群机器人不需要企业管理员权限,也不受可见范围与 IP 白名单限制。',
step1: '登录 <b>企业微信管理后台</b>(work.weixin.qq.com)→ <b>我的企业 → 企业信息</b>,复制页面底部的 <b>企业ID (CorpID)</b>。',
step2: '进入 <b>应用管理 → 应用 → 自建</b>,点「创建应用」上传 logo、填应用名(如 ROS-DPI 审计提醒),<b>可见范围</b>选择要收提醒的成员或部门。',
step3: '打开刚创建的应用,把 <b>AgentId</b> 与 <b>Secret</b> 填到上方(点 Secret 的「查看」后,验证码会发到企业微信/微信里)。',
step4: "<b>接收成员</b>填 <b>{'@'}all</b> 即发给可见范围内所有人;也可填成员 <b>UserID</b>(通讯录点开成员可见,多个用 <b>{'|'}</b> 分隔)。部门 / 标签同理,三者留空时自动按 {'@'}all 处理。",
step5: '想直接在<b>微信</b>里收提醒:<b>我的企业 → 微信插件</b>,用微信扫码关注即可,无需安装企业微信客户端。',
step6: '保存配置后先点 <b>检查应用</b> 校验(会返回应用名与可见范围),再点右上角 <b>测试通知</b> 发一条真实消息确认到达。',
ipWarn: '2022 年 6 月之后创建的自建应用有 IP 限制:请到「应用 → 企业可信IP」把本服务器的公网出口 IP 加入白名单。若出口 IP 不固定或服务器在境外,用下面的反代 / 中转方案,并把地址填进「API 代理」。',
proxyExample: '查看反代示例(Nginx / Docker socat)',
socatTip: '或用 socat 做 TCP 中转:配合 hosts 把 qyapi.weixin.qq.com 指向该服务器;下例映射到 8543,则「API 代理」填 https://qyapi.weixin.qq.com:8543 。',
aesTip: '说明:Token 与 EncodingAESKey 仅用于企业微信「接收消息」回调,本项目只做单向推送提醒,无需填写。',
ref: '字段与思路参考:',
},
ph: { wecom: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…', dingtalk: 'https://oapi.dingtalk.com/robot/send?access_token=…',
feishu: 'https://open.feishu.cn/open-apis/bot/v2/hook/…', discord: 'https://discord.com/api/webhooks/…', slack: 'https://hooks.slack.com/services/…',
bark: 'https://api.day.app/你的key', webhook: 'https://example.com/hook' },
msg: { text: '纯文本', markdown: 'Markdown' },
f: { url: 'Webhook 地址', urls: '推送地址', signSecret: '签名密钥', optional: '可选', msgType: '消息格式', group: '分组', level: '级别',
hmac: '签名密钥(HMAC)', headers: '自定义请求头', template: '文本模板', retries: '失败重试次数', apiBase: 'API 地址(可填反代)',
smtpHost: 'SMTP 服务器', port: '端口', security: '加密', none: '不加密', authCode: '密码或授权码', from: '发件人地址', fromHint: '留空则用账号',
to: '收件人', topic: '主题 / 群组', server: '服务器', appToken: '应用 Token', priority: '优先级' },
},
cp: {
syslog: 'Syslog 外发', syslogHint: '把访问日志和审计事件实时发到日志平台(Graylog、ELK、日志审计系统等),一次访问结束时发一条。',
@@ -299,7 +332,7 @@ export default {
quic: '同时禁止这些设备的 QUIC(udp/443),否则走 QUIC 的应用拦不住', generate: '生成命令', script: '执行命令', undo: '撤销命令', copy: '复制', copied: '已复制',
},
settings: {
tab: { status: '运行状态', access: '接入与自检', sources: '外部数据源', system: '网络与账号', users: '用户管理', compliance: '日志与留存', account: '我的账号' },
tab: { status: '运行状态', access: '接入与自检', sources: '外部数据源', system: '场景与网络', users: '用户管理', compliance: '日志与留存', account: '我的账号' },
title: '系统设置', network: '网络', lan: 'LAN 网段', lanHint: '每行一个,属于这些网段的地址被视为内网终端。路由器上非 WAN 接口的地址段会自动补充(下方灰色)。',
lanAuto: '自动发现', wan: 'WAN 接口', wanHint: '用于显示 WAN 实时速率,留空则按默认路由自动识别。',
wanDetected: '当前识别为:{name}', status: '运行状态', collector: '流量采集 (IPFIX)', dns: '应用识别:DNS + SNI (TZSP)',
+28 -3
View File
@@ -1,20 +1,45 @@
// 使用场景(家用 / 企业):决定称呼(家庭成员 / 人员、分组 / 部门、学习时间 / 工作时间)和默认的分析指标
// 使用场景(家庭 / 企业):全局设置,决定称呼(家庭成员 / 员工、分组 / 部门、学习时间 / 工作时间)和默认的分析指标
import { reactive } from 'vue'
import http from './api.js'
import { i18n } from './i18n/index.js'
export const scene = reactive({ id: 'home', loaded: false, people: [], departments: [] })
const KEY = 'rosdpi.scene'
function cached() {
try { return localStorage.getItem(KEY) === 'enterprise' ? 'enterprise' : 'home' } catch (e) { return 'home' }
}
function remember(id) {
try { localStorage.setItem(KEY, id) } catch (e) { /* ignore */ }
}
// 先用上次的场景,避免企业场景刷新时菜单先闪出“家庭成员”
export const scene = reactive({ id: cached(), loaded: false, people: [], departments: [] })
export async function loadScene(force = false) {
if (scene.loaded && !force) return scene
try {
const r = await http.get('/people/meta')
Object.assign(scene, { id: r.scenario, people: r.people, departments: r.departments, loaded: true })
remember(r.scenario)
} catch (e) { /* 未登录或接口不可用时保持默认 */ }
return scene
}
/** 按场景取称呼:L('person') → 家庭成员 / 人员 */
/** 页面从别的接口拿到场景时同步一下(人员列表、行为洞察都会带 scenario) */
export function syncScene(id) {
if (id && id !== scene.id) {
scene.id = id
remember(id)
}
}
/** 切换场景(管理员):其余分析设置换成新场景的默认值,返回新配置 */
export async function setScene(id) {
const r = await http.put('/analysis/settings', { scenario: id })
syncScene(r.config.scenario)
return r.config
}
/** 按场景取称呼:L('person') → 成员 / 员工 */
export const L = (key, args) => i18n.global.t(`scn.${scene.id}.${key}`, args || {})
/** 秒 → "2 小时 5 分" / "2h 5m" */
+1
View File
@@ -6,6 +6,7 @@
<button v-for="k in TABS" :key="k" role="tab" :aria-selected="tab === k" :class="{ on: tab === k }" @click="setTab(k)">
{{ t('audit.tab.' + k) }}<span v-if="k === 'events' && sum && sum.events_today" class="num cnt">{{ sum.events_today }}</span></button>
</div>
<span class="pill grey" :title="t('scn.' + scene.id + '.desc')">{{ L('badge') }}</span>
<span class="sp" />
<RangeTabs v-if="tab === 'logs' || tab === 'events'" />
</div>
+7 -21
View File
@@ -4,6 +4,7 @@
<div class="seg tabs" role="tablist">
<button v-for="k in TABS" :key="k" role="tab" :aria-selected="tab === k" :class="{ on: tab === k }" @click="setTab(k)">{{ L('tab.' + k) }}</button>
</div>
<span class="pill grey" :title="t('scn.' + scene.id + '.desc')">{{ L('badge') }}</span>
<span class="sp" />
<RangeTabs v-if="tab === 'list'" />
</div>
@@ -128,16 +129,8 @@
<!-- 分析设置 -->
<template v-if="tab === 'settings' && cfg">
<div class="panel">
<div class="panel-h"><h2>{{ t('people.scenario') }}</h2><span class="faint small">{{ t('people.scenarioHint') }}</span></div>
<div class="panel-b scn">
<label v-for="k in ['home', 'enterprise']" :key="k" class="scn-c" :class="{ on: cfg.scenario === k }">
<input type="radio" :value="k" :checked="cfg.scenario === k" @change="switchScene(k)" />
<Icon :name="k === 'home' ? 'people' : 'users'" :size="22" />
<div><b>{{ t('scn.' + k + '.name') }}</b><span class="faint small">{{ t('scn.' + k + '.desc') }}</span></div>
</label>
</div>
</div>
<div class="info-bar scn-now"><span class="ic">i</span><div><b>{{ t('people.sceneNow', { x: t('scn.' + scene.id + '.name') }) }}</b>
<span>{{ t('scn.' + scene.id + '.desc') }} <router-link class="link" to="/settings?tab=system">{{ t('people.sceneGo') }}</router-link></span></div></div>
<div class="panel mt">
<div class="panel-h"><h2>{{ L('workTime') }}</h2><span class="faint small">{{ L('workHint') }}</span></div>
<div class="panel-b form">
@@ -231,7 +224,7 @@ import Modal from '../components/Modal.vue'
import RangeTabs from '../components/RangeTabs.vue'
import Tags from '../components/Tags.vue'
import { bytes, catName, pct } from '../format.js'
import { hours, L, loadScene, scene } from '../scenario.js'
import { hours, L, loadScene, scene, syncScene } from '../scenario.js'
import { ui } from '../state.js'
const { t } = useI18n()
@@ -249,7 +242,7 @@ const dept = ref(0)
const q = ref('')
const unLimit = ref(50)
async function load() {
try { d.value = await http.get('/people', { params: { range: ui.range } }); scene.id = d.value.scenario; err.value = '' } catch (e) { err.value = e.message }
try { d.value = await http.get('/people', { params: { range: ui.range } }); syncScene(d.value.scenario); err.value = '' } catch (e) { err.value = e.message }
const want = +route.query.edit
if (want && d.value && isAdmin.value && !form.value) {
const p = d.value.people.find(x => x.id === want)
@@ -353,7 +346,7 @@ async function delDept(g) {
// ---------- 洞察 ----------
const ins = ref(null)
async function loadIns() {
try { ins.value = await http.get('/insights'); scene.id = ins.value.scenario } catch (e) { err.value = e.message }
try { ins.value = await http.get('/insights'); syncScene(ins.value.scenario) } catch (e) { err.value = e.message }
}
const TAG_IDS = { enterprise: ['job', 'stock_work', 'game_work', 'video_work', 'shop_work', 'late_night'],
home: ['adult', 'screen_over', 'game_over', 'short_video_over', 'late_night', 'study_leisure'] }
@@ -394,18 +387,11 @@ function toggleLeisure(id) {
const i = cfg.value.leisure.indexOf(id)
i >= 0 ? cfg.value.leisure.splice(i, 1) : cfg.value.leisure.push(id)
}
async function switchScene(k) {
cfgErr.value = ''
try {
cfg.value = (await http.put('/analysis/settings', { scenario: k })).config
scene.id = k
} catch (e) { cfgErr.value = e.message }
}
async function saveCfg() {
cfgErr.value = ''
try {
cfg.value = (await http.put('/analysis/settings', cfg.value)).config
scene.id = cfg.value.scenario
syncScene(cfg.value.scenario)
cfgSaved.value = true
setTimeout(() => (cfgSaved.value = false), 1500)
} catch (e) { cfgErr.value = e.message }
+2 -2
View File
@@ -118,7 +118,7 @@ import Stat from '../components/Stat.vue'
import Timeline from '../components/Timeline.vue'
import TrafficPanel from '../components/TrafficPanel.vue'
import { appName, bytes, catColor, pct } from '../format.js'
import { hours, L, scene } from '../scenario.js'
import { hours, L, scene, syncScene } from '../scenario.js'
import { ui } from '../state.js'
const props = defineProps({ id: String })
@@ -139,7 +139,7 @@ const limit = computed(() => (d.value && d.value.scenario === 'home' ? d.value.c
async function load() {
try {
d.value = await http.get(`/people/${props.id}/detail`, { params: { range: ui.range } })
scene.id = d.value.scenario
syncScene(d.value.scenario)
err.value = ''
} catch (e) { err.value = e.message }
}
+6 -2
View File
@@ -12,10 +12,11 @@
</div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('lib.source') }}</th><th class="m-hide">{{ t('lib.what') }}</th><th>{{ t('lib.version') }}</th>
<thead><tr><th class="r ord" :title="t('lib.orderTip')">#</th><th>{{ t('lib.source') }}</th><th class="m-hide">{{ t('lib.what') }}</th><th>{{ t('lib.version') }}</th>
<th class="r">{{ t('lib.size') }}</th><th class="m-hide">{{ t('lib.license') }}</th><th>{{ t('lib.state') }}</th></tr></thead>
<tbody>
<tr v-for="x in lib.sources" :key="x.id">
<tr v-for="(x, i) in lib.sources" :key="x.id">
<td class="r num faint ord">{{ i + 1 }}</td>
<td><a :href="x.url" target="_blank" rel="noopener" class="link"><b>{{ t('lib.name.' + x.id) }}</b></a></td>
<td class="m-hide muted small wrap">{{ t('lib.desc.' + x.id) }}</td>
<td class="num small">
@@ -47,6 +48,7 @@
</tbody>
</table>
</div>
<p class="panel-b small note order"><b>{{ t('lib.orderTitle') }}</b>{{ t('lib.order') }}</p>
<p class="panel-b faint small note">{{ t('lib.note') }}</p>
</div>
@@ -236,6 +238,8 @@ async function test() {
.lib .chk { display: flex; gap: 6px; align-items: center; color: var(--muted); font-size: 13px; cursor: pointer; }
.lib .wrap { white-space: normal; max-width: 340px; line-height: 1.5; }
.lib .note { margin: 0; padding-top: 10px; }
.lib .note.order { line-height: 1.7; }
.lib .ord { width: 28px; }
.lib td { vertical-align: top; }
.mt { margin-top: 14px; }
.form { display: flex; flex-direction: column; gap: 10px; }
+3
View File
@@ -151,6 +151,7 @@
<UsersPanel v-if="tab === 'users'" />
<CompliancePanel v-if="tab === 'compliance'" />
<ScenePicker v-if="isAdmin && tab === 'system'" class="scene-pick" />
<div class="grid g2 sys" v-if="tab === 'system'">
<div class="panel" v-if="s">
<div class="panel-h">{{ t('settings.network') }}</div>
@@ -204,6 +205,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import http, { auth, isAdmin } from '../api.js'
import UsersPanel from '../components/UsersPanel.vue'
import ScenePicker from '../components/ScenePicker.vue'
import CompliancePanel from '../components/CompliancePanel.vue'
import { ago, bytes, datetime, pct } from '../format.js'
@@ -381,6 +383,7 @@ async function copy() {
</script>
<style scoped>
.scene-pick { margin-bottom: 16px; }
.mt { margin-top: 14px; }
.small { font-size: 12.5px; }
.stc { display: flex; flex-direction: column; gap: 6px; font-size: 13px; min-width: 0; }