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:
1 parent
965ccc59fc
commit
2585d15d53
23 files changed
+1285
-418
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "0.6.1"
|
||||
__version__ = "0.6.2"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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)',
|
||||
|
||||
@@ -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" */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user