diff --git a/app/admin/controller/xhprof/RunWatch.php b/app/admin/controller/xhprof/RunWatch.php
new file mode 100644
index 0000000..261f7e0
--- /dev/null
+++ b/app/admin/controller/xhprof/RunWatch.php
@@ -0,0 +1,84 @@
+model = new \app\admin\model\XhprofRunWatch();
+
+ // 命中记录的规则下拉(含禁用规则,便于回溯历史命中)
+ $this->assign('select_list_watch', \app\admin\model\XhprofWatch::column('name', 'id'), true);
+ }
+
+ /**
+ * @NodeAnotation(title="列表")
+ */
+ public function index()
+ {
+ if ($this->request->isAjax()) {
+ if (input('selectFields')) {
+ return $this->selectList();
+ }
+ list($page, $limit, $where, $excludes, $request_options, $group) = $this->buildTableParames();
+ $count = $this->model
+ ->withJoin(['run', 'watch'], 'LEFT')
+ ->where($where)
+ ->group($group)
+ ->count();
+ $list = $this->model
+ ->withJoin(['run', 'watch'], 'LEFT')
+ ->where($where)
+ ->page($page, $limit)
+ ->order($this->sort)
+ ->group($group)
+ ->select();
+ $data = [
+ 'code' => 0,
+ 'msg' => '',
+ 'count' => $count,
+ 'data' => $list,
+ ];
+
+ return json($data);
+ }
+
+ return $this->fetch();
+ }
+
+ /**
+ * @NodeAnotation(title="详情")
+ */
+ public function read($id)
+ {
+ $row = $this->model->withJoin(['run', 'watch'], 'LEFT')->find($id);
+ empty($row) && $this->error('数据不存在');
+
+ // 获取模型的标题(表注释)
+ $title = $row->title;
+
+ $this->assign('row', $row);
+ $this->assign('title', $title);
+
+ return $this->fetch();
+ }
+}
diff --git a/app/admin/controller/xhprof/Watch.php b/app/admin/controller/xhprof/Watch.php
new file mode 100644
index 0000000..5988f67
--- /dev/null
+++ b/app/admin/controller/xhprof/Watch.php
@@ -0,0 +1,72 @@
+model = new \app\admin\model\XhprofWatch();
+
+ $this->assign('select_list_status', $this->model::SELECT_LIST_STATUS, true);
+ }
+
+ /**
+ * @NodeAnotation(title="添加")
+ */
+ public function add()
+ {
+ if ($this->request->isPost()) {
+ $this->validateWatchRegex((string) $this->request->post('regex', ''));
+ }
+
+ return $this->curdAdd();
+ }
+
+ /**
+ * @NodeAnotation(title="编辑")
+ */
+ public function edit($id)
+ {
+ if ($this->request->isPost()) {
+ $this->validateWatchRegex((string) $this->request->post('regex', ''));
+ }
+
+ return $this->curdEdit($id);
+ }
+
+ /**
+ * 校验 PCRE 正则合法性(与导入任务 compileWatchRegexes 同一判据).
+ */
+ protected function validateWatchRegex(string $regex): void
+ {
+ if ($regex === '') {
+ $this->error('正则不能为空');
+ }
+ error_clear_last();
+ if (@preg_match($regex, '') === false) {
+ $error = error_get_last();
+ $message = $error !== null ? $error['message'] : preg_last_error_msg();
+ $this->error('正则非法(必须带定界符,如 ~think\\\\db\\\\.*~):' . $message);
+ }
+ }
+}
diff --git a/app/admin/model/XhprofRunWatch.php b/app/admin/model/XhprofRunWatch.php
index 3729b07..199d35d 100644
--- a/app/admin/model/XhprofRunWatch.php
+++ b/app/admin/model/XhprofRunWatch.php
@@ -5,6 +5,8 @@ namespace app\admin\model;
use app\common\model\TimeModel;
/**
+ * XHProf 采样与监控规则命中聚合(导入时物化)
+ *
* @property int $id 主键ID
* @property int $run_id 采样记录ID(ul_xhprof_run.id)
* @property int $watch_id 监控规则ID(ul_xhprof_watch.id)
@@ -20,4 +22,13 @@ class XhprofRunWatch extends TimeModel
protected $deleteTime = false;
+ public function run()
+ {
+ return $this->belongsTo('app\admin\model\XhprofRun', 'run_id', 'id');
+ }
+
+ public function watch()
+ {
+ return $this->belongsTo('app\admin\model\XhprofWatch', 'watch_id', 'id');
+ }
}
diff --git a/app/admin/model/XhprofWatch.php b/app/admin/model/XhprofWatch.php
index c4507c6..668d149 100644
--- a/app/admin/model/XhprofWatch.php
+++ b/app/admin/model/XhprofWatch.php
@@ -5,9 +5,11 @@ namespace app\admin\model;
use app\common\model\TimeModel;
/**
+ * XHProf 函数监控规则
+ *
* @property int $id 主键ID
* @property string $name 规则名称(人读)
- * @property string $regex PCRE 正则(对被调用函数名匹配)
+ * @property string $regex PCRE 正则(对被调用函数名匹配,带定界符)
* @property string $note 备注
* @property int $threshold_ms 比对阈值(毫秒),0=不比对
* @property int $status 状态:1=启用,0=禁用
@@ -21,4 +23,5 @@ class XhprofWatch extends TimeModel
protected $deleteTime = false;
+ public const SELECT_LIST_STATUS = ['0' => '禁用', '1' => '启用'];
}
diff --git a/app/admin/view/xhprof/run_watch/_common.js b/app/admin/view/xhprof/run_watch/_common.js
new file mode 100644
index 0000000..796e547
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/_common.js
@@ -0,0 +1,11 @@
+var init = {
+ tableElem: '#currentTable',
+ tableRenderId: 'currentTableRenderId',
+ indexUrl: 'xhprof.run_watch/index',
+ addUrl: '',
+ editUrl: '',
+ readUrl: 'xhprof.run_watch/read',
+ deleteUrl: '',
+ exportUrl: '',
+ modifyUrl: '',
+};
diff --git a/app/admin/view/xhprof/run_watch/add.html b/app/admin/view/xhprof/run_watch/add.html
new file mode 100644
index 0000000..e9f206d
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/add.html
@@ -0,0 +1,44 @@
+
\ No newline at end of file
diff --git a/app/admin/view/xhprof/run_watch/add.js b/app/admin/view/xhprof/run_watch/add.js
new file mode 100644
index 0000000..4a445e0
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/add.js
@@ -0,0 +1,3 @@
+$(function(){
+ ua.listen();
+})
\ No newline at end of file
diff --git a/app/admin/view/xhprof/run_watch/edit.html b/app/admin/view/xhprof/run_watch/edit.html
new file mode 100644
index 0000000..ff5d652
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/edit.html
@@ -0,0 +1,44 @@
+
\ No newline at end of file
diff --git a/app/admin/view/xhprof/run_watch/edit.js b/app/admin/view/xhprof/run_watch/edit.js
new file mode 100644
index 0000000..4a445e0
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/edit.js
@@ -0,0 +1,3 @@
+$(function(){
+ ua.listen();
+})
\ No newline at end of file
diff --git a/app/admin/view/xhprof/run_watch/index.html b/app/admin/view/xhprof/run_watch/index.html
new file mode 100644
index 0000000..1f43095
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/index.html
@@ -0,0 +1,9 @@
+
diff --git a/app/admin/view/xhprof/run_watch/index.js b/app/admin/view/xhprof/run_watch/index.js
new file mode 100644
index 0000000..e54fdd3
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/index.js
@@ -0,0 +1,62 @@
+$(function(){
+ ua.table.render({
+ init: init,
+ toolbar: [],
+ defaultToolbar: ['filter'],
+ cols: [[
+ {field: 'id', title: 'ID', width: 80, sort: true},
+ {field: 'watch_id', title: '监控规则', width: 160, search: 'select', searchOp: '=', selectList: ua.getDataBrage('select_list_watch'), templet: function(d) {
+ return d.watch && d.watch.name ? d.watch.name : ('#' + d.watch_id);
+ }},
+ {field: 'run.url', title: '请求 URL', minWidth: 220, search: false, templet: function(d) {
+ if (!d.run || !d.run.url) {
+ return '#' + d.run_id + '';
+ }
+ var esc = String(d.run.url).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ return '' + esc + '';
+ }},
+ {field: 'run.request_time', title: '请求时间', width: 170, search: 'range', templet: function(d) {
+ if (!d.run || !d.run.request_time) {
+ return '-';
+ }
+ var ts = parseFloat(d.run.request_time);
+ if (isNaN(ts) || ts <= 0) {
+ return '-';
+ }
+ return window.UA_CORE.util.toDateString(ts * 1000, 'yyyy-MM-dd HH:mm:ss');
+ }},
+ {field: 'ct', title: '命中次数', width: 100, search: 'number_limit'},
+ {field: 'wt_sum', title: '耗时合计(ms)', width: 140, search: 'number_limit', templet: function(d) {
+ var ms = parseFloat(d.wt_sum) / 1000;
+ var threshold = d.watch ? parseInt(d.watch.threshold_ms, 10) : 0;
+ var text = (isNaN(ms) ? 0 : ms).toFixed(3) + ' ms';
+ if (threshold > 0 && ms > threshold) {
+ return '' + text + '(超阈值 ' + threshold + 'ms)';
+ }
+ return text;
+ }},
+ {field: 'wt_max', title: '单次最大(ms)', width: 130, search: 'number_limit', templet: function(d) {
+ var ms = parseFloat(d.wt_max) / 1000;
+ return (isNaN(ms) ? 0 : ms).toFixed(3) + ' ms';
+ }},
+ {field: 'create_time', title: '入库时间', width: 170, search: false, templet: ua.table.date},
+ {
+ width: 100, title: '操作', templet: ua.table.tool, fixed: 'right', operat: [
+ [{
+ class: 'layui-btn layui-btn-primary layui-btn-xs',
+ method: 'tab',
+ field: 'id',
+ text: '详情',
+ title: '查看详情',
+ auth: 'read',
+ url: init.readUrl,
+ icon: ''
+ }]
+ ]
+ },
+
+ ]],
+ });
+
+ ua.listen();
+})
diff --git a/app/admin/view/xhprof/run_watch/read.html b/app/admin/view/xhprof/run_watch/read.html
new file mode 100644
index 0000000..6c74f8d
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/read.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
监控规则
+
+ {notempty name="row.watch.name"}
+ {$row.watch.name}(#{$row.watch_id})
+ {else/}
+ #{$row.watch_id}
+ {/notempty}
+
+
+
+
命中次数(边数和)
+
+ {$row.ct}
+
+
+
+
耗时合计
+
+ {$row.wt_sum} μs = {:number_format($row['wt_sum'] / 1000, 3)} ms
+ {if condition="$row['watch']['threshold_ms'] > 0 && ($row['wt_sum'] / 1000) > $row['watch']['threshold_ms']"}
+ 超阈值 {$row.watch.threshold_ms}ms
+ {/if}
+
+
+
+
单次最大耗时
+
+ {$row.wt_max} μs = {:number_format($row['wt_max'] / 1000, 3)} ms
+
+
+
+
+
+
+
关联采样
+
+
+
采样记录ID
+
#{$row.run_id}
+
+
+
请求 URL
+
+ {notempty name="row.run.url"}
+ {$row.run.url}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
请求时间
+
+ {notempty name="row.run.request_time"}
+ {:date('Y-m-d H:i:s', intval($row['run']['request_time']))}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
入库时间
+
+ {notempty name="row.create_time"}
+ {$row.create_time|date="Y-m-d H:i:s"}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
+
+
+
+
+
diff --git a/app/admin/view/xhprof/run_watch/read.js b/app/admin/view/xhprof/run_watch/read.js
new file mode 100644
index 0000000..f5671a7
--- /dev/null
+++ b/app/admin/view/xhprof/run_watch/read.js
@@ -0,0 +1,22 @@
+$(function(){
+ // 删除数据
+ window.deleteData = function(id) {
+ layer.confirm('确定要删除这条数据吗?', {
+ icon: 3,
+ title: '提示'
+ }, function(index) {
+ $.post('{{:url("delete")}}', {id: id}, function(res) {
+ if (res.code == 0) {
+ layer.msg('删除成功', {icon: 1}, function() {
+ location.href = '{{:url("index")}}';
+ });
+ } else {
+ layer.msg(res.msg, {icon: 2});
+ }
+ }, 'json');
+ layer.close(index);
+ });
+ };
+
+ ua.listen();
+})
\ No newline at end of file
diff --git a/app/admin/view/xhprof/watch/_common.js b/app/admin/view/xhprof/watch/_common.js
new file mode 100644
index 0000000..4f9f9c5
--- /dev/null
+++ b/app/admin/view/xhprof/watch/_common.js
@@ -0,0 +1,11 @@
+var init = {
+ tableElem: '#currentTable',
+ tableRenderId: 'currentTableRenderId',
+ indexUrl: 'xhprof.watch/index',
+ addUrl: 'xhprof.watch/add' + location.search,
+ editUrl: 'xhprof.watch/edit',
+ readUrl: 'xhprof.watch/read',
+ deleteUrl: 'xhprof.watch/delete',
+ exportUrl: '',
+ modifyUrl: 'xhprof.watch/modify',
+};
diff --git a/app/admin/view/xhprof/watch/add.html b/app/admin/view/xhprof/watch/add.html
new file mode 100644
index 0000000..c278175
--- /dev/null
+++ b/app/admin/view/xhprof/watch/add.html
@@ -0,0 +1,48 @@
+
diff --git a/app/admin/view/xhprof/watch/add.js b/app/admin/view/xhprof/watch/add.js
new file mode 100644
index 0000000..75497d6
--- /dev/null
+++ b/app/admin/view/xhprof/watch/add.js
@@ -0,0 +1,40 @@
+$(function(){
+ // PCRE 正则前端预校验:必须带定界符(权威校验在后端 @preg_match)
+ var form = window.UA_CORE.form;
+ if (form) {
+ form.verify({
+ watchRegex: function (value) {
+ value = $.trim(value);
+ if (value === '') {
+ return '正则不能为空';
+ }
+ var delim = value.charAt(0);
+ if (/[a-zA-Z0-9\s\\]/.test(delim)) {
+ return '必须带 PCRE 定界符,如 ~think\\db\\.*~';
+ }
+ var body = value.substring(1);
+ var closeIdx = -1;
+ for (var i = 0; i < body.length; i++) {
+ var c = body.charAt(i);
+ if (c === '\\') {
+ i++;
+ continue;
+ }
+ if (c === delim) {
+ closeIdx = i;
+ break;
+ }
+ }
+ if (closeIdx === -1) {
+ return '找不到闭合定界符 ' + delim;
+ }
+ var modifiers = body.substring(closeIdx + 1);
+ if (!/^[imsxADSUXJu]*$/.test(modifiers)) {
+ return '非法修饰符:' + modifiers;
+ }
+ },
+ });
+ }
+
+ ua.listen();
+})
diff --git a/app/admin/view/xhprof/watch/edit.html b/app/admin/view/xhprof/watch/edit.html
new file mode 100644
index 0000000..3538728
--- /dev/null
+++ b/app/admin/view/xhprof/watch/edit.html
@@ -0,0 +1,48 @@
+
diff --git a/app/admin/view/xhprof/watch/edit.js b/app/admin/view/xhprof/watch/edit.js
new file mode 100644
index 0000000..75497d6
--- /dev/null
+++ b/app/admin/view/xhprof/watch/edit.js
@@ -0,0 +1,40 @@
+$(function(){
+ // PCRE 正则前端预校验:必须带定界符(权威校验在后端 @preg_match)
+ var form = window.UA_CORE.form;
+ if (form) {
+ form.verify({
+ watchRegex: function (value) {
+ value = $.trim(value);
+ if (value === '') {
+ return '正则不能为空';
+ }
+ var delim = value.charAt(0);
+ if (/[a-zA-Z0-9\s\\]/.test(delim)) {
+ return '必须带 PCRE 定界符,如 ~think\\db\\.*~';
+ }
+ var body = value.substring(1);
+ var closeIdx = -1;
+ for (var i = 0; i < body.length; i++) {
+ var c = body.charAt(i);
+ if (c === '\\') {
+ i++;
+ continue;
+ }
+ if (c === delim) {
+ closeIdx = i;
+ break;
+ }
+ }
+ if (closeIdx === -1) {
+ return '找不到闭合定界符 ' + delim;
+ }
+ var modifiers = body.substring(closeIdx + 1);
+ if (!/^[imsxADSUXJu]*$/.test(modifiers)) {
+ return '非法修饰符:' + modifiers;
+ }
+ },
+ });
+ }
+
+ ua.listen();
+})
diff --git a/app/admin/view/xhprof/watch/index.html b/app/admin/view/xhprof/watch/index.html
new file mode 100644
index 0000000..cba547b
--- /dev/null
+++ b/app/admin/view/xhprof/watch/index.html
@@ -0,0 +1,12 @@
+
diff --git a/app/admin/view/xhprof/watch/index.js b/app/admin/view/xhprof/watch/index.js
new file mode 100644
index 0000000..1cc281f
--- /dev/null
+++ b/app/admin/view/xhprof/watch/index.js
@@ -0,0 +1,38 @@
+$(function(){
+ ua.table.render({
+ init: init,
+ toolbar: ['add', 'delete'],
+ cols: [[
+ {type: 'checkbox'},
+ {field: 'id', title: 'ID', width: 80, sort: true},
+ {field: 'name', title: '规则名称', minWidth: 140},
+ {field: 'regex', title: '正则(匹配被调用函数名)', minWidth: 260, templet: function(d) {
+ var esc = String(d.regex == null ? '' : d.regex).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ return '' + esc + '';
+ }},
+ {field: 'note', title: '备注', minWidth: 140},
+ {field: 'threshold_ms', title: '阈值(ms)', width: 100, search: 'number_limit'},
+ {field: 'status', title: '状态', width: 100, search: 'select', selectList: ua.getDataBrage('select_list_status'), templet: ua.table.switch},
+ {field: 'create_time', title: '创建时间', width: 170, search: false, templet: ua.table.date},
+ {
+ width: 200, title: '操作', templet: ua.table.tool, fixed: 'right', operat: [
+ [{
+ class: 'layui-btn layui-btn-primary layui-btn-xs',
+ method: 'tab',
+ field: 'id',
+ text: '详情',
+ title: '查看详情',
+ auth: 'read',
+ url: init.readUrl,
+ icon: ''
+ }],
+ 'edit',
+ 'delete'
+ ]
+ },
+
+ ]],
+ });
+
+ ua.listen();
+})
diff --git a/app/admin/view/xhprof/watch/read.html b/app/admin/view/xhprof/watch/read.html
new file mode 100644
index 0000000..42d4f76
--- /dev/null
+++ b/app/admin/view/xhprof/watch/read.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
规则名称(人读)
+
+ {notempty name="row.name"}
+ {$row.name}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
PCRE 正则(对被调用函数名匹配)
+
+ {notempty name="row.regex"}
+ {$row.regex}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
备注
+
+ {notempty name="row.note"}
+ {$row.note}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
比对阈值(毫秒),0=不比对
+
+ {notempty name="row.threshold_ms"}
+ {$row.threshold_ms}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
+
+
+
+
基础信息
+
+
+
+
状态:1=启用,0=禁用
+
+ {$select_list_status[$row.status]|default=''}
+
+
+
+
创建时间戳
+
+ {notempty name="row.create_time"}
+ {$row.create_time|date="Y-m-d H:i:s"}
+ {else/}
+ 暂无数据
+ {/notempty}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/admin/view/xhprof/watch/read.js b/app/admin/view/xhprof/watch/read.js
new file mode 100644
index 0000000..f5671a7
--- /dev/null
+++ b/app/admin/view/xhprof/watch/read.js
@@ -0,0 +1,22 @@
+$(function(){
+ // 删除数据
+ window.deleteData = function(id) {
+ layer.confirm('确定要删除这条数据吗?', {
+ icon: 3,
+ title: '提示'
+ }, function(index) {
+ $.post('{{:url("delete")}}', {id: id}, function(res) {
+ if (res.code == 0) {
+ layer.msg('删除成功', {icon: 1}, function() {
+ location.href = '{{:url("index")}}';
+ });
+ } else {
+ layer.msg(res.msg, {icon: 2});
+ }
+ }, 'json');
+ layer.close(index);
+ });
+ };
+
+ ua.listen();
+})
\ No newline at end of file
diff --git a/database/migrations/20260814110001_xhprof_watch_seed.php b/database/migrations/20260814110001_xhprof_watch_seed.php
new file mode 100644
index 0000000..84f2a86
--- /dev/null
+++ b/database/migrations/20260814110001_xhprof_watch_seed.php
@@ -0,0 +1,81 @@
+ 'SQL 执行层',
+ // PHP 值:~think\\db\\PDOConnection::.*~(每个分隔位置两个反斜杠字符)
+ 'regex' => '~think\\\\db\\\\PDOConnection::.*~',
+ 'note' => 'PDO 连接层查询执行(真实形态实测)',
+ 'threshold_ms' => 500,
+ ],
+ [
+ 'name' => '业务层控制器',
+ 'regex' => '~app\\\\admin\\\\controller\\\\.*~',
+ 'note' => '控制器中心主义,业务逻辑所在',
+ 'threshold_ms' => 1000,
+ ],
+ [
+ 'name' => '数据保存操作',
+ 'regex' => '~.*::save$~',
+ 'note' => 'Model/log/session 的 save 调用',
+ 'threshold_ms' => 0,
+ ],
+ ];
+
+ foreach ($rows as $row) {
+ $sql = sprintf(
+ "INSERT INTO `%sxhprof_watch` (`name`, `regex`, `note`, `threshold_ms`, `status`, `create_time`, `update_time`) VALUES (%s, %s, %s, %d, 1, %d, %d) ON DUPLICATE KEY UPDATE `id` = `id`",
+ $this->getAdapter()->getOption('table_prefix'),
+ $this->quoteLiteral($row['name']),
+ $this->quoteLiteral($row['regex']),
+ $this->quoteLiteral($row['note']),
+ $row['threshold_ms'],
+ $now,
+ $now
+ );
+ $this->execute($sql);
+ }
+ }
+
+ public function down()
+ {
+ $prefix = $this->getAdapter()->getOption('table_prefix');
+ $regexes = implode(', ', array_map(function ($regex) {
+ return $this->quoteLiteral($regex);
+ }, [
+ '~think\\\\db\\\\PDOConnection::.*~',
+ '~app\\\\admin\\\\controller\\\\.*~',
+ '~.*::save$~',
+ ]));
+ $this->execute("DELETE FROM `{$prefix}xhprof_watch` WHERE `regex` IN ({$regexes})");
+ }
+
+ /**
+ * MySQL 字面量包装:单引号包裹 + 反斜杠转义(保证落库值与 PHP 值逐字符一致).
+ */
+ private function quoteLiteral(string $value): string
+ {
+ return "'" . addcslashes($value, "\\") . "'";
+ }
+}