Forráskód Böngészése

feat(web-modules): add portable multi-language WeCom Center Zabbix frontend module

FlyingRome 4 napja
szülő
commit
5fae9d5815
46 módosított fájl, 1607 hozzáadás és 0 törlés
  1. 1 0
      docker-compose.yaml
  2. 81 0
      web-modules/README.md
  3. 35 0
      web-modules/WecomCenter/Module.php
  4. 171 0
      web-modules/WecomCenter/actions/WecomCenterSave.php
  5. 97 0
      web-modules/WecomCenter/actions/WecomCenterView.php
  6. 12 0
      web-modules/WecomCenter/assets/wecom.center.css
  7. 344 0
      web-modules/WecomCenter/lib/Helper.php
  8. 66 0
      web-modules/WecomCenter/lib/I18n.php
  9. 3 0
      web-modules/WecomCenter/locale/bg_BG.php
  10. 3 0
      web-modules/WecomCenter/locale/ca_ES.php
  11. 3 0
      web-modules/WecomCenter/locale/cs_CZ.php
  12. 3 0
      web-modules/WecomCenter/locale/da_DK.php
  13. 35 0
      web-modules/WecomCenter/locale/de_DE.php
  14. 3 0
      web-modules/WecomCenter/locale/el_GR.php
  15. 50 0
      web-modules/WecomCenter/locale/en.php
  16. 3 0
      web-modules/WecomCenter/locale/en_GB.php
  17. 35 0
      web-modules/WecomCenter/locale/es_ES.php
  18. 3 0
      web-modules/WecomCenter/locale/fa_IR.php
  19. 3 0
      web-modules/WecomCenter/locale/fi_FI.php
  20. 35 0
      web-modules/WecomCenter/locale/fr_FR.php
  21. 3 0
      web-modules/WecomCenter/locale/he_IL.php
  22. 3 0
      web-modules/WecomCenter/locale/hu_HU.php
  23. 3 0
      web-modules/WecomCenter/locale/id_ID.php
  24. 35 0
      web-modules/WecomCenter/locale/it_IT.php
  25. 35 0
      web-modules/WecomCenter/locale/ja_JP.php
  26. 3 0
      web-modules/WecomCenter/locale/ka_GE.php
  27. 35 0
      web-modules/WecomCenter/locale/ko_KR.php
  28. 3 0
      web-modules/WecomCenter/locale/lt_LT.php
  29. 3 0
      web-modules/WecomCenter/locale/lv_LV.php
  30. 3 0
      web-modules/WecomCenter/locale/nb_NO.php
  31. 3 0
      web-modules/WecomCenter/locale/nl_NL.php
  32. 35 0
      web-modules/WecomCenter/locale/pl_PL.php
  33. 35 0
      web-modules/WecomCenter/locale/pt_BR.php
  34. 3 0
      web-modules/WecomCenter/locale/ro_RO.php
  35. 35 0
      web-modules/WecomCenter/locale/ru_RU.php
  36. 3 0
      web-modules/WecomCenter/locale/sk_SK.php
  37. 3 0
      web-modules/WecomCenter/locale/sv_SE.php
  38. 35 0
      web-modules/WecomCenter/locale/tr_TR.php
  39. 3 0
      web-modules/WecomCenter/locale/uk_UA.php
  40. 3 0
      web-modules/WecomCenter/locale/uz_UZ.php
  41. 3 0
      web-modules/WecomCenter/locale/vi_VN.php
  42. 50 0
      web-modules/WecomCenter/locale/zh_CN.php
  43. 35 0
      web-modules/WecomCenter/locale/zh_TW.php
  44. 25 0
      web-modules/WecomCenter/manifest.json
  45. 12 0
      web-modules/WecomCenter/templates/demo.yaml
  46. 212 0
      web-modules/WecomCenter/views/wecom.center.view.php

+ 1 - 0
docker-compose.yaml

@@ -124,6 +124,7 @@ services:
       PHP_TZ: ${TZ:-UTC}
     volumes:
       - /opt/zabbix/zabbix-server/web:/data
+      - /opt/zabbix/zabbix-server/web-modules:/usr/share/zabbix/modules
       - /opt/zabbix/zabbix-server/fonts/simkai.ttf:/usr/share/zabbix/assets/fonts/DejaVuSans.ttf
     depends_on:
       zabbix-mysql:

+ 81 - 0
web-modules/README.md

@@ -0,0 +1,81 @@
+# WeCom Center — 可移植 Zabbix 前端模块
+
+一个自包含、多语言的 Zabbix 7.4 前端模块(custom module / plugin)。加载后会在 **Administration → WeCom Center** 增加一个定制页面,集中处理:
+
+- **告警优化(核心)**:以勾选方式一键切换“是否告警”与通知严重度;内置 5 套告警方案(标准 / 警告以上 / 全部 / 仅灾难 / 关闭),点一下立即生效。
+- **机器人与账号**:在同一页编辑企业微信 Webhook 地址 / 机器人 key、机器人账号、接收目标,并可“发送测试”验证(直接调用企微接口,返回 errcode)。
+- **模板导入**:把放在 `templates/` 目录里的模板文件(yaml/xml/json)一键导入 Zabbix。
+- **地图坐标**:批量/单个设置主机的 `location_lat` / `location_lon`(主机资产清单)。
+
+所有改动都通过 **Zabbix 官方 API** 写入数据库,因此:换机器、重建容器、升级镜像后设置都在;只要“加载插件”即可复用。
+
+## 目录结构
+
+```
+WecomCenter/
+├── manifest.json                 # 模块清单(v2.0),注册 2 个 action
+├── Module.php                    # 模块入口:注册视图目录、初始化 i18n、注入左侧菜单
+├── actions/
+│   ├── WecomCenterView.php       # GET 控制器,渲染页面
+│   └── WecomCenterSave.php       # POST 控制器,分发一键方案/保存/测试/导入/地图
+├── views/
+│   └── wecom.center.view.php     # 页面模板(Zabbix 原生 UI 组件)
+├── lib/
+│   ├── I18n.php                  # 极简多语言层(按当前语言加载,缺省回退英文)
+│   └── Helper.php                # 业务逻辑:媒介/账号/key、方案预设、导入、坐标、测试
+├── locale/                       # 35 个语言文件(见“多语言”)
+├── templates/demo.yaml           # 示例模板(演示一键导入)
+└── assets/wecom.center.css
+```
+
+## 安装(可移植方式)
+
+1. **持久化模块目录**:在 `docker-compose.yaml` 的 web 服务下挂载宿主目录:
+
+   ```yaml
+   zabbix-web-nginx-mysql:
+     volumes:
+       - /opt/zabbix/zabbix-server/web-modules:/usr/share/zabbix/modules
+   ```
+
+2. 把本 `WecomCenter/` 目录整体放到宿主 `/opt/zabbix/zabbix-server/web-modules/WecomCenter/`,然后
+   `docker compose up -d` 重建一次 web 容器使挂载生效(数据不动,仅 web 短暂重启)。
+
+3. **启用模块**(写入 `module` 表,status=1 为启用):
+
+   ```sql
+   INSERT INTO module(moduleid,id,relative_path,status,config)
+   SELECT 1001,'wecom_center','modules/WecomCenter',1,'[]'
+   WHERE NOT EXISTS (SELECT 1 FROM module WHERE id='wecom_center');
+   ```
+
+   (也可在 Administration → General → Modules 里点“启用”。)
+
+4. 刷新网页,左侧 **Administration → WeCom Center** 即可进入。
+
+> 换环境:重复 1–3 即可。模块代码放在 git 里,`web-modules/` 随仓库走;MySQL 卷里保存着 `module` 启用记录,二者都在 → 插件始终在线。
+
+## 多语言
+
+- 共 **35 个语言文件**,覆盖 Zabbix 全部 36 个 locale(`en_GB`/`en_US` 共用英文)。
+- **已人工翻译**:`zh_CN`、`zh_TW`、`ja_JP`、`ko_KR`、`ru_RU`、`de_DE`、`fr_FR`、`es_ES`、`pt_BR`、`it_IT`、`pl_PL`、`tr_TR`。
+- 其余语言文件存在但暂未翻译,运行时自动回退英文(页面照常工作,不会报错)。
+- 增加/完善语言:复制 `locale/en.php` 为 `locale/<lang>.php` 并翻译 value 即可,键名保持不变。模块会读取当前用户语言(`CWebUser::$data['lang']`)自动切换。
+
+## 设计要点(适配 Zabbix 7.4)
+
+- `manifest.json` 用 `manifest_version: 2.0`;action 的控制器类位于 `actions/`(自动加载器会把子命名空间段转小写)。
+- 视图目录必须在 `Module::init()` 里 `CView::registerDirectory($this->getRelativePath().'/views')`。
+- 菜单注入:`APP::Component()->get('menu.main')->findOrAdd(_('Administration'))->getSubmenu()->add(...)`。
+- 关键 API 注意:`mediatype.get` **不支持 `sortfield=name`**(会整体返回 false);`configuration.import` 的规则键是复数 `templates`(不是 `template`)。
+- 告警“开关”落在用户媒介的 `active` 字段:**0=启用、1=停用**;严重度为位掩码(未分类=1…灾难=32)。
+
+## 告警媒介说明
+
+页面基于“Webhook 类型(type=4)的媒介 + 一个机器人账号(默认用户名 `wecomrobot`)的媒介记录”工作:
+
+- 一键方案 / 勾选保存 → 修改该账号对应媒介的 `severity` / `active`。
+- 保存 key → 修改该 Webhook 媒介的 `webhook_url` 参数(保留其它参数)。
+- 发送测试 → 直接 POST 到该 `webhook_url`,检查返回 `errcode:0`。
+
+若环境中没有 Webhook 媒介类型,页面会提示“未找到 webhook 媒介类型”。

+ 35 - 0
web-modules/WecomCenter/Module.php

@@ -0,0 +1,35 @@
+<?php declare(strict_types = 0);
+/*
+** WeCom Center - a portable, multi-language Zabbix frontend module.
+** Adds an administration page for alert optimization, WeCom robot/key/media
+** configuration, template import and map coordinate adjustment.
+*/
+
+namespace Modules\WecomCenter;
+
+use Zabbix\Core\CModule;
+use APP;
+use CMenuItem;
+use CView;
+use Modules\WecomCenter\Lib\I18n;
+
+class Module extends CModule {
+
+	public function init(): void {
+		// Register this module's own view directory so CView can find:
+		//   modules/WecomCenter/views/wecom.center.view.php
+		CView::registerDirectory($this->getRelativePath().'/views');
+
+		// Load translations for the current language (falls back to English).
+		I18n::init($this->getRelativePath().'/locale');
+
+		// Add "WeCom Center" as a submenu item under Administration.
+		APP::Component()->get('menu.main')
+			->findOrAdd(_('Administration'))
+			->getSubMenu()
+			->add(
+				(new CMenuItem(I18n::t('menu.title')))
+					->setAction('wecom.center.view')
+			);
+	}
+}

+ 171 - 0
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -0,0 +1,171 @@
+<?php declare(strict_types = 0);
+/*
+** WeCom Center - POST controller. Dispatches actions and redirects back.
+*/
+
+namespace Modules\WecomCenter\Actions;
+
+use CController;
+use CControllerResponseRedirect;
+use CUrl;
+use CWebUser;
+use Exception;
+use Modules\WecomCenter\Lib\Helper;
+use Modules\WecomCenter\Lib\I18n;
+
+class WecomCenterSave extends CController {
+
+	protected function init() {
+		$this->disableCsrfValidation();
+	}
+
+	protected function checkInput() {
+		return true;
+	}
+
+	protected function checkPermissions() {
+		return CWebUser::getType() == USER_TYPE_SUPER_ADMIN;
+	}
+
+	protected function doAction() {
+		$mediatypeid = (string) ($_REQUEST['mediatypeid'] ?? '');
+		$hostid = (string) ($_REQUEST['hostid'] ?? '');
+
+		try {
+			// One-click alert scheme presets: buttons named scheme_<key>.
+			$applied = false;
+			foreach (array_keys(Helper::SCHEMES) as $k) {
+				if (isset($_REQUEST['scheme_'.$k])) {
+					$this->applyScheme($mediatypeid, $k);
+					$applied = true;
+					break;
+				}
+			}
+
+			if (!$applied) {
+				$op = (string) ($_REQUEST['op'] ?? '');
+				switch ($op) {
+					case 'save_alert':
+						$this->saveAlert($mediatypeid);
+						break;
+					case 'save_robot':
+						$this->saveRobot($mediatypeid);
+						break;
+					case 'test_robot':
+						$this->testRobot($mediatypeid);
+						break;
+					case 'import_template':
+						$this->importTemplate();
+						break;
+					case 'save_map':
+						$this->saveMap();
+						break;
+					default:
+						error(I18n::t('msg.unknown_op'));
+				}
+			}
+		}
+		catch (Exception $e) {
+			error($e->getMessage());
+		}
+
+		$url = (new CUrl('zabbix.php'))->setArgument('action', 'wecom.center.view');
+		if ($mediatypeid !== '') {
+			$url->setArgument('mediatypeid', $mediatypeid);
+		}
+		if ($hostid !== '') {
+			$url->setArgument('hostid', $hostid);
+		}
+		$this->setResponse(new CControllerResponseRedirect($url));
+	}
+
+	private function userName(): string {
+		$n = trim((string) ($_REQUEST['username'] ?? ''));
+		return $n !== '' ? $n : 'wecomrobot';
+	}
+
+	private function applyScheme(string $mediatypeid, string $scheme): void {
+		if (!array_key_exists($scheme, Helper::SCHEMES)) {
+			throw new Exception(I18n::t('msg.bad_scheme'));
+		}
+		[$mask, $active] = Helper::SCHEMES[$scheme];
+		$user = Helper::ensureRobotUser($this->userName());
+		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active);
+		info(I18n::t('msg.scheme_applied', [I18n::t('scheme.'.$scheme)]));
+	}
+
+	private function saveAlert(string $mediatypeid): void {
+		$active = isset($_REQUEST['enabled']) ? 0 : 1; // media: 0=enabled, 1=disabled
+		$indices = [];
+		for ($i = 1; $i <= 6; $i++) {
+			if (isset($_REQUEST['sev_'.$i])) {
+				$indices[] = $i;
+			}
+		}
+		$mask = Helper::severityMask($indices);
+		if ($mask === 0 && $active === 0) {
+			$mask = 2; // keep at least Information when enabled
+		}
+		$user = Helper::ensureRobotUser($this->userName());
+		$sendto = (string) ($_REQUEST['sendto'] ?? 'WeCom');
+		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active, $sendto);
+		info(I18n::t('msg.saved'));
+	}
+
+	private function saveRobot(string $mediatypeid): void {
+		$url = trim((string) ($_REQUEST['webhook_url'] ?? ''));
+		if ($url !== '') {
+			Helper::setWebhookUrl($mediatypeid, $url);
+		}
+		$user = Helper::ensureRobotUser($this->userName());
+		$cur = Helper::currentMedia($user, $mediatypeid);
+		$mask = $cur['severity'] ?? 48;
+		$active = $cur['active'] ?? 0;
+		$sendto = (string) ($_REQUEST['sendto'] ?? ($cur['sendto'] ?? 'WeCom'));
+		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active, $sendto);
+		info(I18n::t('msg.robot_saved'));
+	}
+
+	private function testRobot(string $mediatypeid): void {
+		$mt = Helper::getMediatype($mediatypeid);
+		if (!$mt) {
+			throw new Exception(I18n::t('msg.no_mediatype'));
+		}
+		$url = Helper::webhookUrl($mt);
+		if ($url === '') {
+			throw new Exception(I18n::t('msg.no_url'));
+		}
+		$text = '['.I18n::t('page.title').'] Test OK - '.date('Y-m-d H:i:s');
+		$res = Helper::testWebhook($url, $text);
+		$body = (string) $res['body'];
+		$ok = (strpos($body, '"errcode":0') !== false) || (strpos($body, '"errcode": 0') !== false);
+		if ($ok) {
+			info(I18n::t('msg.tested'));
+		}
+		else {
+			error(I18n::t('msg.test_failed', [$res['status'], $body]));
+		}
+	}
+
+	private function importTemplate(): void {
+		$file = (string) ($_REQUEST['template_file'] ?? '');
+		if ($file === '') {
+			throw new Exception(I18n::t('msg.no_template'));
+		}
+		$module_dir = dirname(__DIR__);
+		$res = Helper::importTemplate($module_dir, $file);
+		$n = isset($res['templates']) && is_array($res['templates']) ? count($res['templates']) : 0;
+		info(I18n::t('msg.imported', [$n]));
+	}
+
+	private function saveMap(): void {
+		$hostid = (string) ($_REQUEST['hostid'] ?? '');
+		$lat = trim((string) ($_REQUEST['lat'] ?? ''));
+		$lon = trim((string) ($_REQUEST['lon'] ?? ''));
+		if ($hostid === '') {
+			throw new Exception(I18n::t('msg.no_host'));
+		}
+		Helper::setHostLocation($hostid, $lat, $lon);
+		info(I18n::t('msg.map_saved'));
+	}
+}

+ 97 - 0
web-modules/WecomCenter/actions/WecomCenterView.php

@@ -0,0 +1,97 @@
+<?php declare(strict_types = 0);
+/*
+** WeCom Center - GET controller. Gathers current state and renders the page.
+*/
+
+namespace Modules\WecomCenter\Actions;
+
+use CController;
+use CControllerResponseData;
+use CWebUser;
+use Modules\WecomCenter\Lib\Helper;
+use Modules\WecomCenter\Lib\I18n;
+
+class WecomCenterView extends CController {
+
+	protected function init() {
+		$this->disableCsrfValidation();
+	}
+
+	protected function checkInput() {
+		return true;
+	}
+
+	protected function checkPermissions() {
+		return CWebUser::getType() == USER_TYPE_SUPER_ADMIN;
+	}
+
+	protected function doAction() {
+		$module_dir = dirname(__DIR__);
+
+		$mediatypes = Helper::webhookMediatypes();
+
+		// selected media type
+		$mediatypeid = (string) ($_REQUEST['mediatypeid'] ?? '');
+		if ($mediatypeid === '' && $mediatypes) {
+			// prefer one whose name mentions wecom
+			foreach ($mediatypes as $mt) {
+				if (stripos($mt['name'], 'wecom') !== false) {
+					$mediatypeid = (string) $mt['mediatypeid'];
+					break;
+				}
+			}
+			if ($mediatypeid === '') {
+				$mediatypeid = (string) $mediatypes[0]['mediatypeid'];
+			}
+		}
+
+		$mediatype = null;
+		$webhook_url = '';
+		$robot = null;
+		$media = null;
+		if ($mediatypeid !== '') {
+			$mediatype = Helper::getMediatype($mediatypeid);
+			if ($mediatype) {
+				$webhook_url = Helper::webhookUrl($mediatype);
+			}
+			$robot = Helper::robotUser('wecomrobot');
+			$media = Helper::currentMedia($robot, $mediatypeid);
+		}
+
+		// selected host for map tab
+		$selected_hostid = (string) ($_REQUEST['hostid'] ?? '');
+		$host_loc = ['location_lat' => '', 'location_lon' => ''];
+		if ($selected_hostid !== '') {
+			$h = Helper::hostLocation($selected_hostid);
+			$inv = $h['inventory'] ?? [];
+			$host_loc = [
+				'location_lat' => $inv['location_lat'] ?? '',
+				'location_lon' => $inv['location_lon'] ?? ''
+			];
+		}
+
+		$data = [
+			'title'        => I18n::t('page.title'),
+			'mediatypes'   => $mediatypes,
+			'mediatypeid'  => $mediatypeid,
+			'mediatype'    => $mediatype,
+			'webhook_url'  => $webhook_url,
+			'robot'        => $robot,
+			'robot_name'   => 'wecomrobot',
+			'media'        => $media,
+			'severity'     => $media['severity'] ?? 48,
+			'active'       => $media['active'] ?? 0,
+			'sendto'       => $media['sendto'] ?? 'WeCom',
+			'schemes'      => Helper::SCHEMES,
+			'templates'    => Helper::bundledTemplates($module_dir),
+			'hosts'        => Helper::hosts(),
+			'selected_hostid' => $selected_hostid,
+			'host_loc'     => $host_loc,
+			'lang'         => I18n::lang()
+		];
+
+		$response = new CControllerResponseData($data);
+		$response->setTitle(I18n::t('page.title'));
+		$this->setResponse($response);
+	}
+}

+ 12 - 0
web-modules/WecomCenter/assets/wecom.center.css

@@ -0,0 +1,12 @@
+/* WeCom Center - minimal styling */
+.wecom-center h3 {
+	font-size: 15px;
+	font-weight: 600;
+	margin: 18px 0 8px;
+	border-left: 4px solid #d9534f;
+	padding-left: 8px;
+}
+.wecom-center .preset-buttons .btn {
+	margin-right: 8px;
+	margin-bottom: 8px;
+}

+ 344 - 0
web-modules/WecomCenter/lib/Helper.php

@@ -0,0 +1,344 @@
+<?php declare(strict_types = 0);
+/*
+** WeCom Center - business logic helpers.
+** Works through the official Zabbix API so every setting is stored in the
+** Zabbix database itself -> survives image upgrades and is portable.
+*/
+
+namespace Modules\WecomCenter\Lib;
+
+use API;
+use Exception;
+
+class Helper {
+
+	// Zabbix trigger severity index => media severity bit (1 << index).
+	public const SEVERITY_BITS = [1, 2, 4, 8, 16, 32];
+
+	// Built-in alert schemes: name => [severity_mask, active(0=enabled,1=disabled)]
+	public const SCHEMES = [
+		'standard' => [48, 0],   // High + Disaster (recommended)
+		'warnplus' => [56, 0],   // Average + High + Disaster
+		'all'      => [63, 0],   // everything
+		'quiet'    => [32, 0],   // Disaster only
+		'off'      => [63, 1]    // alerting disabled
+	];
+
+	/* ------------------------------------------------------------------ */
+	/*  Media types / robot                                               */
+	/* ------------------------------------------------------------------ */
+
+	/**
+	 * All webhook media types (type == 4).
+	 */
+	public static function webhookMediatypes(): array {
+		try {
+			$res = API::Mediatype()->get([
+				'output'           => ['mediatypeid', 'name', 'type', 'status'],
+				'selectParameters' => 'extend',
+				'filter'           => ['type' => 4]
+			]);
+			return is_array($res) ? $res : [];
+		}
+		catch (\Throwable $e) {
+			return [];
+		}
+	}
+
+	public static function getMediatype(string $mediatypeid): ?array {
+		try {
+			$res = API::Mediatype()->get([
+				'output'           => ['mediatypeid', 'name', 'type', 'status'],
+				'selectParameters' => 'extend',
+				'mediatypeids'     => [$mediatypeid]
+			]);
+			return (is_array($res) && $res) ? $res[0] : null;
+		}
+		catch (\Throwable $e) {
+			return null;
+		}
+	}
+
+	/**
+	 * Read the webhook URL / key from a media type parameters list.
+	 */
+	public static function webhookUrl(array $mediatype): string {
+		foreach (($mediatype['parameters'] ?? []) as $p) {
+			$name = strtolower($p['name']);
+			if (in_array($name, ['webhook_url', 'url', 'webhook'], true)) {
+				return (string) $p['value'];
+			}
+		}
+		return '';
+	}
+
+	/**
+	 * Update (or add) the webhook_url parameter of a media type, preserving others.
+	 */
+	public static function setWebhookUrl(string $mediatypeid, string $url): void {
+		$mt = self::getMediatype($mediatypeid);
+		if (!$mt) {
+			throw new Exception('Media type not found: '.$mediatypeid);
+		}
+		$params = [];
+		$found = false;
+		foreach (($mt['parameters'] ?? []) as $p) {
+			if (in_array(strtolower($p['name']), ['webhook_url', 'url', 'webhook'], true)) {
+				$params[] = ['name' => $p['name'], 'value' => $url];
+				$found = true;
+			}
+			else {
+				$params[] = ['name' => $p['name'], 'value' => $p['value']];
+			}
+		}
+		if (!$found) {
+			$params[] = ['name' => 'webhook_url', 'value' => $url];
+		}
+		API::Mediatype()->update(['mediatypeid' => $mediatypeid, 'parameters' => $params]);
+	}
+
+	/* ------------------------------------------------------------------ */
+	/*  Robot user + media (severity / active)                            */
+	/* ------------------------------------------------------------------ */
+
+	/**
+	 * Find the dedicated robot user with its media.
+	 */
+	public static function robotUser(string $username = 'wecomrobot'): ?array {
+		$res = API::User()->get([
+			'output'        => ['userid', 'username'],
+			'selectMedias'  => 'extend',
+			'filter'        => ['username' => $username]
+		]);
+		return $res ? $res[0] : null;
+	}
+
+	/**
+	 * Current severity mask + active flag of a user's media for a media type.
+	 * Returns ['active'=>int, 'severity'=>int] or null when media absent.
+	 */
+	public static function currentMedia(?array $user, string $mediatypeid): ?array {
+		if (!$user || empty($user['medias'])) {
+			return null;
+		}
+		foreach ($user['medias'] as $m) {
+			if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
+				return [
+					'active'   => (int) $m['active'],
+					'severity' => (int) $m['severity'],
+					'period'   => $m['period'] ?? '1-7,00:00-24:00',
+					'sendto'   => $m['sendto'] ?? ''
+				];
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * Ensure a robot user exists; create one (no frontend access) if missing.
+	 */
+	public static function ensureRobotUser(string $username): array {
+		$user = self::robotUser($username);
+		if ($user) {
+			return $user;
+		}
+
+		// best-effort group / role for a non-login notification account
+		$grp = API::UserGroup()->get([
+			'output' => ['usrgrpid', 'name'],
+			'filter' => ['name' => 'No access to the frontend'],
+			'limit'  => 1
+		]);
+		$usrgrpid = $grp ? $grp[0]['usrgrpid'] : null;
+		if ($usrgrpid === null) {
+			$all = API::UserGroup()->get(['output' => ['usrgrpid'], 'limit' => 1]);
+			$usrgrpid = $all ? $all[0]['usrgrpid'] : null;
+		}
+
+		$role = API::Role()->get([
+			'output' => ['roleid', 'name'],
+			'filter' => ['name' => 'User role'],
+			'limit'  => 1
+		]);
+		$roleid = $role ? $role[0]['roleid'] : null;
+
+		$create = [
+			'username' => $username,
+			'passwd'   => bin2hex(random_bytes(16)).'Aa1!',
+			'usrgrps'  => [['usrgrpid' => $usrgrpid]]
+		];
+		if ($roleid !== null) {
+			$create['roleid'] = $roleid;
+		}
+
+		$res = API::User()->create($create);
+		return self::robotUser($username);
+	}
+
+	/**
+	 * Apply severity mask + active flag to a user's media for a media type.
+	 * Rebuilds the whole media list (API user.update replaces medias).
+	 */
+	public static function applyMedia(string $userid, string $mediatypeid, int $severity, int $active,
+			string $sendto = 'WeCom') {
+		$res = API::User()->get([
+			'output'       => ['userid'],
+			'selectMedias' => 'extend',
+			'userids'      => [$userid]
+		]);
+		$medias = [];
+		if ($res) {
+			foreach (($res[0]['medias'] ?? []) as $m) {
+				$medias[] = [
+					'mediatypeid' => $m['mediatypeid'],
+					'sendto'      => $m['sendto'],
+					'severity'    => (string) $m['severity'],
+					'active'      => (string) $m['active'],
+					'period'      => $m['period'] ?: '1-7,00:00-24:00'
+				];
+			}
+		}
+
+		$hit = false;
+		foreach ($medias as &$m) {
+			if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
+				$m['severity'] = (string) $severity;
+				$m['active']   = (string) $active;
+				if ($sendto !== '') {
+					$m['sendto'] = $sendto;
+				}
+				$hit = true;
+			}
+		}
+		unset($m);
+		if (!$hit) {
+			$medias[] = [
+				'mediatypeid' => $mediatypeid,
+				'sendto'      => $sendto !== '' ? $sendto : 'WeCom',
+				'severity'    => (string) $severity,
+				'active'      => (string) $active,
+				'period'      => '1-7,00:00-24:00'
+			];
+		}
+
+		API::User()->update(['userid' => $userid, 'medias' => $medias]);
+	}
+
+	/* ------------------------------------------------------------------ */
+	/*  Send test message                                                 */
+	/* ------------------------------------------------------------------ */
+
+	public static function testWebhook(string $url, string $text): array {
+		$payload = json_encode(['msgtype' => 'text', 'text' => ['content' => $text]], JSON_UNESCAPED_UNICODE);
+		$ch = curl_init($url);
+		curl_setopt_array($ch, [
+			CURLOPT_RETURNTRANSFER => true,
+			CURLOPT_POST           => true,
+			CURLOPT_POSTFIELDS     => $payload,
+			CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
+			CURLOPT_TIMEOUT        => 15,
+			CURLOPT_SSL_VERIFYPEER => false,
+			CURLOPT_SSL_VERIFYHOST => 0
+		]);
+		$body = curl_exec($ch);
+		$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+		$err = curl_error($ch);
+		curl_close($ch);
+		return ['status' => $status, 'body' => $body !== false ? $body : $err];
+	}
+
+	/* ------------------------------------------------------------------ */
+	/*  Templates                                                         */
+	/* ------------------------------------------------------------------ */
+
+	/**
+	 * Bundled templates shipped inside modules/WecomCenter/templates/.
+	 */
+	public static function bundledTemplates(string $module_dir): array {
+		$dir = rtrim($module_dir, '/').'/templates';
+		$out = [];
+		if (!is_dir($dir)) {
+			return $out;
+		}
+		foreach (scandir($dir) as $f) {
+			if ($f === '.' || $f === '..') {
+				continue;
+			}
+			$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
+			if (in_array($ext, ['xml', 'yaml', 'yml', 'json'], true)) {
+				$out[] = ['file' => $f, 'name' => pathinfo($f, PATHINFO_FILENAME)];
+			}
+		}
+		return $out;
+	}
+
+	public static function importTemplate(string $module_dir, string $file): array {
+		$dir = rtrim($module_dir, '/').'/templates';
+		$file = basename($file);
+		$path = $dir.'/'.$file;
+		if (!is_file($path)) {
+			throw new Exception('Template file not found: '.$file);
+		}
+		$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
+		$format = ['xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml'][$ext] ?? 'yaml';
+		$source = file_get_contents($path);
+		$result = API::Configuration()->import([
+			'format' => $format,
+			'source' => $source,
+			'rules'  => [
+				'template_groups' => ['createMissing' => true, 'updateExisting' => true],
+				'templates'       => ['createMissing' => true, 'updateExisting' => true],
+				'items'           => ['createMissing' => true, 'updateExisting' => true],
+				'triggers'        => ['createMissing' => true, 'updateExisting' => true]
+			]
+		]);
+		return is_array($result) ? $result : [];
+	}
+
+	/* ------------------------------------------------------------------ */
+	/*  Map coordinates                                                   */
+	/* ------------------------------------------------------------------ */
+
+	public static function hosts(): array {
+		return API::Host()->get([
+			'output'    => ['hostid', 'name'],
+			'sortfield' => 'name',
+			'limit'     => 1000
+		]);
+	}
+
+	public static function setHostLocation(string $hostid, string $lat, string $lon): void {
+		API::Host()->update([
+			'hostid'         => $hostid,
+			'inventory_mode' => 0, // manual
+			'inventory'      => [
+				'location_lat' => $lat,
+				'location_lon' => $lon
+			]
+		]);
+	}
+
+	public static function hostLocation(string $hostid): array {
+		$res = API::Host()->get([
+			'output'           => ['hostid', 'name'],
+			'selectInventory'  => ['location_lat', 'location_lon'],
+			'hostids'          => [$hostid]
+		]);
+		return $res ? $res[0] : [];
+	}
+
+	/* ------------------------------------------------------------------ */
+	/*  Helpers                                                           */
+	/* ------------------------------------------------------------------ */
+
+	public static function severityMask(array $indices): int {
+		$mask = 0;
+		foreach ($indices as $i) {
+			$i = (int) $i;
+			if ($i >= 1 && $i <= 6) {
+				$mask |= self::SEVERITY_BITS[$i - 1];
+			}
+		}
+		return $mask;
+	}
+}

+ 66 - 0
web-modules/WecomCenter/lib/I18n.php

@@ -0,0 +1,66 @@
+<?php declare(strict_types = 0);
+/*
+** WeCom Center - tiny portable i18n layer.
+** Translations live in locale/<lang>.php and return a flat key=>string map.
+** Whatever language Zabbix is running in is used automatically; any missing
+** key (or language without a file) falls back to English, so the module
+** "speaks" every Zabbix language while remaining easy to extend.
+*/
+
+namespace Modules\WecomCenter\Lib;
+
+use CWebUser;
+
+class I18n {
+
+	private static string $locale_dir = '';
+	private static array $strings = [];
+	private static string $lang = 'en_US';
+
+	public static function init(string $locale_dir): void {
+		self::$locale_dir = rtrim($locale_dir, '/');
+		self::$lang = self::currentLang();
+
+		$en = self::loadFile('en');
+		$loc = (self::$lang === 'en' || self::$lang === 'en_US') ? [] : self::loadFile(self::$lang);
+
+		// English is the base; the active language overrides individual keys.
+		self::$strings = array_merge($en, $loc);
+	}
+
+	private static function loadFile(string $lang): array {
+		$file = self::$locale_dir.'/'.$lang.'.php';
+		if (is_file($file)) {
+			$data = include $file;
+			if (is_array($data)) {
+				return $data;
+			}
+		}
+		return [];
+	}
+
+	/**
+	 * Current frontend language, e.g. "zh_CN", "en_US", "ru_RU".
+	 */
+	public static function currentLang(): string {
+		if (class_exists('CWebUser') && is_array(CWebUser::$data) && !empty(CWebUser::$data['lang'])) {
+			return (string) CWebUser::$data['lang'];
+		}
+		return 'en_US';
+	}
+
+	public static function lang(): string {
+		return self::$lang;
+	}
+
+	/**
+	 * Translate a key. Placeholders {1},{2}... are replaced with $args.
+	 */
+	public static function t(string $key, array $args = []): string {
+		$s = self::$strings[$key] ?? $key;
+		foreach (array_values($args) as $i => $v) {
+			$s = str_replace('{'.($i + 1).'}', (string) $v, $s);
+		}
+		return $s;
+	}
+}

+ 3 - 0
web-modules/WecomCenter/locale/bg_BG.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - bg_BG (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/ca_ES.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - ca_ES (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/cs_CZ.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - cs_CZ (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/da_DK.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - da_DK (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/de_DE.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - de_DE
+return [
+	'btn.import' => 'Vorlage importieren',
+	'btn.save' => 'Speichern',
+	'btn.test' => 'Test senden',
+	'lbl.account' => 'Roboter-Konto (Benutzername)',
+	'lbl.alerting_on' => 'Alarmierung aktiviert',
+	'lbl.key' => 'Webhook-URL / Roboter-Schlüssel',
+	'lbl.sendto' => 'Empfänger',
+	'lbl.severity' => 'Benachrichtigte Schweregrade',
+	'map.host' => 'Host',
+	'map.lat' => 'Breitengrad',
+	'map.lon' => 'Längengrad',
+	'menu.title' => 'WeCom Alarmzentrale',
+	'msg.saved' => 'Alarmeinstellungen gespeichert.',
+	'page.title' => 'WeCom Alarmzentrale',
+	'scheme.all' => 'Alle Schweregrade',
+	'scheme.off' => 'Aus (alles stumm)',
+	'scheme.quiet' => 'Leise (nur Katastrophe)',
+	'scheme.standard' => 'Standard (Hoch+Katastrophe)',
+	'scheme.warnplus' => 'Warnung und höher',
+	'sev.1' => 'Nicht klassifiziert',
+	'sev.2' => 'Information',
+	'sev.3' => 'Warnung',
+	'sev.4' => 'Durchschnitt',
+	'sev.5' => 'Hoch',
+	'sev.6' => 'Katastrophe',
+	'tab.alert' => 'Alarmoptimierung',
+	'tab.map' => 'Kartenkoordinaten',
+	'tab.robot' => 'Roboter & Konto',
+	'tab.templates' => 'Vorlagenimport',
+	'tpl.choose' => 'Mitgelieferte Vorlage',
+	'tpl.none' => 'Keine Vorlage gefunden',
+];

+ 3 - 0
web-modules/WecomCenter/locale/el_GR.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - el_GR (not translated yet, falls back to English)
+return [];

+ 50 - 0
web-modules/WecomCenter/locale/en.php

@@ -0,0 +1,50 @@
+<?php
+// WeCom Center - English (base language). Copy this file to <lang>.php to add a language.
+return [
+	'menu.title'      => 'WeCom Center',
+	'page.title'      => 'WeCom Center',
+	'tab.alert'       => 'Alert Optimization',
+	'tab.robot'       => 'Robot & Account',
+	'tab.templates'   => 'Template Import',
+	'tab.map'         => 'Map Coordinates',
+	'lbl.mediatype'   => 'Alert media type',
+	'lbl.alerting_on' => 'Alerting enabled',
+	'lbl.severity'    => 'Severities that will notify',
+	'lbl.sendto'      => 'Recipient (send to)',
+	'lbl.scheme_hint' => 'One-click schemes (applied immediately):',
+	'lbl.key'         => 'Webhook URL / robot key',
+	'lbl.account'     => 'Robot account (username)',
+	'btn.save'        => 'Save',
+	'btn.test'        => 'Send test',
+	'btn.import'      => 'Import template',
+	'sev.1'           => 'Not classified',
+	'sev.2'           => 'Information',
+	'sev.3'           => 'Warning',
+	'sev.4'           => 'Average',
+	'sev.5'           => 'High',
+	'sev.6'           => 'Disaster',
+	'scheme.standard' => 'Standard (High + Disaster)',
+	'scheme.warnplus' => 'Warning Plus (Average+)',
+	'scheme.all'      => 'All severities',
+	'scheme.quiet'    => 'Quiet (Disaster only)',
+	'scheme.off'      => 'Off (mute all)',
+	'tpl.choose'      => 'Bundled template',
+	'tpl.none'        => 'No bundled template found',
+	'map.choose_host' => '-- select host --',
+	'map.host'        => 'Host',
+	'map.lat'         => 'Latitude',
+	'map.lon'         => 'Longitude',
+	'msg.saved'       => 'Alert settings saved.',
+	'msg.robot_saved' => 'Robot / key saved.',
+	'msg.tested'      => 'Test message sent successfully (errcode 0).',
+	'msg.test_failed' => 'Test failed (HTTP {1}): {2}',
+	'msg.imported'    => 'Template imported. Templates: {1}',
+	'msg.map_saved'   => 'Map coordinates saved.',
+	'msg.scheme_applied' => 'Scheme applied: {1}',
+	'msg.no_mediatype' => 'No webhook media type found.',
+	'msg.no_url'      => 'Webhook URL is empty.',
+	'msg.no_template' => 'Please choose a template.',
+	'msg.no_host'     => 'Please choose a host.',
+	'msg.bad_scheme'  => 'Unknown scheme.',
+	'msg.unknown_op'  => 'Unknown operation.',
+];

+ 3 - 0
web-modules/WecomCenter/locale/en_GB.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - en_GB (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/es_ES.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - es_ES
+return [
+	'btn.import' => 'Importar plantilla',
+	'btn.save' => 'Guardar',
+	'btn.test' => 'Enviar prueba',
+	'lbl.account' => 'Cuenta del robot (usuario)',
+	'lbl.alerting_on' => 'Alertas activadas',
+	'lbl.key' => 'URL del webhook / clave del robot',
+	'lbl.sendto' => 'Destinatario',
+	'lbl.severity' => 'Gravedades notificadas',
+	'map.host' => 'Host',
+	'map.lat' => 'Latitud',
+	'map.lon' => 'Longitud',
+	'menu.title' => 'Centro de alertas WeCom',
+	'msg.saved' => 'Configuración de alertas guardada.',
+	'page.title' => 'Centro de alertas WeCom',
+	'scheme.all' => 'Todas las gravedades',
+	'scheme.off' => 'Apagado (silenciar todo)',
+	'scheme.quiet' => 'Silencioso (solo desastre)',
+	'scheme.standard' => 'Estándar (Alta+Desastre)',
+	'scheme.warnplus' => 'Advertencia y superior',
+	'sev.1' => 'Sin clasificar',
+	'sev.2' => 'Información',
+	'sev.3' => 'Advertencia',
+	'sev.4' => 'Media',
+	'sev.5' => 'Alta',
+	'sev.6' => 'Desastre',
+	'tab.alert' => 'Optimización de alertas',
+	'tab.map' => 'Coordenadas del mapa',
+	'tab.robot' => 'Robot y cuenta',
+	'tab.templates' => 'Importar plantilla',
+	'tpl.choose' => 'Plantilla integrada',
+	'tpl.none' => 'No hay plantilla integrada',
+];

+ 3 - 0
web-modules/WecomCenter/locale/fa_IR.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - fa_IR (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/fi_FI.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - fi_FI (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/fr_FR.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - fr_FR
+return [
+	'btn.import' => 'Importer le modèle',
+	'btn.save' => 'Enregistrer',
+	'btn.test' => 'Envoyer un test',
+	'lbl.account' => 'Compte du robot (utilisateur)',
+	'lbl.alerting_on' => 'Alertes activées',
+	'lbl.key' => 'URL webhook / clé du robot',
+	'lbl.sendto' => 'Destinataire',
+	'lbl.severity' => 'Gravités notifiées',
+	'map.host' => 'Hôte',
+	'map.lat' => 'Latitude',
+	'map.lon' => 'Longitude',
+	'menu.title' => 'Centre d\'alertes WeCom',
+	'msg.saved' => 'Paramètres d\'alerte enregistrés.',
+	'page.title' => 'Centre d\'alertes WeCom',
+	'scheme.all' => 'Toutes les gravités',
+	'scheme.off' => 'Désactivé (tout muet)',
+	'scheme.quiet' => 'Silencieux (catastrophe seule)',
+	'scheme.standard' => 'Standard (Haute+Catastrophe)',
+	'scheme.warnplus' => 'Avertissement et plus',
+	'sev.1' => 'Non classé',
+	'sev.2' => 'Information',
+	'sev.3' => 'Avertissement',
+	'sev.4' => 'Moyenne',
+	'sev.5' => 'Haute',
+	'sev.6' => 'Catastrophe',
+	'tab.alert' => 'Optimisation des alertes',
+	'tab.map' => 'Coordonnées de carte',
+	'tab.robot' => 'Robot et compte',
+	'tab.templates' => 'Import de modèles',
+	'tpl.choose' => 'Modèle intégré',
+	'tpl.none' => 'Aucun modèle intégré',
+];

+ 3 - 0
web-modules/WecomCenter/locale/he_IL.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - he_IL (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/hu_HU.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - hu_HU (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/id_ID.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - id_ID (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/it_IT.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - it_IT
+return [
+	'btn.import' => 'Importa template',
+	'btn.save' => 'Salva',
+	'btn.test' => 'Invia test',
+	'lbl.account' => 'Account robot (username)',
+	'lbl.alerting_on' => 'Avvisi abilitati',
+	'lbl.key' => 'URL webhook / chiave robot',
+	'lbl.sendto' => 'Destinatario',
+	'lbl.severity' => 'Gravità notificate',
+	'map.host' => 'Host',
+	'map.lat' => 'Latitudine',
+	'map.lon' => 'Longitudine',
+	'menu.title' => 'Centro avvisi WeCom',
+	'msg.saved' => 'Impostazioni avvisi salvate.',
+	'page.title' => 'Centro avvisi WeCom',
+	'scheme.all' => 'Tutte le gravità',
+	'scheme.off' => 'Spento (muto)',
+	'scheme.quiet' => 'Silenzioso (solo disastro)',
+	'scheme.standard' => 'Standard (Alta+Disastro)',
+	'scheme.warnplus' => 'Avviso e superiore',
+	'sev.1' => 'Non classificato',
+	'sev.2' => 'Informazione',
+	'sev.3' => 'Avviso',
+	'sev.4' => 'Media',
+	'sev.5' => 'Alta',
+	'sev.6' => 'Disastro',
+	'tab.alert' => 'Ottimizzazione avvisi',
+	'tab.map' => 'Coordinate mappa',
+	'tab.robot' => 'Robot e account',
+	'tab.templates' => 'Importa template',
+	'tpl.choose' => 'Template integrato',
+	'tpl.none' => 'Nessun template integrato',
+];

+ 35 - 0
web-modules/WecomCenter/locale/ja_JP.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - ja_JP
+return [
+	'btn.import' => 'テンプレートをインポート',
+	'btn.save' => '保存',
+	'btn.test' => 'テスト送信',
+	'lbl.account' => 'ロボットアカウント(ユーザー名)',
+	'lbl.alerting_on' => 'アラートを有効化',
+	'lbl.key' => 'Webhook URL / ロボットキー',
+	'lbl.sendto' => '送信先',
+	'lbl.severity' => '通知する深刻度',
+	'map.host' => 'ホスト',
+	'map.lat' => '緯度',
+	'map.lon' => '経度',
+	'menu.title' => 'WeComアラートセンター',
+	'msg.saved' => 'アラート設定を保存しました。',
+	'page.title' => 'WeComアラートセンター',
+	'scheme.all' => 'すべての深刻度',
+	'scheme.off' => 'オフ(すべてミュート)',
+	'scheme.quiet' => '静か(致命的のみ)',
+	'scheme.standard' => '標準(重大+致命的)',
+	'scheme.warnplus' => '警告以上',
+	'sev.1' => '未分類',
+	'sev.2' => '情報',
+	'sev.3' => '警告',
+	'sev.4' => '軽度',
+	'sev.5' => '重大',
+	'sev.6' => '致命的',
+	'tab.alert' => 'アラート最適化',
+	'tab.map' => '地図座標',
+	'tab.robot' => 'ロボットとアカウント',
+	'tab.templates' => 'テンプレートのインポート',
+	'tpl.choose' => '内蔵テンプレート',
+	'tpl.none' => '内蔵テンプレートなし',
+];

+ 3 - 0
web-modules/WecomCenter/locale/ka_GE.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - ka_GE (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/ko_KR.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - ko_KR
+return [
+	'btn.import' => '템플릿 가져오기',
+	'btn.save' => '저장',
+	'btn.test' => '테스트 전송',
+	'lbl.account' => '로봇 계정(사용자 이름)',
+	'lbl.alerting_on' => '경보 활성화',
+	'lbl.key' => 'Webhook URL / 로봇 키',
+	'lbl.sendto' => '수신 대상',
+	'lbl.severity' => '알림할 심각도',
+	'map.host' => '호스트',
+	'map.lat' => '위도',
+	'map.lon' => '경도',
+	'menu.title' => 'WeCom 경보 센터',
+	'msg.saved' => '경보 설정이 저장되었습니다.',
+	'page.title' => 'WeCom 경보 센터',
+	'scheme.all' => '모든 심각도',
+	'scheme.off' => '끄기(모두 음소거)',
+	'scheme.quiet' => '조용(재난만)',
+	'scheme.standard' => '표준(높음+재난)',
+	'scheme.warnplus' => '경고 이상',
+	'sev.1' => '미분류',
+	'sev.2' => '정보',
+	'sev.3' => '경고',
+	'sev.4' => '보통',
+	'sev.5' => '높음',
+	'sev.6' => '재난',
+	'tab.alert' => '경보 최적화',
+	'tab.map' => '지도 좌표',
+	'tab.robot' => '로봇 및 계정',
+	'tab.templates' => '템플릿 가져오기',
+	'tpl.choose' => '내장 템플릿',
+	'tpl.none' => '내장 템플릿 없음',
+];

+ 3 - 0
web-modules/WecomCenter/locale/lt_LT.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - lt_LT (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/lv_LV.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - lv_LV (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/nb_NO.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - nb_NO (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/nl_NL.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - nl_NL (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/pl_PL.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - pl_PL
+return [
+	'btn.import' => 'Importuj szablon',
+	'btn.save' => 'Zapisz',
+	'btn.test' => 'Wyślij test',
+	'lbl.account' => 'Konto robota (użytkownik)',
+	'lbl.alerting_on' => 'Alerty włączone',
+	'lbl.key' => 'URL webhooka / klucz robota',
+	'lbl.sendto' => 'Odbiorca',
+	'lbl.severity' => 'Powiadamiane poziomy',
+	'map.host' => 'Host',
+	'map.lat' => 'Szerokość',
+	'map.lon' => 'Długość',
+	'menu.title' => 'Centrum alertów WeCom',
+	'msg.saved' => 'Zapisano ustawienia alertów.',
+	'page.title' => 'Centrum alertów WeCom',
+	'scheme.all' => 'Wszystkie poziomy',
+	'scheme.off' => 'Wyłączone (wszystko wyciszone)',
+	'scheme.quiet' => 'Cichy (tylko katastrofa)',
+	'scheme.standard' => 'Standard (Wysoki+Katastrofa)',
+	'scheme.warnplus' => 'Ostrzeżenie i wyżej',
+	'sev.1' => 'Niesklasyfikowane',
+	'sev.2' => 'Informacja',
+	'sev.3' => 'Ostrzeżenie',
+	'sev.4' => 'Średni',
+	'sev.5' => 'Wysoki',
+	'sev.6' => 'Katastrofa',
+	'tab.alert' => 'Optymalizacja alertów',
+	'tab.map' => 'Współrzędne mapy',
+	'tab.robot' => 'Robot i konto',
+	'tab.templates' => 'Import szablonu',
+	'tpl.choose' => 'Wbudowany szablon',
+	'tpl.none' => 'Brak wbudowanego szablonu',
+];

+ 35 - 0
web-modules/WecomCenter/locale/pt_BR.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - pt_BR
+return [
+	'btn.import' => 'Importar modelo',
+	'btn.save' => 'Salvar',
+	'btn.test' => 'Enviar teste',
+	'lbl.account' => 'Conta do robô (usuário)',
+	'lbl.alerting_on' => 'Alertas ativados',
+	'lbl.key' => 'URL do webhook / chave do robô',
+	'lbl.sendto' => 'Destinatário',
+	'lbl.severity' => 'Gravidades notificadas',
+	'map.host' => 'Host',
+	'map.lat' => 'Latitude',
+	'map.lon' => 'Longitude',
+	'menu.title' => 'Central de alertas WeCom',
+	'msg.saved' => 'Configurações de alerta salvas.',
+	'page.title' => 'Central de alertas WeCom',
+	'scheme.all' => 'Todas as gravidades',
+	'scheme.off' => 'Desligado (silenciar tudo)',
+	'scheme.quiet' => 'Silencioso (apenas desastre)',
+	'scheme.standard' => 'Padrão (Alta+Desastre)',
+	'scheme.warnplus' => 'Aviso e acima',
+	'sev.1' => 'Não classificado',
+	'sev.2' => 'Informação',
+	'sev.3' => 'Aviso',
+	'sev.4' => 'Média',
+	'sev.5' => 'Alta',
+	'sev.6' => 'Desastre',
+	'tab.alert' => 'Otimização de alertas',
+	'tab.map' => 'Coordenadas do mapa',
+	'tab.robot' => 'Robô e conta',
+	'tab.templates' => 'Importar modelo',
+	'tpl.choose' => 'Modelo integrado',
+	'tpl.none' => 'Nenhum modelo integrado',
+];

+ 3 - 0
web-modules/WecomCenter/locale/ro_RO.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - ro_RO (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/ru_RU.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - ru_RU
+return [
+	'btn.import' => 'Импорт шаблона',
+	'btn.save' => 'Сохранить',
+	'btn.test' => 'Отправить тест',
+	'lbl.account' => 'Учётная запись робота (имя)',
+	'lbl.alerting_on' => 'Оповещения включены',
+	'lbl.key' => 'URL вебхука / ключ робота',
+	'lbl.sendto' => 'Получатель',
+	'lbl.severity' => 'Уровни важности для уведомлений',
+	'map.host' => 'Узел',
+	'map.lat' => 'Широта',
+	'map.lon' => 'Долгота',
+	'menu.title' => 'Центр оповещений WeCom',
+	'msg.saved' => 'Настройки оповещений сохранены.',
+	'page.title' => 'Центр оповещений WeCom',
+	'scheme.all' => 'Все уровни',
+	'scheme.off' => 'Выкл (без звука)',
+	'scheme.quiet' => 'Тихий (только катастрофа)',
+	'scheme.standard' => 'Стандарт (Высокая+Катастрофа)',
+	'scheme.warnplus' => 'Предупреждение и выше',
+	'sev.1' => 'Не классифицировано',
+	'sev.2' => 'Информация',
+	'sev.3' => 'Предупреждение',
+	'sev.4' => 'Средняя',
+	'sev.5' => 'Высокая',
+	'sev.6' => 'Катастрофа',
+	'tab.alert' => 'Оптимизация оповещений',
+	'tab.map' => 'Координаты карты',
+	'tab.robot' => 'Робот и учётная запись',
+	'tab.templates' => 'Импорт шаблонов',
+	'tpl.choose' => 'Встроенный шаблон',
+	'tpl.none' => 'Встроенных шаблонов нет',
+];

+ 3 - 0
web-modules/WecomCenter/locale/sk_SK.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - sk_SK (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/sv_SE.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - sv_SE (not translated yet, falls back to English)
+return [];

+ 35 - 0
web-modules/WecomCenter/locale/tr_TR.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - tr_TR
+return [
+	'btn.import' => 'Şablonu içe aktar',
+	'btn.save' => 'Kaydet',
+	'btn.test' => 'Test gönder',
+	'lbl.account' => 'Robot hesabı (kullanıcı adı)',
+	'lbl.alerting_on' => 'Uyarılar etkin',
+	'lbl.key' => 'Webhook URL / robot anahtarı',
+	'lbl.sendto' => 'Alıcı',
+	'lbl.severity' => 'Bildirilecek önem düzeyleri',
+	'map.host' => 'Ana Bilgisayar',
+	'map.lat' => 'Enlem',
+	'map.lon' => 'Boylam',
+	'menu.title' => 'WeCom Uyarı Merkezi',
+	'msg.saved' => 'Uyarı ayarları kaydedildi.',
+	'page.title' => 'WeCom Uyarı Merkezi',
+	'scheme.all' => 'Tüm önem düzeyleri',
+	'scheme.off' => 'Kapalı (tümü sessiz)',
+	'scheme.quiet' => 'Sessiz (yalnızca felaket)',
+	'scheme.standard' => 'Standart (Yüksek+Felaket)',
+	'scheme.warnplus' => 'Uyarı ve üzeri',
+	'sev.1' => 'Sınıflandırılmamış',
+	'sev.2' => 'Bilgi',
+	'sev.3' => 'Uyarı',
+	'sev.4' => 'Ortalama',
+	'sev.5' => 'Yüksek',
+	'sev.6' => 'Felaket',
+	'tab.alert' => 'Uyarı Optimizasyonu',
+	'tab.map' => 'Harita Koordinatları',
+	'tab.robot' => 'Robot ve Hesap',
+	'tab.templates' => 'Şablon İçe Aktar',
+	'tpl.choose' => 'Yerleşik şablon',
+	'tpl.none' => 'Yerleşik şablon yok',
+];

+ 3 - 0
web-modules/WecomCenter/locale/uk_UA.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - uk_UA (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/uz_UZ.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - uz_UZ (not translated yet, falls back to English)
+return [];

+ 3 - 0
web-modules/WecomCenter/locale/vi_VN.php

@@ -0,0 +1,3 @@
+<?php
+// WeCom Center - vi_VN (not translated yet, falls back to English)
+return [];

+ 50 - 0
web-modules/WecomCenter/locale/zh_CN.php

@@ -0,0 +1,50 @@
+<?php
+// WeCom Center - 简体中文
+return [
+	'menu.title'      => '企微告警中心',
+	'page.title'      => '企微告警中心',
+	'tab.alert'       => '告警优化',
+	'tab.robot'       => '机器人与账号',
+	'tab.templates'   => '模板导入',
+	'tab.map'         => '地图坐标',
+	'lbl.mediatype'   => '告警媒介类型',
+	'lbl.alerting_on' => '启用告警',
+	'lbl.severity'    => '触发通知的严重度',
+	'lbl.sendto'      => '接收目标(sendto)',
+	'lbl.scheme_hint' => '一键方案(立即生效):',
+	'lbl.key'         => 'Webhook 地址 / 机器人 key',
+	'lbl.account'     => '机器人账号(用户名)',
+	'btn.save'        => '保存',
+	'btn.test'        => '发送测试',
+	'btn.import'      => '导入模板',
+	'sev.1'           => '未分类',
+	'sev.2'           => '信息',
+	'sev.3'           => '警告',
+	'sev.4'           => '一般严重',
+	'sev.5'           => '严重',
+	'sev.6'           => '灾难',
+	'scheme.standard' => '标准(严重+灾难)',
+	'scheme.warnplus' => '警告以上(一般严重及以上)',
+	'scheme.all'      => '全部严重度',
+	'scheme.quiet'    => '安静(仅灾难)',
+	'scheme.off'      => '关闭(全部静音)',
+	'tpl.choose'      => '内置模板',
+	'tpl.none'        => '未找到内置模板',
+	'map.choose_host' => '-- 选择主机 --',
+	'map.host'        => '主机',
+	'map.lat'         => '纬度',
+	'map.lon'         => '经度',
+	'msg.saved'       => '告警设置已保存。',
+	'msg.robot_saved' => '机器人/ key 已保存。',
+	'msg.tested'      => '测试消息发送成功(errcode 0)。',
+	'msg.test_failed' => '测试失败(HTTP {1}):{2}',
+	'msg.imported'    => '模板已导入。模板数:{1}',
+	'msg.map_saved'   => '地图坐标已保存。',
+	'msg.scheme_applied' => '已应用方案:{1}',
+	'msg.no_mediatype' => '未找到 webhook 媒介类型。',
+	'msg.no_url'      => 'Webhook 地址为空。',
+	'msg.no_template' => '请选择一个模板。',
+	'msg.no_host'     => '请选择一台主机。',
+	'msg.bad_scheme'  => '未知方案。',
+	'msg.unknown_op'  => '未知操作。',
+];

+ 35 - 0
web-modules/WecomCenter/locale/zh_TW.php

@@ -0,0 +1,35 @@
+<?php
+// WeCom Center - zh_TW
+return [
+	'btn.import' => '匯入範本',
+	'btn.save' => '儲存',
+	'btn.test' => '傳送測試',
+	'lbl.account' => '機器人帳號(使用者名稱)',
+	'lbl.alerting_on' => '啟用告警',
+	'lbl.key' => 'Webhook 位址 / 機器人 key',
+	'lbl.sendto' => '接收目標',
+	'lbl.severity' => '觸發通知的嚴重度',
+	'map.host' => '主機',
+	'map.lat' => '緯度',
+	'map.lon' => '經度',
+	'menu.title' => '企微告警中心',
+	'msg.saved' => '告警設定已儲存。',
+	'page.title' => '企微告警中心',
+	'scheme.all' => '全部嚴重度',
+	'scheme.off' => '關閉(全部靜音)',
+	'scheme.quiet' => '安靜(僅災難)',
+	'scheme.standard' => '標準(嚴重+災難)',
+	'scheme.warnplus' => '警告以上',
+	'sev.1' => '未分類',
+	'sev.2' => '資訊',
+	'sev.3' => '警告',
+	'sev.4' => '一般嚴重',
+	'sev.5' => '嚴重',
+	'sev.6' => '災難',
+	'tab.alert' => '告警優化',
+	'tab.map' => '地圖座標',
+	'tab.robot' => '機器人與帳號',
+	'tab.templates' => '範本匯入',
+	'tpl.choose' => '內建範本',
+	'tpl.none' => '找不到內建範本',
+];

+ 25 - 0
web-modules/WecomCenter/manifest.json

@@ -0,0 +1,25 @@
+{
+	"manifest_version": 2.0,
+	"id": "wecom_center",
+	"type": "module",
+	"name": "WeCom Center",
+	"namespace": "WecomCenter",
+	"version": "1.0.0",
+	"author": "COBCO IT / FlyingRome",
+	"description": "One-stop customization center for Zabbix: alert optimization switches & presets, WeCom robot / key / media configuration, one-click template import, and map coordinate adjustment.",
+	"url": "https://gogs.example/FlyingRome/zabbix-deploy",
+	"actions": {
+		"wecom.center.view": {
+			"class": "WecomCenterView",
+			"layout": "layout.htmlpage",
+			"view": "wecom.center.view"
+		},
+		"wecom.center.save": {
+			"class": "WecomCenterSave"
+		}
+	},
+	"assets": {
+		"css": [],
+		"js": []
+	}
+}

+ 12 - 0
web-modules/WecomCenter/templates/demo.yaml

@@ -0,0 +1,12 @@
+zabbix_export:
+  version: '7.0'
+  template_groups:
+    - uuid: 7df96b18c230490a9a0a9e2307226338
+      name: Templates
+  templates:
+    - uuid: a571c0d144b14fd4a87a9d9b2aa9fcd6
+      template: 'Demo - WeCom Center'
+      name: 'Demo - WeCom Center'
+      description: 'Sample template shipped with the WeCom Center module to demonstrate one-click template import.'
+      groups:
+        - name: Templates

+ 212 - 0
web-modules/WecomCenter/views/wecom.center.view.php

@@ -0,0 +1,212 @@
+<?php declare(strict_types = 0);
+/*
+** WeCom Center - page view.
+** @var CView $this
+** @var array  $data
+*/
+
+use Modules\WecomCenter\Lib\I18n;
+
+$this->addCssFile('modules/WecomCenter/assets/wecom.center.css');
+
+$save_url = (new CUrl('zabbix.php'))->setArgument('action', 'wecom.center.save')->getUrl();
+
+$html_page = (new CHtmlPage())->setTitle(I18n::t('page.title'));
+
+/* ------------------------------------------------------------------ */
+/*  Media-type switcher (only when several webhook media types exist)  */
+/* ------------------------------------------------------------------ */
+if (count($data['mediatypes']) > 1) {
+	$list = new CList();
+	foreach ($data['mediatypes'] as $mt) {
+		$label = $mt['name'];
+		if ((string) $mt['mediatypeid'] === (string) $data['mediatypeid']) {
+			$list->addItem(new CSpan('['.$label.']'));
+		}
+		else {
+			$list->addItem(new CLink($label,
+				(new CUrl('zabbix.php'))
+					->setArgument('action', 'wecom.center.view')
+					->setArgument('mediatypeid', $mt['mediatypeid'])
+					->getUrl()
+			));
+		}
+	}
+	$html_page->addItem((new CDiv())->addItem(new CTag('h3'))->addItem(I18n::t('lbl.mediatype'))->addItem($list));
+}
+elseif ($data['mediatype']) {
+	$html_page->addItem(
+		(new CDiv())
+			->addItem((new CTag('h3'))->addItem(I18n::t('lbl.mediatype')))
+			->addItem(new CSpan($data['mediatype']['name']))
+	);
+}
+
+if (!$data['mediatype']) {
+	$html_page->addItem((new CDiv())->addItem(
+		(new CTag('p'))->addItem(I18n::t('msg.no_mediatype'))
+	));
+	$html_page->show();
+	return;
+}
+
+$mask = (int) $data['severity'];
+$enabled = ((int) $data['active'] === 0);
+
+/* ------------------------------------------------------------------ */
+/*  1) Alert optimization (the core: simple switches + presets)        */
+/* ------------------------------------------------------------------ */
+$sev_items = new CList();
+for ($i = 1; $i <= 6; $i++) {
+	$bit = 1 << ($i - 1);
+	$sev_items->addItem(
+		(new CCheckBox('sev_'.$i))
+			->setLabel(I18n::t('sev.'.$i))
+			->setChecked(($mask & $bit) !== 0)
+	);
+}
+
+$enabled_box = (new CCheckBox('enabled'))
+	->setLabel(I18n::t('lbl.alerting_on'))
+	->setChecked($enabled);
+
+$alert_list = (new CFormList())
+	->addRow(I18n::t('lbl.alerting_on'), $enabled_box)
+	->addRow(I18n::t('lbl.severity'), $sev_items)
+	->addRow(I18n::t('lbl.sendto'),
+		(new CTextBox('sendto', $data['sendto']))->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH)
+	);
+
+$preset_buttons = [];
+foreach ($data['schemes'] as $key => $def) {
+	$preset_buttons[] = (new CSubmitButton(I18n::t('scheme.'.$key), 'scheme_'.$key, '1'))
+		->addClass(ZBX_STYLE_BTN_ALT);
+}
+
+$alert_form = (new CForm())
+	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
+	->addItem((new CVar('username', $data['robot_name']))->removeId())
+	->addItem(
+		(new CTabView())->addTab('alert', I18n::t('tab.alert'), $alert_list)
+			->setFooter(makeFormFooter(
+				(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_alert'))->setId('alert-save'),
+				[]
+			))
+	)
+	->setAction($save_url)
+	->setId('wecom-alert-form');
+
+// Preset form (separate buttons outside footer of alert form)
+$preset_form = (new CForm())
+	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
+	->addItem((new CVar('username', $data['robot_name']))->removeId())
+	->addItem(new CDiv($preset_buttons))
+	->setAction($save_url)
+	->setId('wecom-preset-form');
+
+$html_page->addItem(
+	(new CDiv())
+		->addItem((new CTag('h3'))->addItem(I18n::t('tab.alert')))
+		->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.scheme_hint')))
+		->addItem($preset_form)
+		->addItem($alert_form)
+);
+
+/* ------------------------------------------------------------------ */
+/*  2) Robot / account / key                                          */
+/* ------------------------------------------------------------------ */
+$robot_list = (new CFormList())
+	->addRow(I18n::t('lbl.key'),
+		(new CTextBox('webhook_url', $data['webhook_url']))
+			->setWidth(ZBX_TEXTAREA_BIG_WIDTH)
+			->setAttribute('style', 'font-family:monospace;'))
+	->addRow(I18n::t('lbl.account'),
+		(new CTextBox('username', $data['robot_name']))->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH))
+	->addRow(I18n::t('lbl.sendto'),
+		(new CTextBox('sendto', $data['sendto']))->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH));
+
+$robot_form = (new CForm())
+	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
+	->addItem(
+		(new CTabView())->addTab('robot', I18n::t('tab.robot'), $robot_list)
+			->setFooter(makeFormFooter(
+				(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_robot'))->setId('robot-save'),
+				[(new CSubmitButton(I18n::t('btn.test'), 'op', 'test_robot'))->addClass(ZBX_STYLE_BTN_ALT)]
+			))
+	)
+	->setAction($save_url)
+	->setId('wecom-robot-form');
+
+$html_page->addItem(
+	(new CDiv())
+		->addItem((new CTag('h3'))->addItem(I18n::t('tab.robot')))
+		->addItem($robot_form)
+);
+
+/* ------------------------------------------------------------------ */
+/*  3) One-click template import                                      */
+/* ------------------------------------------------------------------ */
+$tpl_select = new CSelect('template_file');
+if (!$data['templates']) {
+	$tpl_select->addOption(new CSelectOption('', I18n::t('tpl.none')));
+}
+foreach ($data['templates'] as $t) {
+	$tpl_select->addOption(new CSelectOption($t['file'], $t['name']));
+}
+
+$tpl_list = (new CFormList())->addRow(I18n::t('tpl.choose'), $tpl_select);
+
+$tpl_form = (new CForm())
+	->addItem(
+		(new CTabView())->addTab('tpl', I18n::t('tab.templates'), $tpl_list)
+			->setFooter(makeFormFooter(
+				(new CSubmitButton(I18n::t('btn.import'), 'op', 'import_template'))->setId('tpl-import'),
+				[]
+			))
+	)
+	->setAction($save_url)
+	->setId('wecom-tpl-form');
+
+$html_page->addItem(
+	(new CDiv())
+		->addItem((new CTag('h3'))->addItem(I18n::t('tab.templates')))
+		->addItem($tpl_form)
+);
+
+/* ------------------------------------------------------------------ */
+/*  4) Map coordinates                                                */
+/* ------------------------------------------------------------------ */
+$host_select = new CSelect('hostid');
+$host_select->addOption(new CSelectOption('', I18n::t('map.choose_host')));
+foreach ($data['hosts'] as $h) {
+	$host_select->addOption(new CSelectOption($h['hostid'], $h['name']));
+}
+if ($data['selected_hostid'] !== '') {
+	$host_select->setValue($data['selected_hostid']);
+}
+
+$map_list = (new CFormList())
+	->addRow(I18n::t('map.host'), $host_select)
+	->addRow(I18n::t('map.lat'),
+		(new CTextBox('lat', $data['host_loc']['location_lat']))->setWidth(ZBX_TEXTAREA_SMALL_WIDTH))
+	->addRow(I18n::t('map.lon'),
+		(new CTextBox('lon', $data['host_loc']['location_lon']))->setWidth(ZBX_TEXTAREA_SMALL_WIDTH));
+
+$map_form = (new CForm())
+	->addItem(
+		(new CTabView())->addTab('map', I18n::t('tab.map'), $map_list)
+			->setFooter(makeFormFooter(
+				(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_map'))->setId('map-save'),
+				[]
+			))
+	)
+	->setAction($save_url)
+	->setId('wecom-map-form');
+
+$html_page->addItem(
+	(new CDiv())
+		->addItem((new CTag('h3'))->addItem(I18n::t('tab.map')))
+		->addItem($map_form)
+);
+
+$html_page->show();