feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -17,6 +17,7 @@ use think\exception\HttpException;
|
||||
use think\facade\Config;
|
||||
use think\facade\Request;
|
||||
use think\facade\View;
|
||||
use think\facade\Cache;
|
||||
use ywxapp\exception\AddonException;
|
||||
use ywxapp\service\AddonService;
|
||||
use ywxapp\service\AddonDevService;
|
||||
@@ -45,6 +46,8 @@ class Addons extends BackendBase
|
||||
if ($this->request->isAjax()) {
|
||||
$results = scandir(ADDON_PATH);
|
||||
$list = [];
|
||||
// 市场最新版本:用于列表内「可升级」提醒(市场不可达时静默降级为空)
|
||||
$latestMap = $this->latestVersions();
|
||||
foreach ($results as $name) {
|
||||
if ($name === '.' or $name === '..' or is_file(ADDON_PATH . $name)) {
|
||||
continue;
|
||||
@@ -65,8 +68,12 @@ class Addons extends BackendBase
|
||||
$info['id'] = $info['name'];
|
||||
$info['description'] = $info['intro'] ?? '';
|
||||
$info['status'] = $info['state'] ?? 0;
|
||||
$info['hasConfig'] = is_file($addonDir . 'config.php');
|
||||
$list[] = $info;
|
||||
$info['hasConfig'] = is_file($addonDir . 'config.php');
|
||||
$latest = (string) ($latestMap[$info['name']] ?? '');
|
||||
$info['latest_version'] = $latest;
|
||||
$info['upgradable'] = $latest !== ''
|
||||
&& version_compare($latest, (string) ($info['version'] ?? '0'), '>');
|
||||
$list[] = $info;
|
||||
}
|
||||
|
||||
// 关键字搜索
|
||||
@@ -96,6 +103,46 @@ class Addons extends BackendBase
|
||||
return View::fetch('addon/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场最新版本号映射(插件标识 => 版本号),5 分钟缓存
|
||||
*
|
||||
* 用于已装列表的「可升级」提醒。市场不可达 / 未配置 / 未安装 appmall 时返回空数组,
|
||||
* 列表退化为不显示升级标记,不影响插件管理主流程。
|
||||
*/
|
||||
private function latestVersions(): array
|
||||
{
|
||||
try {
|
||||
return Cache::remember('addon_market_latest_versions', function () {
|
||||
$list = [];
|
||||
try {
|
||||
if (is_market_client()) {
|
||||
$resp = (new RemoteService())->lists([]);
|
||||
$list = $resp['data']['list'] ?? [];
|
||||
} elseif (class_exists(\app\appmall\service\MarketService::class)) {
|
||||
$list = \app\appmall\service\MarketService::instance()
|
||||
->catalog('', '', 'new', 'addon')['list'] ?? [];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
foreach ((array) $list as $it) {
|
||||
if (($it['type'] ?? 'addon') !== 'addon') {
|
||||
continue;
|
||||
}
|
||||
$n = (string) ($it['name'] ?? '');
|
||||
$v = (string) ($it['version'] ?? '');
|
||||
if ($n !== '' && $v !== '') {
|
||||
$map[$n] = $v;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}, 300);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用市场(Discuz 式远程插件市场,核心后台「插件管理」)
|
||||
*
|
||||
@@ -145,13 +192,13 @@ class Addons extends BackendBase
|
||||
} else {
|
||||
$error = '未配置市场地址(请在后台配置 ywxapp.api_url)';
|
||||
}
|
||||
} elseif (!class_exists(\addon\appmall\service\MarketService::class)) {
|
||||
} elseif (!class_exists(\app\appmall\service\MarketService::class)) {
|
||||
// 本机即中心站但未安装 appmall 插件:市场目录不可用
|
||||
$error = '本机未安装 appmall 插件:请安装插件以启用市场目录,或配置 ywxapp.api_url 指向中心站';
|
||||
} else {
|
||||
// 服务中心(本机):委托 appmall 插件读取本地市场目录(中心域逻辑全部在插件侧)
|
||||
try {
|
||||
$r = \addon\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
|
||||
$r = \app\appmall\service\MarketService::instance()->catalog($keyword, $category, $order, $type);
|
||||
$list = $r['list'];
|
||||
$categories = $r['categories'];
|
||||
if (empty($list)) {
|
||||
@@ -275,14 +322,14 @@ class Addons extends BackendBase
|
||||
if (is_market_client()) {
|
||||
return $this->result->error('远程模式订单存于中心站,请于会员中心申请退款');
|
||||
}
|
||||
if (!class_exists(\addon\appmall\service\MarketService::class)) {
|
||||
if (!class_exists(\app\appmall\service\MarketService::class)) {
|
||||
return $this->result->error('本机未安装 appmall 插件,无法执行退款');
|
||||
}
|
||||
try {
|
||||
\addon\appmall\service\MarketService::instance()
|
||||
\app\appmall\service\MarketService::instance()
|
||||
->operatorRefund((string) $request->post('name', ''));
|
||||
} catch (\Throwable $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
return $this->result->success([], '退款成功,授权已吊销');
|
||||
}
|
||||
@@ -303,7 +350,7 @@ class Addons extends BackendBase
|
||||
}
|
||||
$token = config('ywxapp.developer_token', '');
|
||||
if (!$token) {
|
||||
return $this->result->error('未配置开发者令牌:请在 .env 设置 DEVELOPER_TOKEN');
|
||||
return $this->result->error('未配置开发者令牌:请在 .env 设置 APPMARKET_DEV_TOKEN');
|
||||
}
|
||||
$AddonService = AddonService::instance($name);
|
||||
if (!$AddonService->isInstalled()) {
|
||||
@@ -354,9 +401,9 @@ class Addons extends BackendBase
|
||||
}
|
||||
return $this->result->error('提交到官方市场失败:' . $msg);
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +422,7 @@ class Addons extends BackendBase
|
||||
$zipFile = $AddonService->package();
|
||||
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +447,11 @@ class Addons extends BackendBase
|
||||
];
|
||||
$info = AddonService::instance()->local($file, $extend);
|
||||
} catch (AddonException $e) {
|
||||
$this->result->error(LANG($e->getMessage(), $e->getCode()));
|
||||
$this->result->error(
|
||||
LANG($e->getMessage(), $e->getCode()),
|
||||
$e->getCode() ?: 1,
|
||||
$e->getData() ?: null
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->result->error(lang($e->getMessage()));
|
||||
}
|
||||
@@ -426,14 +477,20 @@ class Addons extends BackendBase
|
||||
$extend = [
|
||||
'install_user' => session('user_id'),
|
||||
'install_ip' => Request::ip(),
|
||||
'install_time' => time()
|
||||
'install_time' => time(),
|
||||
// 中心站 Market::valid() 读的是 **uid**(不是 install_user):
|
||||
// 离线上传付费插件时若不带 uid,服务端收到 uid=0 会直接判
|
||||
// 「该插件为付费插件,请先购买」,导致后台永远装不上自己买的插件。
|
||||
// 这里的 uid 指「市场会员 ID」;后台管理员 ID 只在「本机即中心站、
|
||||
// 且管理员与市场会员同一套账号」时等价,跨机器场景请走会员登录态。
|
||||
'uid' => (int) (session('user_id') ?? 0),
|
||||
];
|
||||
$info = $AddonService->local($file, $extend);
|
||||
return $this->result->success($info, '插件安装成功');
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -447,15 +504,21 @@ class Addons extends BackendBase
|
||||
if (!$addonName || !preg_match('/^[a-zA-Z0-9_]+$/', $addonName)) {
|
||||
return json(['code' => 0, 'msg' => '插件名称格式不正确']);
|
||||
}
|
||||
// 卸载选项(前端勾选,缺省沿用服务层默认:备份 + 删除)
|
||||
// backup_data=0 不备份;delete_data=0 保留插件数据表
|
||||
$options = [
|
||||
'backup_data' => (bool) input('backup_data', 1),
|
||||
'delete_data' => (bool) input('delete_data', 1),
|
||||
];
|
||||
$AddonService = AddonService::instance($addonName);
|
||||
$result = $AddonService->uninstall();
|
||||
$result = $AddonService->uninstall($options);
|
||||
if ($result) {
|
||||
$this->result->success([], '插件卸载成功');
|
||||
} else {
|
||||
$this->result->error('插件卸载失败');
|
||||
}
|
||||
} catch (AddonException $e) {
|
||||
$this->result->error('插件卸载失败:' . $e->getMessage());
|
||||
$this->result->error('插件卸载失败:' . $e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
$this->result->error('插件卸载失败:' . $e->getMessage());
|
||||
}
|
||||
@@ -499,7 +562,7 @@ class Addons extends BackendBase
|
||||
$zipFile = $AddonService->package();
|
||||
return download($zipFile, $addonName . '-' . $AddonService->getVersion() . '.zip');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,9 +597,9 @@ class Addons extends BackendBase
|
||||
$info = $AddonService->local($file, $extend);
|
||||
return $this->result->success($info, '下载并安装成功');
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,9 +625,9 @@ class Addons extends BackendBase
|
||||
'升级成功:' . ($res['from'] ?? '') . ' → ' . ($res['to'] ?? '')
|
||||
);
|
||||
} catch (AddonException $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,10 +648,28 @@ class Addons extends BackendBase
|
||||
$post = input('post.');
|
||||
$data = [];
|
||||
foreach ($fields as $f) {
|
||||
$n = $f['name'] ?? '';
|
||||
if ($n !== '' && array_key_exists($n, $post)) {
|
||||
$data[$n] = $post[$n];
|
||||
$n = $f['name'] ?? '';
|
||||
$type = $f['type'] ?? 'text';
|
||||
if ($n === '') {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($n, $post)) {
|
||||
$v = $post[$n];
|
||||
} else {
|
||||
// 未提交:开关视为关闭、多选视为空数组;其余字段不覆盖已有值
|
||||
if ($type === 'switch') {
|
||||
$v = 0;
|
||||
} elseif ($type === 'checkbox') {
|
||||
$v = [];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// array 型:textarea 的「键=值」多行文本转数组存储
|
||||
if ($type === 'array' && is_string($v)) {
|
||||
$v = self::parseKvText($v);
|
||||
}
|
||||
$data[$n] = $v;
|
||||
}
|
||||
AddonService::config($addon, $data);
|
||||
AddonService::clearConfigCache($addon);
|
||||
@@ -596,8 +677,30 @@ class Addons extends BackendBase
|
||||
}
|
||||
$saved = AddonService::config($addon);
|
||||
foreach ($fields as &$f) {
|
||||
if (isset($saved[$f['name']])) {
|
||||
$f['value'] = $saved[$f['name']];
|
||||
$n = $f['name'] ?? '';
|
||||
$type = $f['type'] ?? 'text';
|
||||
if ($n !== '' && isset($saved[$n])) {
|
||||
$val = $saved[$n];
|
||||
// array 型:数组还原成「键=值」文本供 textarea 编辑
|
||||
if ($type === 'array' && is_array($val)) {
|
||||
$val = implode(PHP_EOL, array_map(
|
||||
function ($k, $v) {
|
||||
return $k . '=' . (is_scalar($v) ? (string) $v : json_encode($v, JSON_UNESCAPED_UNICODE));
|
||||
},
|
||||
array_keys($val),
|
||||
$val
|
||||
));
|
||||
}
|
||||
$f['value'] = $val;
|
||||
}
|
||||
// 缺省值兜底:无已存值时用 config.php 的 default,避免模板取到未定义变量
|
||||
if (! isset($f['value'])) {
|
||||
$f['value'] = $f['default'] ?? ($type === 'checkbox' ? [] : ($type === 'switch' ? 0 : ''));
|
||||
}
|
||||
// options 容错:正则化写法。select/radio/checkbox 的 options 既可以是数组,
|
||||
// 也可以写字符串('0:否,1:是' 或 '北京,上海'),省得插件作者写一堆嵌套数组
|
||||
if (isset($f['options'])) {
|
||||
$f['options'] = self::normalizeOptions($f['options']);
|
||||
}
|
||||
}
|
||||
unset($f);
|
||||
@@ -605,6 +708,61 @@ class Addons extends BackendBase
|
||||
return View::fetch('addon/setting');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「键=值」多行文本为数组(array 型插件配置用)
|
||||
*
|
||||
* 每行一条:`key=value` 写入关联键,无等号的整行作为列表项。
|
||||
*/
|
||||
private static function parseKvText(string $text): array
|
||||
{
|
||||
$arr = [];
|
||||
foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (strpos($line, '=') !== false) {
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$arr[trim($k)] = trim($v);
|
||||
} else {
|
||||
$arr[] = $line;
|
||||
}
|
||||
}
|
||||
return $arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 select/radio/checkbox 的 options
|
||||
*
|
||||
* 支持三种写法(最终统一成 值=>标题 数组):
|
||||
* [1 => '开启', 0 => '关闭'] 数组(原样返回)
|
||||
* '0:关闭,1:开启' 键值对字符串
|
||||
* '北京,上海' 纯列表(值与标题相同)
|
||||
*/
|
||||
private static function normalizeOptions($options): array
|
||||
{
|
||||
if (is_array($options)) {
|
||||
return $options;
|
||||
}
|
||||
if (! is_string($options) || trim($options) === '') {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (explode(',', $options) as $item) {
|
||||
$item = trim($item);
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($item, ':')) {
|
||||
[$k, $v] = explode(':', $item, 2);
|
||||
$out[trim($k)] = trim($v);
|
||||
} else {
|
||||
$out[$item] = $item;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ==================== 开发模式:插件设计器(抄 Discuz!) ====================
|
||||
|
||||
/**
|
||||
@@ -645,7 +803,7 @@ class Addons extends BackendBase
|
||||
$info = AddonDevService::instance($name)->createSkeleton($meta);
|
||||
return $this->result->success($info, '插件骨架创建成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,7 +823,7 @@ class Addons extends BackendBase
|
||||
$data = AddonDevService::instance($name)->readAll();
|
||||
return $this->result->success($data);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,7 +869,7 @@ class Addons extends BackendBase
|
||||
}
|
||||
return $this->result->success([], '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +892,7 @@ class Addons extends BackendBase
|
||||
$rel = ltrim(str_replace(ADDON_PATH . $name . DIRECTORY_SEPARATOR, '', $file), DIRECTORY_SEPARATOR);
|
||||
return $this->result->success(['file' => $rel], '生成成功:' . $rel);
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,7 +912,7 @@ class Addons extends BackendBase
|
||||
$info = AddonDevService::instance($name)->developInstall();
|
||||
return $this->result->success($info, '开发模式安装成功,菜单已注入并启用');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +932,7 @@ class Addons extends BackendBase
|
||||
AddonDevService::instance($name)->remove();
|
||||
return $this->result->success([], '插件目录已删除');
|
||||
} catch (\Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
return $this->result->error($e->getMessage(), $e->getCode() ?: 1, $e->getData() ?: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\AdminLogger;
|
||||
|
||||
/**
|
||||
* 管理员操作日志
|
||||
*
|
||||
* 日志来源:
|
||||
* - 后台所有写操作:BackendBase 的 create / edit / del / destroy / restore / multi 成功后自动留痕;
|
||||
* - 登录与退出:BackendBase::login() / logout();
|
||||
* - 业务代码可直接 AdminLogger::write('动作', '摘要') 补充关键操作。
|
||||
*
|
||||
* 权限节点(首次访问自动补录):adminlog:index / adminlog:delete / adminlog:clean
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Adminlog extends BackendBase
|
||||
{
|
||||
use EnsuresPower;
|
||||
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
/**
|
||||
* 进程内只补录一次权限
|
||||
* @var bool
|
||||
*/
|
||||
protected static $powerChecked = false;
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
if (!self::$powerChecked) {
|
||||
self::$powerChecked = true;
|
||||
$this->ensureMenus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
$kw = trim((string) $this->request->param('keyword', ''));
|
||||
$action = trim((string) $this->request->param('action', ''));
|
||||
$start = trim((string) $this->request->param('start', ''));
|
||||
$end = trim((string) $this->request->param('end', ''));
|
||||
|
||||
$query = Db::name('backend_log')->alias('l')
|
||||
->leftJoin('backend_admin a', 'a.id = l.aid')
|
||||
->field('l.*, a.account, a.nickname');
|
||||
|
||||
if ($kw !== '') {
|
||||
// 注意:本 ORM 版本的 whereLike 对带表别名字段会抛
|
||||
// 「where express error」,统一用 where(field, 'like', ...)
|
||||
$query->where(function ($q) use ($kw) {
|
||||
$q->where('l.remark', 'like', '%' . $kw . '%')
|
||||
->whereOr('l.ip', 'like', '%' . $kw . '%')
|
||||
->whereOr('a.account', 'like', '%' . $kw . '%')
|
||||
->whereOr('a.nickname', 'like', '%' . $kw . '%');
|
||||
});
|
||||
}
|
||||
if ($action !== '') {
|
||||
$query->where('l.action', 'like', '%' . $action . '%');
|
||||
}
|
||||
if ($start !== '') {
|
||||
$query->where('l.create_at', '>=', strtotime($start . ' 00:00:00'));
|
||||
}
|
||||
if ($end !== '') {
|
||||
$query->where('l.create_at', '<=', strtotime($end . ' 23:59:59'));
|
||||
}
|
||||
|
||||
$total = (int) $query->count();
|
||||
$list = $query->order('l.id', 'desc')
|
||||
->page($page, $limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$row['create_at_text'] = $row['create_at'] ? date('Y-m-d H:i:s', (int) $row['create_at']) : '-';
|
||||
$row['operator'] = $row['account'] ?: ($row['aid'] ? ('#' . $row['aid']) : '系统');
|
||||
}
|
||||
$this->result->setCount($total)->success($list);
|
||||
}
|
||||
|
||||
// 动作类型下拉:取表里出现过的动作,便于筛选
|
||||
$actions = Db::name('backend_log')
|
||||
->field('action, COUNT(*) AS total')
|
||||
->group('action')
|
||||
->order('total', 'desc')
|
||||
->limit(50)
|
||||
->select()
|
||||
->toArray();
|
||||
$this->assign('actions', $actions);
|
||||
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除(支持逗号分隔批量)
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$ids = $this->request->param('ids', '');
|
||||
$ids = is_array($ids) ? $ids : array_filter(explode(',', (string) $ids));
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的日志');
|
||||
}
|
||||
$count = Db::name('backend_log')->whereIn('id', $ids)->delete();
|
||||
AdminLogger::write('adminlog/delete', '删除日志 ' . $count . ' 条');
|
||||
$this->result->success(['count' => $count], '已删除 ' . $count . ' 条');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理:按保留天数删除,days=0 表示清空
|
||||
*/
|
||||
public function clean()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$days = $this->request->param('days/d', 30);
|
||||
if ($days < 0 || $days > 3650) {
|
||||
$this->result->error('保留天数应在 0 ~ 3650 之间(0=清空)');
|
||||
}
|
||||
$count = AdminLogger::clean($days);
|
||||
AdminLogger::write('adminlog/clean', ($days > 0 ? ('清理 ' . $days . ' 天前日志') : '清空日志') . ',共 ' . $count . ' 条');
|
||||
$this->result->success(['count' => $count], $days > 0
|
||||
? ('已清理 ' . $count . ' 条(保留最近 ' . $days . ' 天)')
|
||||
: ('已清空 ' . $count . ' 条'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\AdminLogger;
|
||||
use ywxapp\model\CommonAttachment;
|
||||
use ywxapp\service\FileStorageService;
|
||||
|
||||
/**
|
||||
* 附件统一管理
|
||||
*
|
||||
* API 端(/api/v1/common/upload 等)负责收文件,后台这里做全局视角:
|
||||
* 全站附件一览(不分模块/不分上传者)、按条件检索、预览、删除(物理文件 + 软删记录)、容量统计。
|
||||
* 删除始终走 FileStorageService::delete(),OSS / FTP 等自定义存储由 FileDelete 事件接管,
|
||||
* 后台代码无需关心文件到底存在哪。
|
||||
*
|
||||
* 权限节点(首次访问自动补录):attachment:index / attachment:delete
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Attachment extends BackendBase
|
||||
{
|
||||
use EnsuresPower;
|
||||
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
/**
|
||||
* 进程内只补录一次权限
|
||||
* @var bool
|
||||
*/
|
||||
protected static $powerChecked = false;
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
if (!self::$powerChecked) {
|
||||
self::$powerChecked = true;
|
||||
$this->ensureMenus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表 + 统计
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
$kw = trim((string) $this->request->param('keyword', ''));
|
||||
$module = trim((string) $this->request->param('module', ''));
|
||||
$onlyImg = (int) $this->request->param('only_image/d', 0);
|
||||
|
||||
$query = CommonAttachment::where('delete_at', 0);
|
||||
if ($kw !== '') {
|
||||
$query->where(function ($q) use ($kw) {
|
||||
$q->where('original', 'like', '%' . $kw . '%')
|
||||
->whereOr('name', 'like', '%' . $kw . '%')
|
||||
->whereOr('path', 'like', '%' . $kw . '%');
|
||||
});
|
||||
}
|
||||
if ($module !== '') {
|
||||
$query->where('module', $module);
|
||||
}
|
||||
if ($onlyImg === 1) {
|
||||
$query->where('is_image', 1);
|
||||
}
|
||||
|
||||
$total = (int) $query->count();
|
||||
$list = $query->order('id', 'desc')->page($page, $limit)->select()->toArray();
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$row['size_text'] = $this->formatBytes((int) $row['size']);
|
||||
$row['create_at_text'] = $row['create_at'] ? date('Y-m-d H:i:s', (int) $row['create_at']) : '-';
|
||||
}
|
||||
$this->result->setCount($total)->success($list);
|
||||
}
|
||||
|
||||
// 概览统计与模块清单(供页面筛选下拉)
|
||||
$stat = [
|
||||
'total' => (int) CommonAttachment::where('delete_at', 0)->count(),
|
||||
'size' => (int) CommonAttachment::where('delete_at', 0)->sum('size'),
|
||||
'image' => (int) CommonAttachment::where('delete_at', 0)->where('is_image', 1)->count(),
|
||||
];
|
||||
$stat['size_text'] = $this->formatBytes($stat['size']);
|
||||
$modules = Db::name('common_attachment')
|
||||
->where('delete_at', 0)
|
||||
->field('module, COUNT(*) AS total')
|
||||
->group('module')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$this->assign('stat', $stat);
|
||||
$this->assign('modules', $modules);
|
||||
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除:物理删文件(可由事件接管)+ 软删记录
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$ids = $this->request->param('ids', '');
|
||||
$ids = is_array($ids) ? $ids : array_filter(explode(',', (string) $ids));
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的附件');
|
||||
}
|
||||
|
||||
$storage = new FileStorageService();
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($ids as $id) {
|
||||
$row = CommonAttachment::find((int) $id);
|
||||
if (! $row) {
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
// 文件删不掉也要删记录,避免磁盘残留变成无法再管的孤儿
|
||||
$storage->delete((string) $row->path);
|
||||
$row->delete();
|
||||
$ok++;
|
||||
}
|
||||
|
||||
AdminLogger::write('attachment/delete', '删除附件 ' . $ok . ' 个' . ($fail ? "({$fail} 个不存在)" : ''));
|
||||
$this->result->success(['ok' => $ok, 'fail' => $fail], '已删除 ' . $ok . ' 个附件');
|
||||
}
|
||||
|
||||
protected function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
declare (strict_types = 1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use app\backend\library\SystemInfo;
|
||||
use think\Request;
|
||||
use think\facade\View;
|
||||
use think\facade\Db;
|
||||
@@ -51,10 +52,221 @@ class Console extends BackendBase
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// 控制台首页带一份系统概览(与「系统信息」页共用 SystemInfo,避免两套实现)
|
||||
if ($this->request->isAjax()) {
|
||||
$this->result->success(SystemInfo::collect());
|
||||
}
|
||||
|
||||
View::assign('title', '控制台');
|
||||
View::assign('sys', SystemInfo::collect());
|
||||
// 控制台内的「框架升级」卡片用(与 Upgrade 页同源:config('ywxapp.version'))
|
||||
View::assign('version', (string) config('ywxapp.version'));
|
||||
// 首页下半区:真实统计(替换 layuiAdmin 模板自带的演示数据)
|
||||
View::assign('stats', $this->collectStats());
|
||||
return View::fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存:cache / temp / all(不动 log)
|
||||
*
|
||||
* 由原 System::clean() 迁来(/admin/system/clean → /admin/console/clean):
|
||||
* 「系统信息」页已随 System 控制器一起下线,但首页顶部的三个清理按钮要留着,
|
||||
* 所以能力搬来这里;清理实现仍在 SystemInfo::cleanRuntime(),权限节点同步改成 console:clean。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clean()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$type = trim((string) $this->request->param('type', 'cache'));
|
||||
|
||||
try {
|
||||
$files = SystemInfo::cleanRuntime($type);
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error($e->getMessage());
|
||||
}
|
||||
|
||||
$this->result->success(['files' => $files], '已清理 ' . $files . ' 项缓存');
|
||||
}
|
||||
|
||||
|
||||
/* ====================== 首页统计(真实数据,替换演示数据) ====================== */
|
||||
|
||||
/**
|
||||
* 采集首页下半区要用的统计
|
||||
*
|
||||
* 数据源只有三张核心表:会员 member_user / 后台日志 backend_log / 蜘蛛统计 common_spider_stat。
|
||||
* 任意一张缺失或查询失败都退化为 0——首页是最容易被人访问的页面,
|
||||
* 不能因为某张插件表没建就整体 500。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function collectStats(): array
|
||||
{
|
||||
$todayStart = strtotime('today');
|
||||
$todayEnd = $todayStart + 86399;
|
||||
$weekStart = strtotime('-6 days', $todayStart);
|
||||
|
||||
// 近 7 天先铺好 0 值骨架:某天没有数据也不会把曲线挤错位
|
||||
$bucket = [];
|
||||
$dates = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$ts = strtotime("-{$i} days", $todayStart);
|
||||
$key = date('Y-m-d', $ts);
|
||||
$bucket[$key] = ['member' => 0, 'log' => 0];
|
||||
$dates[] = date('m-d', $ts);
|
||||
}
|
||||
|
||||
// 近 7 天:一次取回时间戳列,在 PHP 侧按天归桶(省掉 14 次 COUNT)
|
||||
foreach ($this->safeColumn('member_user', 'create_at', $weekStart, $todayEnd) as $ts) {
|
||||
$key = date('Y-m-d', (int) $ts);
|
||||
if (isset($bucket[$key])) {
|
||||
$bucket[$key]['member']++;
|
||||
}
|
||||
}
|
||||
foreach ($this->safeColumn('backend_log', 'create_at', $weekStart, $todayEnd) as $ts) {
|
||||
$key = date('Y-m-d', (int) $ts);
|
||||
if (isset($bucket[$key])) {
|
||||
$bucket[$key]['log']++;
|
||||
}
|
||||
}
|
||||
|
||||
$memberWeek = 0;
|
||||
foreach ($bucket as $row) {
|
||||
$memberWeek += $row['member'];
|
||||
}
|
||||
|
||||
return [
|
||||
'dates' => $dates,
|
||||
'series' => [
|
||||
'member' => array_column($bucket, 'member'),
|
||||
'log' => array_column($bucket, 'log'),
|
||||
],
|
||||
'today' => [
|
||||
'member' => SystemInfo::safeCount('member_user', function ($q) use ($todayStart, $todayEnd) {
|
||||
$q->where('create_at', '>=', $todayStart)->where('create_at', '<=', $todayEnd);
|
||||
}),
|
||||
'log' => SystemInfo::safeCount('backend_log', function ($q) use ($todayStart, $todayEnd) {
|
||||
$q->where('create_at', '>=', $todayStart)->where('create_at', '<=', $todayEnd);
|
||||
}),
|
||||
// 蜘蛛按日聚合表:今日那行的 count 就是当天抓取量
|
||||
'spider' => $this->safeSum('common_spider_stat', 'count', function ($q) use ($todayStart) {
|
||||
$q->where('stat_date', date('Y-m-d', $todayStart));
|
||||
}),
|
||||
],
|
||||
'total' => [
|
||||
'member' => SystemInfo::safeCount('member_user'),
|
||||
'member_week' => $memberWeek,
|
||||
'log' => SystemInfo::safeCount('backend_log'),
|
||||
'spider' => $this->safeSum('common_spider_stat', 'count'),
|
||||
],
|
||||
'members' => $this->latestMembers(),
|
||||
'logs' => $this->latestLogs(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新注册的会员
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function latestMembers(): array
|
||||
{
|
||||
try {
|
||||
return Db::name('member_user')
|
||||
->field('uid, account, nickname, create_at, update_at')
|
||||
->order('uid', 'desc')
|
||||
->limit(8)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近的后台操作日志(附带管理员昵称)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function latestLogs(): array
|
||||
{
|
||||
$admins = [];
|
||||
try {
|
||||
$admins = Db::name('backend_admin')->column('nickname', 'id');
|
||||
} catch (\Throwable $e) {
|
||||
$admins = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = Db::name('backend_log')
|
||||
->field('id, aid, action, ip, create_at')
|
||||
->order('id', 'desc')
|
||||
->limit(8)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$list[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'admin' => $admins[$row['aid']] ?? ('#' . $row['aid']),
|
||||
'action' => (string) $row['action'],
|
||||
'ip' => (string) $row['ip'],
|
||||
'time' => (int) $row['create_at'],
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全取单列:表缺失 / 查询异常时返回空数组
|
||||
*
|
||||
* @param string $table
|
||||
* @param string $field
|
||||
* @param int $since
|
||||
* @param int|null $until
|
||||
* @return array
|
||||
*/
|
||||
protected function safeColumn(string $table, string $field, int $since, int $until = null): array
|
||||
{
|
||||
try {
|
||||
$q = Db::name($table)->where($field, '>=', $since);
|
||||
if ($until !== null) {
|
||||
$q->where($field, '<=', $until);
|
||||
}
|
||||
return $q->column($field);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全求和:表缺失 / 查询异常时返回 0
|
||||
*
|
||||
* @param string $table
|
||||
* @param string $field
|
||||
* @param callable|null $where
|
||||
* @return int
|
||||
*/
|
||||
protected function safeSum(string $table, string $field, callable $where = null): int
|
||||
{
|
||||
try {
|
||||
$q = Db::name($table);
|
||||
if ($where) {
|
||||
$where($q);
|
||||
}
|
||||
return (int) $q->sum($field);
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门统计(真实数据)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-09-28 00:00:00
|
||||
* @Description: 数据库备份 / 还原 / 优化
|
||||
* @FilePath: \ywxapp_dev\app\backend\controller\Database.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\DbBackup;
|
||||
|
||||
/**
|
||||
* 数据库管理
|
||||
*
|
||||
* 权限节点(首次访问自动补录到 backend_power,挂到「系统管理」下):
|
||||
* database:index / database:backup / database:recovery / database:delete / database:optimize
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Database extends BackendBase
|
||||
{
|
||||
use EnsuresPower; // 权限节点自愈:首次访问自动补录 backend_power
|
||||
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
/**
|
||||
* @var DbBackup
|
||||
*/
|
||||
protected $bak;
|
||||
|
||||
/**
|
||||
* 进程内只补录一次权限
|
||||
* @var bool
|
||||
*/
|
||||
protected static $powerChecked = false;
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->bak = new DbBackup();
|
||||
if (!self::$powerChecked) {
|
||||
self::$powerChecked = true;
|
||||
$this->ensureMenus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表:数据表 + 备份记录
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$type = $this->request->param('type', 'table');
|
||||
|
||||
if ($type === 'backup') {
|
||||
$list = $this->bak->backups();
|
||||
$this->result->setCount(count($list))->success(array_map(function ($item) {
|
||||
$item['size_text'] = $this->formatBytes($item['size']);
|
||||
$item['time_text'] = date('Y-m-d H:i:s', $item['time']);
|
||||
return $item;
|
||||
}, $list));
|
||||
}
|
||||
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$kw = trim((string) $this->request->param('keyword', ''));
|
||||
|
||||
$tables = $this->bak->tables();
|
||||
if ($kw !== '') {
|
||||
$tables = array_values(array_filter($tables, function ($t) use ($kw) {
|
||||
return stripos($t['name'], $kw) !== false || stripos($t['comment'], $kw) !== false;
|
||||
}));
|
||||
}
|
||||
$total = count($tables);
|
||||
$slice = array_slice($tables, ($page - 1) * $limit, $limit);
|
||||
foreach ($slice as &$t) {
|
||||
$t['size_text'] = $this->formatBytes($t['size']);
|
||||
}
|
||||
$this->result->setCount($total)->success($slice);
|
||||
}
|
||||
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份
|
||||
*/
|
||||
public function backup()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$tables = $this->request->param('tables/a', []);
|
||||
$size = $this->request->param('part_size/d', 2097152);
|
||||
$size = max(65536, min($size, 52428800)); // 64KB ~ 50MB
|
||||
|
||||
set_time_limit(0);
|
||||
try {
|
||||
$res = $this->bak->backup(array_values(array_filter((array) $tables)), null);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('数据库备份失败:' . $e->getMessage());
|
||||
$this->result->error('备份失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$res['size_text'] = $this->formatBytes($res['size']);
|
||||
$this->result->success($res, '备份完成:' . $res['tables'] . ' 张表 / ' . $res['rows'] . ' 行');
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原
|
||||
*
|
||||
* 注意:基类 BackendBase 已有 restore($ids='')(回收站恢复),此处不能用同名,
|
||||
* 否则签名不兼容会直接 500,故命名为 recovery。
|
||||
*/
|
||||
public function recovery()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$name = (string) $this->request->param('name', '');
|
||||
if ($name === '') {
|
||||
$this->result->error('请选择备份');
|
||||
}
|
||||
|
||||
set_time_limit(0);
|
||||
try {
|
||||
$res = $this->bak->restore($name);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('数据库还原失败:' . $e->getMessage());
|
||||
$this->result->error('还原失败:' . $e->getMessage());
|
||||
}
|
||||
$this->result->success($res, '还原完成:' . $res['files'] . ' 个文件 / ' . $res['sql'] . ' 条语句');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除备份
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$name = (string) $this->request->param('name', '');
|
||||
if ($name === '') {
|
||||
$this->result->error('请选择备份');
|
||||
}
|
||||
$this->result->success(['ok' => $this->bak->delete($name)], '已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载备份文件
|
||||
*/
|
||||
public function download()
|
||||
{
|
||||
$name = (string) $this->request->param('name', '');
|
||||
$file = (string) $this->request->param('file', '');
|
||||
$path = $this->bak->fileOf($name, $file);
|
||||
if (! $path || ! is_file($path)) {
|
||||
$this->result->error('备份文件不存在', 404);
|
||||
}
|
||||
|
||||
return response(file_get_contents($path), 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . basename($path) . '"',
|
||||
'Content-Length' => filesize($path),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化表
|
||||
*/
|
||||
public function optimize()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
|
||||
if (! $tables) {
|
||||
$this->result->error('请选择要优化的表');
|
||||
}
|
||||
$res = $this->bak->optimize($tables);
|
||||
$this->result->success($res, '已优化 ' . count($res) . ' 张表');
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限节点补录:核心后台菜单即权限记录,缺失时自动补上,
|
||||
* 避免手工在「权限菜单」里一条条加。
|
||||
*/
|
||||
protected function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\AdminLogger;
|
||||
|
||||
/**
|
||||
* 数据表管理(可视化建表 / 查看结构 / 优化 / 删表)
|
||||
*
|
||||
* 与「数据库备份」分开成两个控制器:那边是数据层面的备份还原,
|
||||
* 这边是结构层面的 DDL。
|
||||
*
|
||||
* 安全约束(DDL 不可逆,这里比 CRUD 更保守):
|
||||
* 1. 表名 / 字段名 / 类型 / 引擎 / 字符集全部走白名单,用户输入的字符串
|
||||
* 一个字符都不会拼进 SQL(注释与默认值只做转义,不参与语法结构);
|
||||
* 2. 建表只允许「新建不存在的表」,已存在直接拒绝,绝不覆盖(不做 DROP+CREATE);
|
||||
* 3. 删表:核心表(backend_admin / backend_power 等)列入保护名单,
|
||||
* 且要求前端回传表全名做二次确认;
|
||||
* 4. 所有写操作进 AdminLogger 留痕。
|
||||
*
|
||||
* 权限节点(首次访问自动补录):datatable:index / datatable:create /
|
||||
* datatable:drop / datatable:optimize / datatable:structure
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Datatable extends BackendBase
|
||||
{
|
||||
use EnsuresPower;
|
||||
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
/**
|
||||
* 进程内只补录一次权限
|
||||
* @var bool
|
||||
*/
|
||||
protected static $powerChecked = false;
|
||||
|
||||
/**
|
||||
* install.sql 索引缓存:[裸表名 => install.sql 相对路径]
|
||||
* @var array|null
|
||||
*/
|
||||
protected static $installMap = null;
|
||||
|
||||
/**
|
||||
* 允许的数据类型(白名单)
|
||||
* @var array
|
||||
*/
|
||||
protected const TYPES = [
|
||||
'tinyint', 'smallint', 'mediumint', 'int', 'bigint',
|
||||
'decimal', 'float', 'double',
|
||||
'char', 'varchar', 'text', 'mediumtext', 'longtext',
|
||||
'date', 'datetime', 'timestamp', 'time', 'year',
|
||||
'json', 'blob', 'longblob',
|
||||
];
|
||||
|
||||
/**
|
||||
* 数值类型(可 unsigned / 可自增)
|
||||
* @var array
|
||||
*/
|
||||
protected const NUMERIC = [
|
||||
'tinyint', 'smallint', 'mediumint', 'int', 'bigint', 'decimal', 'float', 'double',
|
||||
];
|
||||
|
||||
/**
|
||||
* 需要长度定义的类型
|
||||
* @var array
|
||||
*/
|
||||
protected const NEED_LEN = ['char', 'varchar', 'decimal', 'float', 'double'];
|
||||
|
||||
/**
|
||||
* 允许的引擎
|
||||
* @var array
|
||||
*/
|
||||
protected const ENGINES = ['InnoDB', 'MyISAM', 'MEMORY', 'Aria'];
|
||||
|
||||
/**
|
||||
* 允许的字符集
|
||||
* @var array
|
||||
*/
|
||||
protected const CHARSETS = ['utf8mb4', 'utf8', 'gbk', 'latin1'];
|
||||
|
||||
/**
|
||||
* 允许的排序规则(与上面的字符集配对)
|
||||
* @var array
|
||||
*/
|
||||
protected const COLLATIONS = [
|
||||
'utf8mb4_general_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin',
|
||||
'utf8_general_ci', 'utf8_bin',
|
||||
'gbk_chinese_ci', 'latin1_swedish_ci',
|
||||
];
|
||||
|
||||
/**
|
||||
* 禁止删除的核心表(不含前缀)
|
||||
* @var array
|
||||
*/
|
||||
protected const PROTECTED = [
|
||||
'backend_admin', 'backend_power', 'backend_role', 'backend_group',
|
||||
'backend_log', 'backend_menu',
|
||||
];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
if (!self::$powerChecked) {
|
||||
self::$powerChecked = true;
|
||||
$this->ensureMenus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表:全部数据表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limt = $this->request->param('limit/d', 15);
|
||||
$kw = trim((string) $this->request->param('keyword', ''));
|
||||
// 只看「库里有、但没登记进任何 install.sql」的表
|
||||
$onlyUnregistered = (int) $this->request->param('unregistered/d', 0) === 1;
|
||||
|
||||
$prefix = $this->prefix();
|
||||
$map = $this->installMap();
|
||||
$rows = [];
|
||||
try {
|
||||
$rows = Db::query('SHOW TABLE STATUS');
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error('读取表状态失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$row = array_change_key_case($row, CASE_LOWER);
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
$short = $prefix !== '' && str_starts_with($name, $prefix)
|
||||
? substr($name, strlen($prefix))
|
||||
: $name;
|
||||
if ($onlyUnregistered && $this->archiveInfo($short, $map)[0]) {
|
||||
continue;
|
||||
}
|
||||
if ($kw !== ''
|
||||
&& stripos($name, $kw) === false
|
||||
&& stripos((string) ($row['comment'] ?? ''), $kw) === false) {
|
||||
continue;
|
||||
}
|
||||
$archive = $this->archiveInfo($short, $map);
|
||||
$list[] = [
|
||||
'name' => $name,
|
||||
'short' => $short,
|
||||
'engine' => (string) ($row['engine'] ?? ''),
|
||||
// 是否已在某个 install.sql / 模型自愈里登记(决定全新安装 / 自愈能否补出来)
|
||||
'registered' => $archive[0],
|
||||
'target' => $archive[1],
|
||||
'rows' => (int) ($row['rows'] ?? 0),
|
||||
'size' => (int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0)),
|
||||
'size_text' => $this->formatBytes((int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0))),
|
||||
'collation' => (string) ($row['collation'] ?? ''),
|
||||
'comment' => (string) ($row['comment'] ?? ''),
|
||||
'protected' => in_array($short, self::PROTECTED, true),
|
||||
];
|
||||
}
|
||||
|
||||
$total = count($list);
|
||||
$slice = array_slice($list, ($page - 1) * $limt, $limt);
|
||||
$this->result->setCount($total)->success($slice);
|
||||
}
|
||||
|
||||
$this->assign('prefix', $this->prefix());
|
||||
$this->assign('charset', $this->charset());
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看表结构:字段 + 索引 + 建表语句
|
||||
*/
|
||||
public function structure()
|
||||
{
|
||||
$table = $this->safeTable((string) $this->request->param('table', ''));
|
||||
if ($table === '') {
|
||||
$this->result->error('参数错误:table');
|
||||
}
|
||||
if (! $this->tableExists($table)) {
|
||||
$this->result->error('表不存在:' . $table, 404);
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
foreach (Db::query('SHOW FULL COLUMNS FROM `' . $table . '`') as $c) {
|
||||
$c = array_change_key_case($c, CASE_LOWER);
|
||||
$columns[] = [
|
||||
'field' => (string) ($c['field'] ?? ''),
|
||||
'type' => (string) ($c['type'] ?? ''),
|
||||
'null' => (string) ($c['null'] ?? ''),
|
||||
'key' => (string) ($c['key'] ?? ''),
|
||||
'default' => $c['default'] ?? null,
|
||||
'extra' => (string) ($c['extra'] ?? ''),
|
||||
'comment' => (string) ($c['comment'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$indexes = [];
|
||||
foreach (Db::query('SHOW INDEX FROM `' . $table . '`') as $i) {
|
||||
$i = array_change_key_case($i, CASE_LOWER);
|
||||
$indexes[] = [
|
||||
'name' => (string) ($i['key_name'] ?? ''),
|
||||
'column' => (string) ($i['column_name'] ?? ''),
|
||||
'unique' => (int) ($i['non_unique'] ?? 1) === 0,
|
||||
'seq' => (int) ($i['seq_in_index'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$create = Db::query('SHOW CREATE TABLE `' . $table . '`');
|
||||
$create = array_change_key_case((array) ($create[0] ?? []), CASE_LOWER);
|
||||
|
||||
$this->result->success([
|
||||
'table' => $table,
|
||||
'short' => $this->shortName($table),
|
||||
'columns' => $columns,
|
||||
'indexes' => $indexes,
|
||||
'sql' => (string) ($create['create table'] ?? $create['create view'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成建表 SQL(只预览,不执行)
|
||||
*/
|
||||
public function preview()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
try {
|
||||
$def = $this->buildDefinition();
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error($e->getMessage());
|
||||
return;
|
||||
}
|
||||
$this->result->success([
|
||||
'sql' => $this->createSql($def),
|
||||
'table' => $def['table'],
|
||||
'short' => $this->shortName($def['table']),
|
||||
'command' => 'php think addon:crud <插件> -t ' . $this->shortName($def['table']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行建表
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
try {
|
||||
$def = $this->buildDefinition();
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error($e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->tableExists($def['table'])) {
|
||||
$this->result->error('表已存在,已中止(本页只做新建,不会覆盖或重建已有表):' . $def['table']);
|
||||
}
|
||||
|
||||
$sql = $this->createSql($def);
|
||||
try {
|
||||
Db::execute($sql);
|
||||
} catch (\Throwable $e) {
|
||||
AdminLogger::write('datatable/create', '建表失败:' . $def['table'] . '|' . $e->getMessage());
|
||||
$this->result->error('建表失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
AdminLogger::write('datatable/create', '新建表:' . $def['table'] . '(' . count($def['fields']) . ' 个字段)');
|
||||
$this->result->success([
|
||||
'table' => $def['table'],
|
||||
'short' => $this->shortName($def['table']),
|
||||
'sql' => $sql,
|
||||
'command' => 'php think addon:crud <插件> -t ' . $this->shortName($def['table']),
|
||||
], '表创建成功:' . $def['table']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除表(危险:不可逆)
|
||||
*
|
||||
* 必须回传 confirm=表全名,与前端输入框完全一致才放行。
|
||||
*/
|
||||
public function drop()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$table = $this->safeTable((string) $this->request->param('table', ''));
|
||||
$confirm = (string) $this->request->param('confirm', '');
|
||||
if ($table === '') {
|
||||
$this->result->error('参数错误:table');
|
||||
}
|
||||
if ($confirm !== $table) {
|
||||
$this->result->error('确认表名不一致,已中止');
|
||||
}
|
||||
if (in_array($this->shortName($table), self::PROTECTED, true)) {
|
||||
$this->result->error('该表是系统核心表,禁止删除:' . $table);
|
||||
}
|
||||
if (! $this->tableExists($table)) {
|
||||
$this->result->error('表不存在:' . $table, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
Db::execute('DROP TABLE `' . $table . '`');
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error('删除失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
AdminLogger::write('datatable/drop', '删除表:' . $table);
|
||||
$this->result->success(['table' => $table], '已删除表:' . $table);
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化表
|
||||
*/
|
||||
public function optimize()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
|
||||
if (! $tables) {
|
||||
$this->result->error('请选择要优化的表');
|
||||
}
|
||||
$ok = [];
|
||||
foreach ($tables as $t) {
|
||||
$table = $this->safeTable((string) $t);
|
||||
if ($table === '' || ! $this->tableExists($table)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Db::execute('OPTIMIZE TABLE `' . $table . '`');
|
||||
$ok[] = $table;
|
||||
} catch (\Throwable $e) {
|
||||
// 单表失败不影响其它表
|
||||
}
|
||||
}
|
||||
AdminLogger::write('datatable/optimize', '优化表:' . implode(', ', $ok));
|
||||
$this->result->success(['tables' => $ok], '已优化 ' . count($ok) . ' 张表');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出安装 SQL(只生成文本,不写任何文件、不执行)
|
||||
*
|
||||
* 目的:把「运行时在页面上建出来的表」回写到项目里的 install.sql,
|
||||
* 否则换库全新安装 / 老站自愈都拿不到这张表。
|
||||
* 输出形式统一为 `CREATE TABLE IF NOT EXISTS `__PREFIX__xxx``,
|
||||
* 与各 install.sql 现有写法一致(自愈类按 __PREFIX__ 占位符解析执行)。
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
|
||||
if (! $tables) {
|
||||
$this->result->error('请选择要导出的表');
|
||||
}
|
||||
|
||||
$map = $this->installMap();
|
||||
$items = [];
|
||||
foreach ($tables as $t) {
|
||||
$table = $this->safeTable((string) $t);
|
||||
if ($table === '' || ! $this->tableExists($table)) {
|
||||
continue;
|
||||
}
|
||||
$sql = $this->exportSql($table);
|
||||
if ($sql === '') {
|
||||
continue;
|
||||
}
|
||||
$short = $this->shortName($table);
|
||||
[$registered, $target] = $this->archiveInfo($short, $map);
|
||||
$items[] = [
|
||||
'table' => $table,
|
||||
'short' => $short,
|
||||
'registered' => $registered,
|
||||
'target' => $target,
|
||||
'sql' => $sql,
|
||||
];
|
||||
}
|
||||
if (! $items) {
|
||||
$this->result->error('没有可导出的表(表不存在或无法读取建表语句)');
|
||||
}
|
||||
|
||||
$unregistered = array_values(array_filter($items, fn ($i) => ! $i['registered']));
|
||||
AdminLogger::write('datatable/export', '导出安装 SQL:' . implode(', ', array_column($items, 'table')));
|
||||
|
||||
$this->result->success([
|
||||
'items' => $items,
|
||||
'text' => implode("\n\n", array_column($items, 'sql')),
|
||||
'unregistered' => array_column($unregistered, 'short'),
|
||||
], '已生成 ' . count($items) . ' 张表的安装 SQL');
|
||||
}
|
||||
|
||||
/* ============================ 建表定义解析 ============================ */
|
||||
|
||||
/**
|
||||
* 解析前端提交的建表参数(全部走白名单,非法直接抛错)
|
||||
*
|
||||
* @return array{table:string,comment:string,engine:string,charset:string,collation:string,fields:array}
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function buildDefinition(): array
|
||||
{
|
||||
$short = trim((string) $this->request->param('table', ''));
|
||||
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $short)) {
|
||||
throw new \InvalidArgumentException('表名不合法:只允许字母、数字、下划线,且以字母开头');
|
||||
}
|
||||
$table = $this->prefix() . $short;
|
||||
if (strlen($table) > 64) {
|
||||
throw new \InvalidArgumentException('表名过长(含前缀超过 64 字符)');
|
||||
}
|
||||
|
||||
$engine = (string) $this->request->param('engine', 'InnoDB');
|
||||
$charset = (string) $this->request->param('charset', $this->charset());
|
||||
$collation = (string) $this->request->param('collation', '');
|
||||
if (! in_array($engine, self::ENGINES, true)) {
|
||||
throw new \InvalidArgumentException('不支持的存储引擎:' . $engine);
|
||||
}
|
||||
if (! in_array($charset, self::CHARSETS, true)) {
|
||||
throw new \InvalidArgumentException('不支持的字符集:' . $charset);
|
||||
}
|
||||
if ($collation !== '' && ! in_array($collation, self::COLLATIONS, true)) {
|
||||
throw new \InvalidArgumentException('不支持的排序规则:' . $collation);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
foreach ((array) $this->request->param('fields/a', []) as $f) {
|
||||
$fields[] = $this->parseField((array) $f);
|
||||
}
|
||||
if (! $fields) {
|
||||
throw new \InvalidArgumentException('至少需要一个字段');
|
||||
}
|
||||
|
||||
// 字段名重复 / 多个自增列是 MySQL 会直接报错的情况,提前拦掉
|
||||
$seen = [];
|
||||
$auto = 0;
|
||||
foreach ($fields as $f) {
|
||||
if (isset($seen[$f['name']])) {
|
||||
throw new \InvalidArgumentException('字段名重复:' . $f['name']);
|
||||
}
|
||||
$seen[$f['name']] = true;
|
||||
if ($f['autoinc']) {
|
||||
$auto++;
|
||||
}
|
||||
}
|
||||
if ($auto > 1) {
|
||||
throw new \InvalidArgumentException('只能有一个自增字段');
|
||||
}
|
||||
foreach ($fields as $f) {
|
||||
// 自增列必须是主键(MySQL 硬性要求)
|
||||
if ($f['autoinc'] && ! $f['pk']) {
|
||||
throw new \InvalidArgumentException('自增字段「' . $f['name'] . '」必须同时设为主键');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'table' => $table,
|
||||
'comment' => trim((string) $this->request->param('comment', '')),
|
||||
'engine' => $engine,
|
||||
'charset' => $charset,
|
||||
'collation' => $collation,
|
||||
'fields' => $fields,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个字段
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function parseField(array $f): array
|
||||
{
|
||||
$name = trim((string) ($f['name'] ?? ''));
|
||||
$type = strtolower(trim((string) ($f['type'] ?? '')));
|
||||
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $name)) {
|
||||
throw new \InvalidArgumentException('字段名不合法:' . ($name === '' ? '(空)' : $name));
|
||||
}
|
||||
if (! in_array($type, self::TYPES, true)) {
|
||||
throw new \InvalidArgumentException('不支持的字段类型:' . $type);
|
||||
}
|
||||
|
||||
// 长度:仅数值精度与字符长度允许,形如 10 或 10,2
|
||||
$length = trim((string) ($f['length'] ?? ''));
|
||||
if ($length !== '' && ! preg_match('/^\d{1,10}(,\d{1,4})?$/', $length)) {
|
||||
throw new \InvalidArgumentException('字段「' . $name . '」的长度 / 精度不合法');
|
||||
}
|
||||
if ($length === '' && in_array($type, self::NEED_LEN, true)) {
|
||||
$length = $type === 'varchar' ? '255' : ($type === 'decimal' ? '10,2' : '255');
|
||||
}
|
||||
if (! in_array($type, self::NEED_LEN, true)) {
|
||||
$length = ''; // 整型带显示宽度在 MySQL 8 已废弃,统一不输出
|
||||
}
|
||||
|
||||
// 默认值要区分「没传」和「传了空串」:空串是合法默认值(DEFAULT ''),
|
||||
// 一律 trim 成 '' 会让 NOT NULL 的 varchar 拿不到默认值。
|
||||
$hasDefault = array_key_exists('default', $f)
|
||||
&& $f['default'] !== null
|
||||
&& $f['default'] !== false;
|
||||
$default = $hasDefault ? (string) $f['default'] : '';
|
||||
$unsigned = ! empty($f['unsigned']) && in_array($type, self::NUMERIC, true);
|
||||
$nullable = ! empty($f['nullable']);
|
||||
$autoinc = ! empty($f['autoinc']) && in_array($type, self::NUMERIC, true)
|
||||
&& ! in_array($type, ['decimal', 'float', 'double'], true);
|
||||
$pk = ! empty($f['pk']);
|
||||
|
||||
// 自增列不能为空、不能有默认值
|
||||
if ($autoinc) {
|
||||
$nullable = false;
|
||||
$default = '';
|
||||
$hasDefault = false;
|
||||
}
|
||||
|
||||
$index = strtolower(trim((string) ($f['index'] ?? '')));
|
||||
if (! in_array($index, ['', 'index', 'unique'], true)) {
|
||||
$index = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'length' => $length,
|
||||
'default' => $default,
|
||||
'has_default' => $hasDefault,
|
||||
'comment' => trim((string) ($f['comment'] ?? '')),
|
||||
'unsigned' => $unsigned,
|
||||
'nullable' => $nullable,
|
||||
'autoinc' => $autoinc,
|
||||
'pk' => $pk,
|
||||
'index' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 CREATE TABLE 语句
|
||||
*/
|
||||
protected function createSql(array $def): string
|
||||
{
|
||||
$lines = [];
|
||||
$pks = [];
|
||||
$keys = [];
|
||||
|
||||
foreach ($def['fields'] as $f) {
|
||||
$type = $f['type'] . ($f['length'] !== '' ? '(' . $f['length'] . ')' : '');
|
||||
$part = ' `' . $f['name'] . '` ' . $type;
|
||||
if ($f['unsigned']) {
|
||||
$part .= ' unsigned';
|
||||
}
|
||||
$part .= $f['nullable'] ? ' NULL' : ' NOT NULL';
|
||||
|
||||
// 默认值:NULL / CURRENT_TIMESTAMP / 数字字面量不加引号,其余按字符串处理
|
||||
if ($f['has_default']) {
|
||||
$d = strtoupper($f['default']);
|
||||
if ($d === 'NULL' && $f['nullable']) {
|
||||
$part .= ' DEFAULT NULL';
|
||||
} elseif ($d === 'CURRENT_TIMESTAMP' || $d === 'CURRENT_TIMESTAMP()') {
|
||||
$part .= ' DEFAULT CURRENT_TIMESTAMP';
|
||||
} elseif ($this->isNumericLiteral($f['default'])) {
|
||||
$part .= ' DEFAULT ' . $f['default'];
|
||||
} else {
|
||||
$part .= " DEFAULT '" . $this->escape($f['default']) . "'";
|
||||
}
|
||||
} elseif ($f['nullable']) {
|
||||
$part .= ' DEFAULT NULL';
|
||||
}
|
||||
|
||||
if ($f['autoinc']) {
|
||||
$part .= ' AUTO_INCREMENT';
|
||||
}
|
||||
if ($f['comment'] !== '') {
|
||||
$part .= " COMMENT '" . $this->escape($f['comment']) . "'";
|
||||
}
|
||||
$lines[] = $part;
|
||||
|
||||
if ($f['pk']) {
|
||||
$pks[] = '`' . $f['name'] . '`';
|
||||
}
|
||||
if ($f['index'] === 'unique') {
|
||||
$keys[] = ' UNIQUE KEY `' . $this->keyName('uk_' . $f['name']) . '` (`' . $f['name'] . '`)';
|
||||
} elseif ($f['index'] === 'index') {
|
||||
$keys[] = ' KEY `' . $this->keyName('idx_' . $f['name']) . '` (`' . $f['name'] . '`)';
|
||||
}
|
||||
}
|
||||
|
||||
if ($pks) {
|
||||
$lines[] = ' PRIMARY KEY (' . implode(',', $pks) . ')';
|
||||
}
|
||||
$lines = array_merge($lines, $keys);
|
||||
|
||||
$tail = 'ENGINE=' . $def['engine'] . ' DEFAULT CHARSET=' . $def['charset'];
|
||||
if ($def['collation'] !== '') {
|
||||
$tail .= ' COLLATE=' . $def['collation'];
|
||||
}
|
||||
if ($def['comment'] !== '') {
|
||||
$tail .= " COMMENT='" . $this->escape($def['comment']) . "'";
|
||||
}
|
||||
|
||||
return 'CREATE TABLE `' . $def['table'] . "` (\n" . implode(",\n", $lines) . "\n) " . $tail . ';';
|
||||
}
|
||||
|
||||
/* ============================ 内部方法 ============================ */
|
||||
|
||||
/**
|
||||
* 全项目 install.sql 索引:[裸表名 => 相对项目根的 install.sql 路径]
|
||||
*
|
||||
* 覆盖三处:核心 public/install/install.sql、应用 app/{应用}/install.sql、
|
||||
* 插件 addon/{插件}/install.sql。表名里的 __PREFIX__ 与写死的前缀都会去掉,
|
||||
* 与实际的裸表名对齐。结果进程内缓存一次,避免每次列表都读一遍文件。
|
||||
*/
|
||||
protected function installMap(): array
|
||||
{
|
||||
if (self::$installMap !== null) {
|
||||
return self::$installMap;
|
||||
}
|
||||
|
||||
$root = rtrim((string) app()->getRootPath(), '/\\');
|
||||
$files = array_merge(
|
||||
[$root . '/public/install/install.sql'],
|
||||
glob($root . '/app/*/install.sql') ?: [],
|
||||
glob($root . '/addon/*/install.sql') ?: []
|
||||
);
|
||||
|
||||
$map = [];
|
||||
$prefix = $this->prefix();
|
||||
foreach ($files as $file) {
|
||||
if (! is_file($file) || ! is_readable($file)) {
|
||||
continue;
|
||||
}
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false || $sql === '') {
|
||||
continue;
|
||||
}
|
||||
if (! preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`([^`]+)`/i', $sql, $m)) {
|
||||
continue;
|
||||
}
|
||||
$rel = ltrim(str_replace($root, '', $file), '/\\');
|
||||
foreach ($m[1] as $table) {
|
||||
$short = str_replace('__PREFIX__', '', $table);
|
||||
if ($prefix !== '' && str_starts_with($short, $prefix)) {
|
||||
$short = substr($short, strlen($prefix));
|
||||
}
|
||||
if (! isset($map[$short])) {
|
||||
$map[$short] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installMap = $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档状态:[是否已归档, 归档位置描述]
|
||||
*
|
||||
* 归档有两条合法路径,都要算「已归档」,否则会误报:
|
||||
* 1. install.sql(核心 / 应用 / 插件)—— 安装与自愈都靠它;
|
||||
* 2. 模型自愈 DDL(如 common_addonconf,见 BaseModel 注释)——
|
||||
* 不在任何 install.sql 里,由模型 ensureSchema() 运行时补表。
|
||||
*
|
||||
* @param array $map installMap() 结果
|
||||
* @return array{0:bool,1:string}
|
||||
*/
|
||||
protected function archiveInfo(string $short, array $map): array
|
||||
{
|
||||
if (isset($map[$short])) {
|
||||
return [true, $map[$short]];
|
||||
}
|
||||
if ($this->selfHealed($short)) {
|
||||
return [true, '模型自愈 DDL'];
|
||||
}
|
||||
return [false, $this->guessTarget($short)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否被某个模型 / 服务的自愈 DDL 登记过
|
||||
*
|
||||
* 判定方式:模型目录里的 PHP 源码中出现该表名字符串。
|
||||
* 属于宽松匹配(宁可算已归档,也不在列表里制造假警报),
|
||||
* 精确事实源仍是各 install.sql 与模型 ensureSchema()。
|
||||
*/
|
||||
protected function selfHealed(string $short): bool
|
||||
{
|
||||
static $source = null;
|
||||
if ($source === null) {
|
||||
$root = rtrim((string) app()->getRootPath(), '/\\');
|
||||
$dirs = array_merge(
|
||||
[$root . '/ywxapp/model'],
|
||||
glob($root . '/app/*/model') ?: [],
|
||||
glob($root . '/addon/*/model') ?: []
|
||||
);
|
||||
$source = '';
|
||||
foreach ($dirs as $dir) {
|
||||
foreach (glob($dir . '/*.php') ?: [] as $file) {
|
||||
$body = file_get_contents($file);
|
||||
if ($body !== false) {
|
||||
$source .= $body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $source !== '' && strpos($source, $short) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单表导出语句:SHOW CREATE TABLE → __PREFIX__ 占位符 + IF NOT EXISTS
|
||||
*
|
||||
* 顺手去掉 AUTO_INCREMENT=当前值(自增值是运行时状态,不该进安装脚本)。
|
||||
*/
|
||||
protected function exportSql(string $table): string
|
||||
{
|
||||
try {
|
||||
$rows = Db::query('SHOW CREATE TABLE `' . $table . '`');
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
$row = array_change_key_case((array) ($rows[0] ?? []), CASE_LOWER);
|
||||
$sql = (string) ($row['create table'] ?? '');
|
||||
if ($sql === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$prefix = $this->prefix();
|
||||
if ($prefix !== '') {
|
||||
$sql = str_replace('`' . $prefix, '`__PREFIX__', $sql);
|
||||
}
|
||||
$sql = (string) preg_replace('/^CREATE TABLE `/i', 'CREATE TABLE IF NOT EXISTS `', $sql, 1);
|
||||
$sql = (string) preg_replace('/AUTO_INCREMENT=\d+\s*/i', '', $sql);
|
||||
|
||||
return rtrim($sql, "; \t\n\r\0\x0B") . ';';
|
||||
}
|
||||
|
||||
/**
|
||||
* 未归档表的目标 install.sql 猜测(按表名前缀匹配模块)
|
||||
*
|
||||
* 猜不出来返回空串,由前端提示人工选择,绝不默认塞进核心 SQL。
|
||||
*/
|
||||
protected function guessTarget(string $short): string
|
||||
{
|
||||
$root = rtrim((string) app()->getRootPath(), '/\\');
|
||||
foreach (glob($root . '/addon/*/install.sql') ?: [] as $file) {
|
||||
$module = basename(dirname($file));
|
||||
if ($module !== '' && str_starts_with($short, $module . '_')) {
|
||||
return 'addon/' . $module . '/install.sql';
|
||||
}
|
||||
}
|
||||
foreach (glob($root . '/app/*/install.sql') ?: [] as $file) {
|
||||
$module = basename(dirname($file));
|
||||
if ($module !== '' && str_starts_with($short, $module . '_')) {
|
||||
return 'app/' . $module . '/install.sql';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 表名白名单过滤(去掉反引号等非法字符)
|
||||
*/
|
||||
protected function safeTable(string $name): string
|
||||
{
|
||||
$name = trim(str_replace('`', '', $name));
|
||||
return preg_match('/^[a-zA-Z0-9_]{1,64}$/', $name) ? $name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 表是否存在(参数绑定,防注入)
|
||||
*/
|
||||
protected function tableExists(string $table): bool
|
||||
{
|
||||
try {
|
||||
return ! empty(Db::query('SHOW TABLES LIKE ?', [$table]));
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引名(截断到 64 字符)
|
||||
*/
|
||||
protected function keyName(string $name): string
|
||||
{
|
||||
$name = preg_replace('/[^a-zA-Z0-9_]/', '_', $name) ?: 'idx';
|
||||
return substr($name, 0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认值是否数字字面量(决定是否加引号)
|
||||
*/
|
||||
protected function isNumericLiteral(string $v): bool
|
||||
{
|
||||
return preg_match('/^-?\d+(\.\d+)?$/', $v) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单引号转义(不依赖 mysql_real_escape_string,避免需要连接句柄)
|
||||
*/
|
||||
protected function escape(string $v): string
|
||||
{
|
||||
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表前缀
|
||||
*/
|
||||
protected function prefix(): string
|
||||
{
|
||||
try {
|
||||
return (string) Db::connect()->getConfig('prefix');
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认字符集
|
||||
*/
|
||||
protected function charset(): string
|
||||
{
|
||||
try {
|
||||
$c = (string) Db::connect()->getConfig('charset');
|
||||
} catch (\Throwable $e) {
|
||||
$c = '';
|
||||
}
|
||||
return in_array($c, self::CHARSETS, true) ? $c : 'utf8mb4';
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉前缀的短表名
|
||||
*/
|
||||
protected function shortName(string $table): string
|
||||
{
|
||||
$prefix = $this->prefix();
|
||||
return $prefix !== '' && str_starts_with($table, $prefix) ? substr($table, strlen($prefix)) : $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数格式化
|
||||
*/
|
||||
protected function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\backend\controller;
|
||||
|
||||
use app\backend\library\MenuRegistry;
|
||||
|
||||
/**
|
||||
* 权限节点自愈(控制器侧入口)
|
||||
*
|
||||
* 核心后台的菜单就是 backend_power 里的一条记录。菜单定义集中在
|
||||
* MenuRegistry(避免散落各控制器),本 trait 与 BackendSchemaMiddleware
|
||||
* 共用同一个 MenuRegistry::ensure(),两处触发、一份定义:
|
||||
* - 中间件:每个 /admin/* 请求补录 ⇒ 菜单「自己出现」,管理员不必手工加节点;
|
||||
* - 控制器:直接访问页面时的双保险。
|
||||
*
|
||||
* 幂等 + 静默:已存在的节点不动(管理员可能改过名 / 排过序),
|
||||
* 权限表不可用或没写权限时一律吞掉,不能阻断业务。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
trait EnsuresPower
|
||||
{
|
||||
/**
|
||||
* 补录本应用的新增菜单与按钮节点
|
||||
* @return void
|
||||
*/
|
||||
protected function ensureMenus(): void
|
||||
{
|
||||
MenuRegistry::ensure();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\facade\Cookie;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\HookService;
|
||||
|
||||
/**
|
||||
* 模板钩子诊断(只读)
|
||||
*
|
||||
* 列出两件事,用于排查「插件启了但页面没输出」:
|
||||
* 1. 模板里埋了哪些 hook() 点(扫描视图目录)
|
||||
* 2. 每个点挂了什么(mount/register 可枚举;事件监听只能看到「有无」)
|
||||
*/
|
||||
class Hook extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$points = HookService::points();
|
||||
|
||||
// 挂载按钩子名归并(视图里做数组下标访问不安全,这里先拼好)
|
||||
$mounted = [];
|
||||
foreach (HookService::mounted() as $item) {
|
||||
$mounted[$item['name']][] = $item;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($points as $name => $info) {
|
||||
$rows[] = [
|
||||
'name' => $name,
|
||||
'files' => $info['files'],
|
||||
'mounts' => $mounted[$name] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
$this->assign('rows', $rows);
|
||||
$this->assign('hasListener', HookService::hasListener());
|
||||
$this->assign('total', count($points));
|
||||
$this->assign('devOn', HookService::devMode());
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换「钩点可视化标注」开关(Discuz 式开发者模式)
|
||||
*
|
||||
* 写 cookie hook_dev=1,仅在后台应用(backend)内生效,不落配置文件,
|
||||
* 也不会随前台页面暴露给访客;全局模式请用 .env 的 HOOK_DEVELOPER=true。
|
||||
* 访问:POST /backend/hook/devMode {on: 1|0}
|
||||
*/
|
||||
public function devMode()
|
||||
{
|
||||
$on = (int) $this->request->param('on', 0) === 1;
|
||||
if ($on) {
|
||||
Cookie::set(HookService::DEV_COOKIE, '1', ['expire' => 86400 * 7, 'path' => '/']);
|
||||
} else {
|
||||
Cookie::delete(HookService::DEV_COOKIE);
|
||||
}
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $on
|
||||
? '已开启:打开任意后台页面,hook() 埋点会显示蓝色虚线框与「⟡ 钩子名」标签'
|
||||
: '已关闭钩点标注',
|
||||
'data' => ['on' => $on],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ class Login extends BackendBase
|
||||
$data = $this->request->param();
|
||||
try {
|
||||
validate(LoginValidate::class)->check($data);
|
||||
// 注意:验证码已由 LoginValidate 的 'captcha' 规则校验;
|
||||
// think-captcha 是一次性的(校验通过即删 session),
|
||||
// 因此**不要**再把 captcha 传给 auth->login(),否则二次校验必失败、谁也登不上。
|
||||
// auth->login() 内部的验证码校验服务于未走本验证器的入口(如 BackendBase::login)。
|
||||
$this->auth->login($data['username'], $data['password']);
|
||||
if ($this->auth->isLogin) {
|
||||
$adminId = $this->auth->model->id ?? null;
|
||||
|
||||
@@ -7,8 +7,8 @@ use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Notice as NoticeModel;
|
||||
use app\backend\validate\CommonNotice as NoticeValidate;
|
||||
use ywxapp\model\CommonNotice as NoticeModel;
|
||||
use app\backend\validate\Notice as NoticeValidate;
|
||||
|
||||
/**
|
||||
* 站点公告管理
|
||||
|
||||
@@ -17,6 +17,7 @@ use app\backend\service\PowerService;
|
||||
use app\backend\validate\Power as PowerValidate;
|
||||
use think\exception\ValidateException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\View;
|
||||
use think\Request;
|
||||
use think\Response;
|
||||
@@ -63,7 +64,7 @@ class Power extends BackendBase
|
||||
public function index()
|
||||
{
|
||||
$page = $this->request->param('page', 1);
|
||||
$size = $this->request->param('size', 100);
|
||||
$size = $this->request->param('size',300);
|
||||
if ($this->request->isAjax()) {
|
||||
$data = PowerModel::cache(false)->paginate([
|
||||
'page' => $page,
|
||||
@@ -203,14 +204,49 @@ class Power extends BackendBase
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的数据');
|
||||
}
|
||||
$ids = is_array($ids) ? $ids : explode(',', (string) $ids);
|
||||
$ids = array_values(array_filter(array_map('intval', $ids)));
|
||||
if (empty($ids)) {
|
||||
$this->result->error('参数错误:ids');
|
||||
}
|
||||
try {
|
||||
Db::transaction(function () use ($ids, $force) {
|
||||
if ($force) {
|
||||
PowerModel::onlyTrashed()->whereIn('id', $ids)->select()->each(function ($item) {
|
||||
$item->force()->delete();
|
||||
});
|
||||
} else {
|
||||
PowerModel::destroy($ids);
|
||||
// 取出待删行:软删取正常行,强制删取回收站行
|
||||
$rows = $force
|
||||
? PowerModel::onlyTrashed()->whereIn('id', $ids)->select()
|
||||
: PowerModel::whereIn('id', $ids)->select();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防护①:核心后台菜单/权限节点(addon 为空)禁止删除。
|
||||
// 这些节点是后台菜单树与权限校验的基础,误删会导致后台菜单消失、
|
||||
// 权限节点丢失(历史上出现过整表被清空、登录后 menus 为空的事故)。
|
||||
$coreNames = [];
|
||||
foreach ($rows as $row) {
|
||||
if (empty($row->addon)) {
|
||||
$coreNames[] = (string) $row->name;
|
||||
}
|
||||
}
|
||||
if ($coreNames) {
|
||||
throw new \RuntimeException(
|
||||
'核心后台菜单禁止删除(会导致后台菜单与权限节点丢失):' . implode('、', $coreNames)
|
||||
);
|
||||
}
|
||||
|
||||
// 防护②:删除前落快照日志,误删后可按日志恢复
|
||||
Log::warning('[Power] 删除权限节点', [
|
||||
'admin_id' => $this->auth->info['id'] ?? 0,
|
||||
'account' => $this->auth->info['account'] ?? '',
|
||||
'force' => (bool) $force,
|
||||
'count' => count($rows),
|
||||
'ip' => $this->request->ip(),
|
||||
'snapshot' => $rows->toArray(),
|
||||
]);
|
||||
|
||||
foreach ($rows as $item) {
|
||||
$force ? $item->force()->delete() : $item->delete();
|
||||
}
|
||||
});
|
||||
$this->result->success();
|
||||
|
||||
@@ -116,7 +116,10 @@ class Profile extends BackendBase
|
||||
*/
|
||||
public function read($id)
|
||||
{
|
||||
$id = $id ?: ($this->auth->info['id'] ?? 0);
|
||||
// 安全:个人资料只允许操作「本人」,忽略 URL 传入的 id。
|
||||
// 原写法 $id ?: 当前登录ID 会让任意已登录管理员通过 /profile/update/1
|
||||
// 读取并修改他人(含超级管理员)的资料与密码,属于水平越权 + 提权。
|
||||
$id = (int) ($this->auth->info['id'] ?? 0);
|
||||
$info = AdminModel::with(['roles'])->find($id);
|
||||
if (! $info) {
|
||||
$this->result->error('管理员不存在', 404);
|
||||
@@ -129,7 +132,10 @@ class Profile extends BackendBase
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
$id = $id ?: ($this->auth->info['id'] ?? 0);
|
||||
// 安全:个人资料只允许操作「本人」,忽略 URL 传入的 id。
|
||||
// 原写法 $id ?: 当前登录ID 会让任意已登录管理员通过 /profile/update/1
|
||||
// 读取并修改他人(含超级管理员)的资料与密码,属于水平越权 + 提权。
|
||||
$id = (int) ($this->auth->info['id'] ?? 0);
|
||||
$info = AdminModel::with(['roles'])->find($id);
|
||||
if (! $info) {
|
||||
$this->result->error('管理员不存在', 404);
|
||||
@@ -142,7 +148,10 @@ class Profile extends BackendBase
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$id = $id ?: ($this->auth->info['id'] ?? 0);
|
||||
// 安全:个人资料只允许操作「本人」,忽略 URL 传入的 id。
|
||||
// 原写法 $id ?: 当前登录ID 会让任意已登录管理员通过 /profile/update/1
|
||||
// 读取并修改他人(含超级管理员)的资料与密码,属于水平越权 + 提权。
|
||||
$id = (int) ($this->auth->info['id'] ?? 0);
|
||||
$info = AdminModel::find($id);
|
||||
if (! $info) {
|
||||
$this->result->error('管理员不存在', 404);
|
||||
|
||||
@@ -8,6 +8,7 @@ use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\MebmberScore as ScoreModel;
|
||||
use ywxapp\service\ScoreService;
|
||||
use app\backend\validate\Score as ScoreValidate;
|
||||
|
||||
/**
|
||||
@@ -22,40 +23,83 @@ class Score extends BackendBase
|
||||
{
|
||||
$this->model = new ScoreModel();
|
||||
// 触发 score_rule / score_log 自愈
|
||||
ScoreModel::ensureSchema();
|
||||
ScoreModel::ensureSchema();
|
||||
}
|
||||
|
||||
// ============ 积分规则 ============
|
||||
public function index()
|
||||
{
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$name = $this->request->param('name', '');
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$title = $this->request->param('title', '');
|
||||
$action = $this->request->param('action', '');
|
||||
|
||||
if ($this->request->isAjax()) {
|
||||
$query = $this->model->newQuery();
|
||||
if ($name !== '') {
|
||||
$query->where('name', 'like', '%' . $name . '%');
|
||||
if ($title !== '') {
|
||||
$query->where('title', 'like', '%' . $title . '%');
|
||||
}
|
||||
if ($action !== '') {
|
||||
$query->where('action', $action);
|
||||
}
|
||||
$list = $query->order('sort', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
$typeList = ScoreModel::typeList();
|
||||
$typeList = ScoreModel::typeList();
|
||||
$limitTypeList = ScoreService::limitTypeList();
|
||||
$items = $list->items();
|
||||
foreach ($items as &$item) {
|
||||
$item['type_text'] = $typeList[$item['type']] ?? '-';
|
||||
$item['type_text'] = $typeList[$item['type']] ?? '-';
|
||||
$item['limit_text'] = self::limitText($item, $limitTypeList);
|
||||
$item['addon_text'] = empty($item['addon']) ? '核心' : $item['addon'];
|
||||
}
|
||||
$this->result->setCount($list->total())->success($items);
|
||||
}
|
||||
$this->assign('typeList', ScoreModel::typeList());
|
||||
$this->assign('limitTypeList', ScoreService::limitTypeList());
|
||||
$this->assign('uniqueTypeList', ScoreService::uniqueTypeList());
|
||||
$this->assign('actionList', ScoreService::actions());
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 可用动作下拉数据(核心预置 + 插件注册 + 规则表已使用)
|
||||
*/
|
||||
public function actions()
|
||||
{
|
||||
$list = ScoreService::actions();
|
||||
$data = [];
|
||||
foreach ($list as $key => $item) {
|
||||
$data[] = [
|
||||
'action' => $key,
|
||||
'title' => $item['title'],
|
||||
'addon' => $item['addon'],
|
||||
];
|
||||
}
|
||||
$this->result->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 限次文案:不限 / 每日 N 次 ...
|
||||
*/
|
||||
protected static function limitText(array $item, array $limitTypeList): string
|
||||
{
|
||||
$type = (int) ($item['limit_type'] ?? 0);
|
||||
$times = (int) ($item['limit_times'] ?? 0);
|
||||
if ($type === 0 || $times <= 0) {
|
||||
return '不限';
|
||||
}
|
||||
return ($limitTypeList[$type] ?? '-') . ' ' . $times . ' 次';
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$params = $this->request->only(['name', 'action', 'type', 'value', 'sort', 'status'], 'post');
|
||||
$params = $this->request->only(
|
||||
['title', 'action', 'type', 'value', 'addon', 'limit_type', 'limit_times', 'unique_type', 'remark', 'sort', 'status'],
|
||||
'post'
|
||||
);
|
||||
try {
|
||||
validate(ScoreValidate::class)->check($params);
|
||||
$this->model->create($params);
|
||||
@@ -82,7 +126,10 @@ class Score extends BackendBase
|
||||
if (! ($this->request->isAjax() && $this->request->isPut())) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$params = $this->request->param();
|
||||
$params = $this->request->only(
|
||||
['id', 'title', 'action', 'type', 'value', 'addon', 'limit_type', 'limit_times', 'unique_type', 'remark', 'sort', 'status'],
|
||||
'param'
|
||||
);
|
||||
$id = $params['id'] ?? null;
|
||||
$info = $this->model->find($id);
|
||||
if (! $info) {
|
||||
@@ -170,10 +217,12 @@ class Score extends BackendBase
|
||||
{
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$uid = $this->request->param('uid/d', 0);
|
||||
$uid = $this->request->param('uid/d', 0);
|
||||
$action = $this->request->param('action', '');
|
||||
$source = $this->request->param('source', '');
|
||||
|
||||
if ($this->request->isAjax()) {
|
||||
$res = ScoreModel::logList($uid, $page, $limit);
|
||||
$res = ScoreModel::logList($uid, $page, $limit, ['action' => $action, 'source' => $source]);
|
||||
$typeList = ScoreModel::typeList();
|
||||
foreach ($res['list'] as &$item) {
|
||||
$item['type_text'] = $item['type'] == 2 ? '支出' : '收入';
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\Request;
|
||||
use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\CommonShop as ShopModel;
|
||||
use app\backend\validate\Shop as ShopValidate;
|
||||
|
||||
/**
|
||||
* 电子商务管理
|
||||
*/
|
||||
class Shop extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->model = new ShopModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$title = $this->request->param('title', '');
|
||||
|
||||
if ($this->request->isAjax()) {
|
||||
$query = $this->model->newQuery();
|
||||
if ($title !== '') {
|
||||
$query->where('title', 'like', '%' . $title . '%');
|
||||
}
|
||||
$list = $query->order('sort', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
$this->result->setCount($list->total())->success($list->items());
|
||||
}
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if (! $this->request->isPost()) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$params = $this->request->only(['title', 'price', 'stock', 'description', 'sort', 'status'], 'post');
|
||||
try {
|
||||
validate(ShopValidate::class)->check($params);
|
||||
$this->model->create($params);
|
||||
$this->result->success('', '添加成功');
|
||||
} catch (ValidateException $e) {
|
||||
$this->result->error($e->getMessage());
|
||||
} catch (DbException $e) {
|
||||
$this->result->error('添加失败: ' . $e->getMessage(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$id = $id ?: $this->request->param('id');
|
||||
$info = $this->model->find($id);
|
||||
if (! $info) {
|
||||
$this->result->error('记录不存在', 404);
|
||||
}
|
||||
$this->result->success(['info' => $info]);
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
if (! ($this->request->isAjax() && $this->request->isPut())) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$params = $this->request->param();
|
||||
$id = $params['id'] ?? null;
|
||||
$info = $this->model->find($id);
|
||||
if (! $info) {
|
||||
$this->result->error('记录不存在', 404);
|
||||
}
|
||||
$statusOnly = isset($params['status'])
|
||||
&& count(array_diff(array_keys($params), ['id', 'status'])) === 0;
|
||||
if (! $statusOnly) {
|
||||
try {
|
||||
validate(ShopValidate::class)->check($params);
|
||||
} catch (ValidateException $e) {
|
||||
$this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
$info->save($params);
|
||||
$this->result->success($info, '更新成功');
|
||||
}
|
||||
|
||||
public function recyclebin()
|
||||
{
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
if ($this->request->isAjax()) {
|
||||
$list = $this->model->onlyTrashed()
|
||||
->order('sort', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
$this->result->setCount($list->total())->success($list->items());
|
||||
}
|
||||
return $this->fetch('shop/index');
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if ($this->request->isAjax() && $this->request->isDelete()) {
|
||||
$ids = $this->request->param('ids', '');
|
||||
$force = $this->request->param('force', false);
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的数据');
|
||||
}
|
||||
$idsArray = array_filter(explode(',', $ids), 'is_numeric');
|
||||
if (empty($idsArray)) {
|
||||
$this->result->error('参数错误');
|
||||
}
|
||||
try {
|
||||
Db::transaction(function () use ($idsArray, $force) {
|
||||
if ($force) {
|
||||
$this->model->onlyTrashed()->whereIn('id', $idsArray)->select()
|
||||
->each(function ($item) { $item->force()->delete(); });
|
||||
} else {
|
||||
$this->model->destroy($idsArray);
|
||||
}
|
||||
});
|
||||
$this->result->success();
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
$this->result->error('删除失败: ' . ($e->getMessage() ?: $e->__toString()), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function restore($ids = '')
|
||||
{
|
||||
if ($this->request->isAjax() && $this->request->isPost()) {
|
||||
$ids = $this->request->param('ids', '');
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要恢复的数据');
|
||||
}
|
||||
$idsArray = array_filter(explode(',', $ids), 'is_numeric');
|
||||
Db::startTrans();
|
||||
try {
|
||||
$this->model->withTrashed()->where('id', 'in', $idsArray)->select()
|
||||
->each(function ($item) { $item->restore(); });
|
||||
Db::commit();
|
||||
$this->result->success('恢复成功');
|
||||
} catch (DbException $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('恢复失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ namespace app\backend\controller;
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\SpiderDetect;
|
||||
use ywxapp\library\SpiderVerify;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 搜索蜘蛛统计
|
||||
@@ -20,12 +22,26 @@ use ywxapp\library\SpiderDetect;
|
||||
* 数据来源:全局中间件 ywxapp\middleware\SpiderStat 写入的
|
||||
* spider_log(明细)与 spider_stat(按日聚合)两张表。
|
||||
*
|
||||
* 真伪识别:UA 只是「声明」,SpiderVerify 通过 rDNS 双向验证给出结论,
|
||||
* verified 字段:0 待验证 / 1 真蜘蛛 / 2 假蜘蛛(伪造 UA)。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Spider extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = [];
|
||||
// recheck 为幂等统计操作(只重新判定真伪并同步聚合,不增删业务数据),
|
||||
// 免权限项校验,避免老库未注册 spider:recheck 权限时 403。
|
||||
protected $noNeedVerify = ['recheck'];
|
||||
|
||||
/**
|
||||
* 真伪文案
|
||||
*/
|
||||
public const VERIFY_TEXT = [
|
||||
0 => '待验证',
|
||||
1 => '真蜘蛛',
|
||||
2 => '假蜘蛛',
|
||||
];
|
||||
|
||||
/**
|
||||
* 抓取明细列表(页面 + Ajax)
|
||||
@@ -33,12 +49,13 @@ class Spider extends BackendBase
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$spider = $this->request->param('spider', '');
|
||||
$ip = $this->request->param('ip', '');
|
||||
$url = $this->request->param('url', '');
|
||||
$date = $this->request->param('date', ''); // YYYY-MM-DD
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 15);
|
||||
$spider = $this->request->param('spider', '');
|
||||
$ip = $this->request->param('ip', '');
|
||||
$url = $this->request->param('url', '');
|
||||
$date = $this->request->param('date', ''); // YYYY-MM-DD
|
||||
$verified = $this->request->param('verified', ''); // ''全部 0待验证 1真 2假
|
||||
|
||||
$query = Db::name('common_spider_log');
|
||||
if ($spider !== '') {
|
||||
@@ -53,6 +70,9 @@ class Spider extends BackendBase
|
||||
if ($date !== '' && ($ts = strtotime($date)) !== false) {
|
||||
$query->whereBetween('create_at', [$ts, $ts + 86399]);
|
||||
}
|
||||
if ($verified !== '' && in_array((string) $verified, ['0', '1', '2'], true)) {
|
||||
$query->where('verified', (int) $verified);
|
||||
}
|
||||
|
||||
try {
|
||||
$list = $query->order('id', 'desc')
|
||||
@@ -62,6 +82,13 @@ class Spider extends BackendBase
|
||||
foreach ($items as &$item) {
|
||||
$item['spider_text'] = $labels[$item['spider']] ?? $item['spider'];
|
||||
$item['time_text'] = date('Y-m-d H:i:s', (int) $item['create_at']);
|
||||
|
||||
$v = (int) ($item['verified'] ?? 0);
|
||||
$item['verified'] = $v;
|
||||
$item['verified_text'] = self::VERIFY_TEXT[$v] ?? '待验证';
|
||||
$item['rdns'] = (string) ($item['rdns'] ?? '');
|
||||
// 真伪校验依赖 UA 声明的蜘蛛是否支持 rDNS
|
||||
$item['verifiable'] = SpiderDetect::verifiable((string) $item['spider']);
|
||||
}
|
||||
unset($item);
|
||||
$this->result->setCount($list->total())->success($items);
|
||||
@@ -71,11 +98,14 @@ class Spider extends BackendBase
|
||||
}
|
||||
}
|
||||
|
||||
$this->assign('verifyText', self::VERIFY_TEXT);
|
||||
$this->assign('dnsDown', SpiderVerify::dnsDown());
|
||||
$this->assign('spiderLabels', SpiderDetect::labels());
|
||||
return $this->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总统计:各蜘蛛 今日/7日/30日/总量 + 近30日逐日趋势(图表用)
|
||||
* 汇总统计:各蜘蛛 今日/7日/30日/总量 + 真伪拆分 + 近30日逐日趋势(图表用)
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
@@ -86,14 +116,18 @@ class Spider extends BackendBase
|
||||
|
||||
$summary = [];
|
||||
$trend = ['dates' => [], 'series' => []];
|
||||
$totals = ['count' => 0, 'fake' => 0];
|
||||
|
||||
try {
|
||||
// 老库兜底:确保聚合表有 fake_count 列
|
||||
$this->ensureStatColumns();
|
||||
|
||||
$rows = Db::name('common_spider_stat')
|
||||
->where('stat_date', '>=', $d30)
|
||||
->select()
|
||||
->toArray();
|
||||
$totalRows = Db::name('common_spider_stat')
|
||||
->field('spider, SUM(`count`) AS total')
|
||||
->field('spider, SUM(`count`) AS total, SUM(`fake_count`) AS fakes')
|
||||
->group('spider')
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -101,27 +135,41 @@ class Spider extends BackendBase
|
||||
// 各蜘蛛汇总
|
||||
$map = [];
|
||||
foreach ($totalRows as $r) {
|
||||
$total = (int) $r['total'];
|
||||
$fake = (int) ($r['fakes'] ?? 0);
|
||||
$map[$r['spider']] = [
|
||||
'spider' => $r['spider'],
|
||||
'label' => $labels[$r['spider']] ?? $r['spider'],
|
||||
'today' => 0,
|
||||
'week' => 0,
|
||||
'month' => 0,
|
||||
'total' => (int) $r['total'],
|
||||
'spider' => $r['spider'],
|
||||
'label' => $labels[$r['spider']] ?? $r['spider'],
|
||||
'verifiable' => SpiderDetect::verifiable((string) $r['spider']),
|
||||
'today' => 0,
|
||||
'week' => 0,
|
||||
'month' => 0,
|
||||
'total' => $total,
|
||||
'fake' => $fake,
|
||||
'real' => max(0, $total - $fake),
|
||||
'fake_today' => 0,
|
||||
'fake_week' => 0,
|
||||
'fake_month' => 0,
|
||||
];
|
||||
$totals['count'] += $total;
|
||||
$totals['fake'] += $fake;
|
||||
}
|
||||
foreach ($rows as $r) {
|
||||
$key = $r['spider'];
|
||||
if (! isset($map[$key])) {
|
||||
if (!isset($map[$key])) {
|
||||
continue;
|
||||
}
|
||||
$c = (int) $r['count'];
|
||||
$f = (int) ($r['fake_count'] ?? 0);
|
||||
$map[$key]['month'] += $c;
|
||||
$map[$key]['fake_month'] += $f;
|
||||
if ($r['stat_date'] >= $d7) {
|
||||
$map[$key]['week'] += $c;
|
||||
$map[$key]['fake_week'] += $f;
|
||||
}
|
||||
if ($r['stat_date'] === $today) {
|
||||
$map[$key]['today'] += $c;
|
||||
$map[$key]['fake_today'] += $f;
|
||||
}
|
||||
}
|
||||
usort($map, fn ($a, $b) => $b['total'] <=> $a['total']);
|
||||
@@ -149,7 +197,116 @@ class Spider extends BackendBase
|
||||
// 表未创建:返回空数据
|
||||
}
|
||||
|
||||
$this->result->success(['summary' => $summary, 'trend' => $trend]);
|
||||
$this->result->success([
|
||||
'summary' => $summary,
|
||||
'trend' => $trend,
|
||||
'totals' => [
|
||||
'count' => $totals['count'],
|
||||
'fake' => $totals['fake'],
|
||||
'real' => max(0, $totals['count'] - $totals['fake']),
|
||||
],
|
||||
'dns_down' => SpiderVerify::dnsDown(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新校验历史记录的真伪
|
||||
*
|
||||
* 适用于:刚升级到带真伪识别的版本时,把历史「待验证」记录补校验一遍;
|
||||
* 或某蜘蛛统计异常时手动重算。只处理明细中仍在保留期内的记录,
|
||||
* 校验后同步修正当日聚合表。
|
||||
*/
|
||||
public function recheck()
|
||||
{
|
||||
if (!($this->request->isAjax() && $this->request->isPost())) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
|
||||
$limit = (int) $this->request->param('limit/d', 200);
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
try {
|
||||
$this->ensureLogColumns();
|
||||
|
||||
// 取「待验证」的日志,按 IP 去重后逐条校验(同一 IP 只查一次 DNS)
|
||||
$rows = Db::name('common_spider_log')
|
||||
->field('spider, ip')
|
||||
->where('verified', 0)
|
||||
->order('id', 'desc')
|
||||
->limit(3000)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error('读取日志失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
foreach ($rows as $r) {
|
||||
$spider = (string) $r['spider'];
|
||||
$ip = (string) $r['ip'];
|
||||
if ($ip === '' || !SpiderDetect::verifiable($spider)) {
|
||||
continue;
|
||||
}
|
||||
$pairs[$spider . '|' . $ip] = [$spider, $ip];
|
||||
if (count($pairs) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($pairs)) {
|
||||
$this->result->success(
|
||||
['real' => 0, 'fake' => 0, 'pending' => 0, 'scanned' => 0],
|
||||
'没有需要重新校验的记录(或该蜘蛛不支持 rDNS 验证)'
|
||||
);
|
||||
}
|
||||
|
||||
$real = $fake = $pending = 0;
|
||||
$touched = [];
|
||||
|
||||
foreach ($pairs as [$spider, $ip]) {
|
||||
SpiderVerify::forget($ip, $spider);
|
||||
$verdict = SpiderVerify::verify($ip, $spider);
|
||||
|
||||
if ((int) $verdict['status'] === 0) {
|
||||
$pending++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('common_spider_log')
|
||||
->where('spider', $spider)
|
||||
->where('ip', $ip)
|
||||
->update([
|
||||
'verified' => (int) $verdict['status'],
|
||||
'rdns' => (string) $verdict['rdns'],
|
||||
]);
|
||||
|
||||
$touched[$spider] = true;
|
||||
if ((int) $verdict['status'] === 1) {
|
||||
$real++;
|
||||
} else {
|
||||
$fake++;
|
||||
}
|
||||
}
|
||||
|
||||
// 同步修正聚合表(只重算有明细的日期,历史聚合保留)
|
||||
foreach (array_keys($touched) as $spider) {
|
||||
$this->resyncStat((string) $spider);
|
||||
}
|
||||
|
||||
$msg = "校验完成:真蜘蛛 {$real} 个 IP,假蜘蛛 {$fake} 个 IP";
|
||||
if ($pending > 0) {
|
||||
$msg .= ",{$pending} 个 IP 因 DNS 不可用暂无法判定(稍后可重试)";
|
||||
}
|
||||
if (SpiderVerify::dnsDown()) {
|
||||
$msg .= '。注意:DNS 查询连续失败已暂时降级,请检查服务器 DNS 配置。';
|
||||
}
|
||||
|
||||
$this->result->success([
|
||||
'real' => $real,
|
||||
'fake' => $fake,
|
||||
'pending' => $pending,
|
||||
'scanned' => count($pairs),
|
||||
], $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +314,7 @@ class Spider extends BackendBase
|
||||
*/
|
||||
public function clear()
|
||||
{
|
||||
if (! ($this->request->isAjax() && $this->request->isPost())) {
|
||||
if (!($this->request->isAjax() && $this->request->isPost())) {
|
||||
$this->result->error('请求方式错误', 405);
|
||||
}
|
||||
$days = $this->request->param('days/d', 30);
|
||||
@@ -171,4 +328,44 @@ class Spider extends BackendBase
|
||||
$this->result->error('清理失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 spider 重算聚合表(只覆盖明细仍在的日期,避免抹掉历史累计)
|
||||
*/
|
||||
protected function resyncStat(string $spider): void
|
||||
{
|
||||
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
|
||||
$log = $prefix . 'common_spider_log';
|
||||
$stat = $prefix . 'common_spider_stat';
|
||||
|
||||
Db::execute(
|
||||
"INSERT INTO `{$stat}` (`stat_date`, `spider`, `count`, `fake_count`) "
|
||||
. "SELECT DATE(FROM_UNIXTIME(`create_at`)) AS d, `spider`, COUNT(*), SUM(`verified` = 2) "
|
||||
. "FROM `{$log}` WHERE `spider` = ? "
|
||||
. 'GROUP BY d, `spider` '
|
||||
. 'ON DUPLICATE KEY UPDATE `count` = VALUES(`count`), `fake_count` = VALUES(`fake_count`)',
|
||||
[$spider]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 老库兜底:明细表补 verified / rdns 列
|
||||
*/
|
||||
protected function ensureLogColumns(): void
|
||||
{
|
||||
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
|
||||
$log = $prefix . 'common_spider_log';
|
||||
BaseModel::ensureColumn($log, 'verified', "tinyint(1) NOT NULL DEFAULT '0' COMMENT '真伪:0待验证 1真蜘蛛 2假蜘蛛'");
|
||||
BaseModel::ensureColumn($log, 'rdns', "varchar(255) NOT NULL DEFAULT '' COMMENT 'rDNS反查主机名'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 老库兜底:聚合表补 fake_count 列
|
||||
*/
|
||||
protected function ensureStatColumns(): void
|
||||
{
|
||||
$prefix = config('database.connections.mysql.prefix', 'wxapp_');
|
||||
$stat = $prefix . 'common_spider_stat';
|
||||
BaseModel::ensureColumn($stat, 'fake_count', "int unsigned NOT NULL DEFAULT '0' COMMENT '其中假蜘蛛次数'");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use Exception;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\library\TemplateManager;
|
||||
use ywxapp\library\TemplateInstaller;
|
||||
use ywxapp\library\SiteTheme;
|
||||
use ywxapp\library\TemplateMenu;
|
||||
|
||||
/**
|
||||
* 模板管理(核心后台内置模块,参考 Discuz! 后台「界面」分类)。
|
||||
@@ -327,4 +329,52 @@ class Template extends BackendBase
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点 layui 配色(整站主题变量 config/theme.php)。
|
||||
*
|
||||
* 与分组级皮肤的区别:这里控制的是 layui 组件的底色(按钮/表单/分页/导航…),
|
||||
* 对后台、会员中心、前台与全部插件前台一律生效(由 ThemeInject 中间件注入)。
|
||||
*
|
||||
* GET (AJAX) → 返回当前变量;POST → 保存并即时全站生效。
|
||||
*/
|
||||
public function siteTheme()
|
||||
{
|
||||
try {
|
||||
if ($this->request->isPost()) {
|
||||
$raw = input('vars/a', []);
|
||||
if (! is_array($raw)) {
|
||||
return $this->result->error('参数格式错误');
|
||||
}
|
||||
// enable 是开关,不参与 CSS 变量白名单校验
|
||||
if (array_key_exists('enable', $raw)) {
|
||||
SiteTheme::setEnabled(! empty($raw['enable']) && $raw['enable'] !== 'false');
|
||||
unset($raw['enable']);
|
||||
}
|
||||
$saved = SiteTheme::save($raw);
|
||||
TemplateManager::clearOverlayCache();
|
||||
return $this->result->success(['saved' => $saved], '已保存,全站立即生效');
|
||||
}
|
||||
|
||||
return $this->result->success([
|
||||
'vars' => SiteTheme::raw(),
|
||||
'enable' => SiteTheme::isEnabled(),
|
||||
'menu_ok' => TemplateMenu::exists(),
|
||||
], '获取成功');
|
||||
} catch (Exception $e) {
|
||||
return $this->result->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补建「界面」菜单入口(幂等)。
|
||||
* 模板中心/界面设置是内置页面,但老站点可能没有菜单入口,管理员点一次即可修复。
|
||||
*/
|
||||
public function syncMenu()
|
||||
{
|
||||
$res = TemplateMenu::sync();
|
||||
return $res['ok']
|
||||
? $this->result->success(['created' => $res['created']], $res['message'])
|
||||
: $this->result->error($res['message']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\backend\library;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\model\BackendPower;
|
||||
|
||||
/**
|
||||
* 核心后台「新增菜单」注册表(请求级自愈)
|
||||
*
|
||||
* 为什么需要它:核心后台的菜单就是 backend_power 里的一条记录,
|
||||
* 如果只在「访问某个页面时」才补录节点,那菜单本身不存在 ⇒ 点不进去 ⇒ 永远补不上,
|
||||
* 新功能上线后管理员根本看不到入口。
|
||||
*
|
||||
* 因此与 appmall 侧栏菜单同构(AppmallMenu::ensure()),由
|
||||
* BackendSchemaMiddleware 在每个 /admin/* 请求上幂等补录:菜单随请求出现,
|
||||
* 老库升级也不用手工去权限管理里加节点。
|
||||
*
|
||||
* 控制器侧的 EnsuresPower trait 调的是同一个入口,属双保险(进程内 memo,不重复查库)。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class MenuRegistry
|
||||
{
|
||||
/**
|
||||
* 进程内只补录一次
|
||||
* @var bool
|
||||
*/
|
||||
protected static $checked = false;
|
||||
|
||||
/**
|
||||
* 进程内只迁移一次
|
||||
* @var bool
|
||||
*/
|
||||
protected static $migrated = false;
|
||||
|
||||
/**
|
||||
* 补录全部新增菜单(幂等)
|
||||
* @return void
|
||||
*/
|
||||
public static function ensure(): void
|
||||
{
|
||||
if (self::$checked) {
|
||||
return;
|
||||
}
|
||||
// 先标记:即使中途失败也不在本次请求内反复重试
|
||||
self::$checked = true;
|
||||
|
||||
// 先做结构调整(顶层目录拆分),再补录新菜单
|
||||
self::migrate();
|
||||
|
||||
foreach (self::menus() as $menu) {
|
||||
try {
|
||||
// 菜单按所属分组挂:配置类进「系统管理」,运维类进「数据运维」
|
||||
$pid = self::groupPid((string) ($menu['group'] ?? 'system'));
|
||||
self::ensureMenu($menu, $pid);
|
||||
} catch (\Throwable $e) {
|
||||
// 单个菜单失败不影响其它菜单
|
||||
}
|
||||
}
|
||||
|
||||
// 只补按钮的菜单(菜单节点在 install.sql 已有种子,但按钮缺失)
|
||||
self::ensureScoreButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* 补录「积分规则」的按钮权限节点(幂等)
|
||||
*
|
||||
* install.sql 早期只种了 score:index,缺 score:add / edit / del / restore / destroy,
|
||||
* 结果后台新增、编辑、删除按钮被前端权限过滤隐藏,规则根本没法维护。
|
||||
* 老库不会重跑 install.sql,故在此自愈,节点定义与 install.sql 种子保持一致。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function ensureScoreButtons(): void
|
||||
{
|
||||
try {
|
||||
$menu = Db::name('backend_power')->where('name', 'score:index')->find();
|
||||
if (! $menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pid = (int) $menu['id'];
|
||||
$buttons = [
|
||||
'add' => ['title' => '新增规则', 'route' => 'score/save'],
|
||||
'edit' => ['title' => '编辑规则', 'route' => 'score/edit'],
|
||||
'del' => ['title' => '删除规则', 'route' => 'score/delete'],
|
||||
'restore' => ['title' => '恢复规则', 'route' => 'score/restore'],
|
||||
'destroy' => ['title' => '彻底删除规则', 'route' => 'score/delete'],
|
||||
];
|
||||
|
||||
$now = time();
|
||||
$newIds = [];
|
||||
foreach ($buttons as $act => $cfg) {
|
||||
$node = 'score:' . $act;
|
||||
if (Db::name('backend_power')->where('name', $node)->find()) {
|
||||
continue;
|
||||
}
|
||||
$newIds[] = (int) Db::name('backend_power')->insertGetId([
|
||||
'pid' => $pid,
|
||||
'title' => $cfg['title'],
|
||||
'name' => $node,
|
||||
'type' => 3,
|
||||
'route' => $cfg['route'],
|
||||
'icon' => '',
|
||||
'sort' => 0,
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($newIds) {
|
||||
self::grantToAllRoles($newIds);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 静默:补录失败不应让后台打不开
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================ 结构调整(一次性迁移) ============================ */
|
||||
|
||||
/**
|
||||
* 顶层目录拆分:「营运」→「内容运营」+「交易营销」(幂等、只跑一次)
|
||||
*
|
||||
* 为什么要在这里做而不是只改 install.sql:
|
||||
* install.sql 只影响全新安装,老库不会重跑,必须有一条自愈路径才能生效。
|
||||
*
|
||||
* 两个关键约束:
|
||||
* 1. 「营运」节点是原地改名成「内容运营」的,id 不变 ⇒ 角色既有的授权不失效;
|
||||
* 2. 新建的「交易营销」目录 id 是新的,角色授权表里没有它,
|
||||
* 非超管账号会因为父节点不在授权列表里而整个分支都看不到,
|
||||
* 所以迁移末尾要把新节点补授权给所有角色(见 grantToAllRoles())。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function migrate(): void
|
||||
{
|
||||
if (self::$migrated) {
|
||||
return;
|
||||
}
|
||||
self::$migrated = true;
|
||||
|
||||
try {
|
||||
// 完成标记:分组标题存在即视为已拆分。
|
||||
// 之后管理员手工调顺序 / 改名不再被本方法覆盖。
|
||||
if (Db::name('backend_power')->where('name', 'trade.group.goods')->value('id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
// 1) 内容运营:优先复用原「营运」节点(保住 id 与既有角色授权)
|
||||
$contentId = (int) Db::name('backend_power')->where('name', 'content')->value('id');
|
||||
if ($contentId <= 0) {
|
||||
$operate = Db::name('backend_power')->where('name', 'operate')->find();
|
||||
if ($operate) {
|
||||
Db::name('backend_power')->where('id', (int) $operate['id'])->update([
|
||||
'title' => '内容运营',
|
||||
'name' => 'content',
|
||||
'route' => '/content',
|
||||
'icon' => 'layui-icon layui-icon-speaker',
|
||||
'sort' => 4,
|
||||
'update_at' => $now,
|
||||
]);
|
||||
$contentId = (int) $operate['id'];
|
||||
} else {
|
||||
$contentId = (int) Db::name('backend_power')->insertGetId([
|
||||
'pid' => 0,
|
||||
'title' => '内容运营',
|
||||
'name' => 'content',
|
||||
'type' => 1,
|
||||
'route' => '/content',
|
||||
'icon' => 'layui-icon layui-icon-speaker',
|
||||
'sort' => 4,
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 交易营销:新顶层目录
|
||||
$tradeId = (int) Db::name('backend_power')->where('name', 'trade')->value('id');
|
||||
if ($tradeId <= 0) {
|
||||
$tradeId = (int) Db::name('backend_power')->insertGetId([
|
||||
'pid' => 0,
|
||||
'title' => '交易营销',
|
||||
'name' => 'trade',
|
||||
'type' => 1,
|
||||
'route' => '/trade',
|
||||
'icon' => 'layui-icon layui-icon-cart-simple',
|
||||
'sort' => 5,
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// 3) 菜单重挂到新目录并重排 sort
|
||||
$plan = self::splitPlan();
|
||||
foreach ($plan['content'] as $name => $sort) {
|
||||
Db::name('backend_power')->where('name', $name)
|
||||
->update(['pid' => $contentId, 'sort' => $sort, 'update_at' => $now]);
|
||||
}
|
||||
foreach ($plan['trade'] as $name => $sort) {
|
||||
Db::name('backend_power')->where('name', $name)
|
||||
->update(['pid' => $tradeId, 'sort' => $sort, 'update_at' => $now]);
|
||||
}
|
||||
|
||||
// 4) 交易营销内的分组标题(type=5,只做视觉分组,不可点击)
|
||||
$newIds = [];
|
||||
foreach (self::groupTitles() as $name => $cfg) {
|
||||
$id = (int) Db::name('backend_power')->where('name', $name)->value('id');
|
||||
if ($id > 0) {
|
||||
continue;
|
||||
}
|
||||
$newIds[] = (int) Db::name('backend_power')->insertGetId([
|
||||
'pid' => $tradeId,
|
||||
'title' => $cfg['title'],
|
||||
'name' => $name,
|
||||
'type' => BackendPower::TYPE_GROUP_TITLE,
|
||||
'route' => '',
|
||||
'icon' => '',
|
||||
'sort' => $cfg['sort'],
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// 5) 新节点补授权给所有角色
|
||||
self::grantToAllRoles(array_merge([$tradeId], $newIds));
|
||||
} catch (\Throwable $e) {
|
||||
// 结构调整失败不应让后台打不开:保持静默
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆分方案:[菜单 name => 新 sort]
|
||||
*
|
||||
* 与 public/install/install.sql 的 backend_power 种子保持一致,
|
||||
* 保证「全新安装」与「老库自愈」两条路径结果相同。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function splitPlan(): array
|
||||
{
|
||||
return [
|
||||
// 站点内容与展示类
|
||||
'content' => [
|
||||
'links:index' => 1,
|
||||
'notice:index' => 2,
|
||||
'ad:index' => 3,
|
||||
'help:index' => 4,
|
||||
'spider:index' => 5,
|
||||
],
|
||||
// 交易、激励与服务类(内部再用分组标题切成三组)
|
||||
'trade' => [
|
||||
'card:index' => 2,
|
||||
'score:index' => 3,
|
||||
'task:index' => 11,
|
||||
'prop:index' => 12,
|
||||
'medal:index' => 13,
|
||||
'sms:index' => 21,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录内分组标题定义(name => [标题, sort])
|
||||
*
|
||||
* 目前只在「交易营销」内使用:7 个菜单跨越交易 / 激励 / 服务三类,
|
||||
* 直接用标题分组比再建一层目录更轻(layui 侧边栏不支持三级展开)。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function groupTitles(): array
|
||||
{
|
||||
return [
|
||||
'trade.group.goods' => ['title' => '交易中心', 'sort' => 0],
|
||||
'trade.group.mission' => ['title' => '互动激励', 'sort' => 10],
|
||||
'trade.group.service' => ['title' => '基础服务', 'sort' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新增节点补授权给所有角色(幂等)
|
||||
*
|
||||
* 菜单是按授权 id 取树的:父目录不在授权列表里,整条分支都会丢,
|
||||
* 所以新建顶层目录 / 分组标题必须同步授权,否则非超管账号会「看不见但确实存在」。
|
||||
*
|
||||
* @param array $powerIds
|
||||
* @return void
|
||||
*/
|
||||
protected static function grantToAllRoles(array $powerIds): void
|
||||
{
|
||||
$powerIds = array_values(array_unique(array_filter(array_map('intval', $powerIds))));
|
||||
if (! $powerIds) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
$roles = Db::name('backend_role')->column('id');
|
||||
foreach ($roles as $roleId) {
|
||||
$owned = Db::name('backend_role_power')
|
||||
->where('role_id', (int) $roleId)
|
||||
->whereIn('power_id', $powerIds)
|
||||
->column('power_id');
|
||||
$missing = array_diff($powerIds, array_map('intval', $owned));
|
||||
foreach ($missing as $powerId) {
|
||||
Db::name('backend_role_power')->insert([
|
||||
'role_id' => (int) $roleId,
|
||||
'power_id' => (int) $powerId,
|
||||
'create_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层分组定义(分组名 => 目录节点)
|
||||
*
|
||||
* 与 public/install/install.sql 的 backend_power 种子保持一致:
|
||||
* name/sort/route/icon 三处对齐,保证全新安装与老库自愈结果相同。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function groups(): array
|
||||
{
|
||||
return [
|
||||
'system' => [
|
||||
'title' => '系统管理',
|
||||
'name' => 'system',
|
||||
'route' => '/system',
|
||||
'icon' => 'layui-icon layui-icon-set',
|
||||
'sort' => 6,
|
||||
],
|
||||
'tools' => [
|
||||
'title' => '数据运维',
|
||||
'name' => 'tools',
|
||||
'route' => '/tools',
|
||||
'icon' => 'layui-icon layui-icon-engine',
|
||||
'sort' => 7,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取分组目录节点 ID(不存在则补建,幂等)
|
||||
*/
|
||||
protected static function groupPid(string $group): int
|
||||
{
|
||||
try {
|
||||
$cfg = self::groups()[$group] ?? null;
|
||||
if (! $cfg) {
|
||||
return 0;
|
||||
}
|
||||
$id = Db::name('backend_power')->where('name', $cfg['name'])->value('id');
|
||||
if ($id) {
|
||||
return (int) $id;
|
||||
}
|
||||
$now = time();
|
||||
return (int) Db::name('backend_power')->insertGetId([
|
||||
'pid' => 0,
|
||||
'title' => $cfg['title'],
|
||||
'name' => $cfg['name'],
|
||||
'type' => 1,
|
||||
'route' => $cfg['route'],
|
||||
'icon' => $cfg['icon'],
|
||||
'sort' => $cfg['sort'],
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return 0; // 权限表不可用:静默
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增菜单定义(权限前缀 => 配置)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function menus(): array
|
||||
{
|
||||
return [
|
||||
'database' => [
|
||||
'title' => '数据库备份',
|
||||
'route' => 'database/index',
|
||||
'icon' => 'layui-icon layui-icon-set',
|
||||
'group' => 'tools',
|
||||
// 与 public/install/install.sql 的 backend_power 种子 sort 一致,
|
||||
// 保证「全新安装」与「老库自愈」两条路径的菜单顺序相同
|
||||
'sort' => 1,
|
||||
'buttons' => [
|
||||
'backup' => '备份',
|
||||
'recovery' => '还原',
|
||||
'delete' => '删除',
|
||||
'optimize' => '优化',
|
||||
],
|
||||
],
|
||||
'datatable' => [
|
||||
'title' => '数据表管理',
|
||||
'route' => 'datatable/index',
|
||||
'icon' => 'layui-icon layui-icon-template-1',
|
||||
'group' => 'tools',
|
||||
'sort' => 2,
|
||||
'buttons' => [
|
||||
'create' => '新建',
|
||||
'structure' => '结构',
|
||||
'optimize' => '优化',
|
||||
'export' => '导出安装SQL',
|
||||
'drop' => '删除',
|
||||
],
|
||||
],
|
||||
'adminlog' => [
|
||||
'title' => '操作日志',
|
||||
'route' => 'adminlog/index',
|
||||
'icon' => 'layui-icon layui-icon-log',
|
||||
'group' => 'tools',
|
||||
'sort' => 4,
|
||||
'buttons' => [
|
||||
'delete' => '删除',
|
||||
'clean' => '清理',
|
||||
],
|
||||
],
|
||||
'attachment' => [
|
||||
'title' => '附件管理',
|
||||
'route' => 'attachment/index',
|
||||
'icon' => 'layui-icon layui-icon-picture',
|
||||
'group' => 'tools',
|
||||
'sort' => 3,
|
||||
'buttons' => [
|
||||
'delete' => '删除',
|
||||
],
|
||||
],
|
||||
'hook' => [
|
||||
'title' => '模板钩子',
|
||||
'route' => 'hook/index',
|
||||
'icon' => 'layui-icon layui-icon-template-1',
|
||||
'group' => 'tools',
|
||||
// 与 install.sql 种子 sort 一致(数据运维下第 6 项)
|
||||
'sort' => 6,
|
||||
// 只读诊断页,没有按钮权限
|
||||
'buttons' => [],
|
||||
],
|
||||
'framework' => [
|
||||
'title' => '框架升级',
|
||||
'route' => 'framework/index',
|
||||
'icon' => 'layui-icon layui-icon-refresh',
|
||||
'group' => 'system',
|
||||
// 与 install.sql 种子 sort 一致(系统管理下第 6 项)
|
||||
'sort' => 6,
|
||||
// 控制台页也用这两个动作(检查更新 / 立即升级),
|
||||
// 老库菜单已存在 ⇒ 这里只补按钮权限节点。
|
||||
'buttons' => [
|
||||
'check' => '检查更新',
|
||||
'upgrade' => '执行升级',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 补录单个菜单 + 其按钮节点
|
||||
*
|
||||
* @param array $menu
|
||||
* @param int $pid 「系统管理」目录节点 ID
|
||||
* @return void
|
||||
*/
|
||||
protected static function ensureMenu(array $menu, int $pid): void
|
||||
{
|
||||
$name = $menu['route'];
|
||||
$name = explode('/', $name)[0] . ':index';
|
||||
|
||||
$prefix = explode(':', $name)[0];
|
||||
$exists = Db::name('backend_power')->where('name', $name)->find();
|
||||
if ($exists) {
|
||||
// 菜单已存在(含手工调整过的排序 / 改名):菜单本身不动,
|
||||
// 但按钮节点要继续补,否则后来新增的按钮(如导出)在老库上永远没有权限节点。
|
||||
self::ensureButtons((int) $exists['id'], $prefix, $menu['buttons']);
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$id = (int) Db::name('backend_power')->insertGetId([
|
||||
'pid' => $pid,
|
||||
'title' => $menu['title'],
|
||||
'name' => $name,
|
||||
'type' => 2,
|
||||
'route' => $menu['route'],
|
||||
'icon' => $menu['icon'],
|
||||
'sort' => (int) ($menu['sort'] ?? 100),
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
|
||||
self::ensureButtons($id, $prefix, $menu['buttons'], $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 补录按钮节点(幂等:只补缺失的,已存在的不动)
|
||||
*
|
||||
* @param int $pid 菜单节点 ID
|
||||
* @param string $prefix 权限前缀,如 datatable
|
||||
* @param array $buttons [动作 => 中文名]
|
||||
*/
|
||||
protected static function ensureButtons(int $pid, string $prefix, array $buttons, int $now = 0): void
|
||||
{
|
||||
if ($pid <= 0 || ! $buttons) {
|
||||
return;
|
||||
}
|
||||
$now = $now ?: time();
|
||||
|
||||
foreach ($buttons as $act => $label) {
|
||||
$node = $prefix . ':' . $act;
|
||||
if (Db::name('backend_power')->where('name', $node)->find()) {
|
||||
continue; // 已存在(可能手工改过名),不再覆盖
|
||||
}
|
||||
Db::name('backend_power')->insert([
|
||||
'pid' => $pid,
|
||||
'title' => $label,
|
||||
'name' => $node,
|
||||
'type' => 3,
|
||||
'route' => $prefix . '/' . $act,
|
||||
'icon' => '',
|
||||
'sort' => 1,
|
||||
'addon' => null,
|
||||
'status' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
'delete_at' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\backend\library;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\library\AdminLogger;
|
||||
use ywxapp\library\DbBackup;
|
||||
use ywxapp\model\CommonAttachment;
|
||||
|
||||
/**
|
||||
* 系统信息采集与缓存清理
|
||||
*
|
||||
* 从 System 控制器里抽出来,供「系统信息」页与「控制台」首页共用一份数据,
|
||||
* 避免同一套巡检逻辑在两个控制器里各写一遍(改一处、忘一处)。
|
||||
*
|
||||
* 只读采集为主;唯一的写操作 cleanRuntime() 只删 runtime/cache 与 runtime/temp,
|
||||
* 绝不碰 runtime/log(审计日志要留着)与业务文件。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class SystemInfo
|
||||
{
|
||||
/**
|
||||
* 采集环境 / 目录 / 磁盘 / 统计信息
|
||||
*
|
||||
* 任何一项失败都不抛异常(巡检页不能因为一张表缺失就 500)。
|
||||
*
|
||||
* @return array{env:array,dirs:array,disk:array,stat:array}
|
||||
*/
|
||||
public static function collect(): array
|
||||
{
|
||||
$root = app()->getRootPath();
|
||||
|
||||
/* ---------------- 运行环境 ---------------- */
|
||||
$mysql = '-';
|
||||
try {
|
||||
$ver = Db::query('SELECT VERSION() AS v');
|
||||
$mysql = $ver[0]['v'] ?? '-';
|
||||
} catch (\Throwable $e) {
|
||||
$mysql = '读取失败';
|
||||
}
|
||||
|
||||
$disable = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
|
||||
$env = [
|
||||
['YwxApp 版本', (string) config('ywxapp.version', '-')],
|
||||
['ThinkPHP 版本', app()->version()],
|
||||
['PHP 版本', PHP_VERSION . '(' . PHP_SAPI . ')'],
|
||||
['MySQL 版本', $mysql],
|
||||
['操作系统', PHP_OS . ' ' . php_uname('r')],
|
||||
['服务器时间', date('Y-m-d H:i:s') . '(' . (string) config('app.default_timezone', date_default_timezone_get()) . ')'],
|
||||
['上传限制', ini_get('upload_max_filesize') . ' / POST ' . ini_get('post_max_size')],
|
||||
['内存限制', ini_get('memory_limit')],
|
||||
['脚本超时', ini_get('max_execution_time') . ' 秒'],
|
||||
['禁用函数', $disable ? implode(', ', array_slice($disable, 0, 6)) . (count($disable) > 6 ? ' …' : '') : '无'],
|
||||
];
|
||||
|
||||
/* ---------------- 目录可写 ---------------- */
|
||||
$dirs = [
|
||||
'runtime' => $root . 'runtime',
|
||||
'public/storage' => public_path() . 'storage',
|
||||
'config' => $root . 'config',
|
||||
'addon' => $root . 'addon',
|
||||
];
|
||||
$dirList = [];
|
||||
foreach ($dirs as $label => $path) {
|
||||
$dirList[] = [
|
||||
'name' => $label,
|
||||
'exists' => is_dir($path),
|
||||
'writable' => is_dir($path) && is_writable($path),
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------- 磁盘 ---------------- */
|
||||
$diskTotal = @disk_total_space($root) ?: 0;
|
||||
$diskFree = @disk_free_space($root) ?: 0;
|
||||
$disk = [
|
||||
'total' => $diskTotal,
|
||||
'free' => $diskFree,
|
||||
'used' => $diskTotal - $diskFree,
|
||||
'total_text' => self::formatBytes($diskTotal),
|
||||
'free_text' => self::formatBytes($diskFree),
|
||||
'used_text' => self::formatBytes($diskTotal - $diskFree),
|
||||
'percent' => $diskTotal > 0 ? round(($diskTotal - $diskFree) / $diskTotal * 100, 1) : 0,
|
||||
];
|
||||
|
||||
/* ---------------- 运行统计 ---------------- */
|
||||
$dbSize = 0;
|
||||
try {
|
||||
foreach ((new DbBackup())->tables() as $t) {
|
||||
$dbSize += (int) ($t['size'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 统计失败不阻断
|
||||
}
|
||||
$attachSize = 0;
|
||||
try {
|
||||
$attachSize = (int) CommonAttachment::where('delete_at', 0)->sum('size');
|
||||
} catch (\Throwable $e) {
|
||||
$attachSize = 0;
|
||||
}
|
||||
|
||||
$stat = [
|
||||
'admin' => self::safeCount('backend_admin'),
|
||||
'attachment' => self::safeCount('common_attachment', function ($q) {
|
||||
$q->where('delete_at', 0);
|
||||
}),
|
||||
'attach_size' => $attachSize,
|
||||
'attach_size_text' => self::formatBytes($attachSize),
|
||||
'log_week' => self::safeCount('backend_log', function ($q) {
|
||||
$q->where('create_at', '>', time() - 7 * 86400);
|
||||
}),
|
||||
'login_fail' => self::safeCount('backend_log', function ($q) {
|
||||
$q->where('action', '登录失败')->where('create_at', '>', time() - 7 * 86400);
|
||||
}),
|
||||
'db_size' => $dbSize,
|
||||
'db_size_text' => self::formatBytes($dbSize),
|
||||
'addon' => count(self::addonDirs()),
|
||||
];
|
||||
|
||||
return [
|
||||
'env' => $env,
|
||||
'dirs' => $dirList,
|
||||
'disk' => $disk,
|
||||
'stat' => $stat,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 runtime 缓存:cache / temp / all(不动 log)
|
||||
*
|
||||
* @param string $type
|
||||
* @return int 删除项数
|
||||
* @throws \InvalidArgumentException 未知类型
|
||||
*/
|
||||
public static function cleanRuntime(string $type): int
|
||||
{
|
||||
$targets = [
|
||||
'cache' => ['cache'], // 数据缓存
|
||||
'temp' => ['temp'], // 模板编译缓存
|
||||
'all' => ['cache', 'temp'],
|
||||
];
|
||||
if (! isset($targets[$type])) {
|
||||
throw new \InvalidArgumentException('未知清理类型:' . $type);
|
||||
}
|
||||
|
||||
$root = runtime_path();
|
||||
$files = 0;
|
||||
foreach ($targets[$type] as $dir) {
|
||||
$files += self::clearDir($root . $dir);
|
||||
}
|
||||
|
||||
// 路由缓存文件在 runtime 根目录,清 all 时一并删掉(下次请求自动重建)
|
||||
if ($type === 'all') {
|
||||
$routeCache = $root . 'route_list.php';
|
||||
if (is_file($routeCache)) {
|
||||
@unlink($routeCache);
|
||||
$files++;
|
||||
}
|
||||
}
|
||||
|
||||
AdminLogger::write('console/clean', '清理类型:' . $type . ',删除 ' . $files . ' 项');
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全计数:表缺失 / 查询异常时返回 0,不阻断巡检页
|
||||
*
|
||||
* @param string $table
|
||||
* @param callable|null $where
|
||||
* @return int
|
||||
*/
|
||||
public static function safeCount(string $table, callable $where = null): int
|
||||
{
|
||||
try {
|
||||
$q = Db::name($table);
|
||||
if ($where) {
|
||||
$where($q);
|
||||
}
|
||||
return (int) $q->count();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件目录清单(一级目录即插件名)
|
||||
* @return array
|
||||
*/
|
||||
public static function addonDirs(): array
|
||||
{
|
||||
$base = app()->getRootPath() . 'addon';
|
||||
if (! is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
$dirs = glob($base . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [];
|
||||
return array_map('basename', $dirs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录下的内容(保留目录本身)
|
||||
*
|
||||
* @param string $dir
|
||||
* @return int 删除项数
|
||||
*/
|
||||
protected static function clearDir(string $dir): int
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
if ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
} else {
|
||||
@unlink($item->getPathname());
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数格式化
|
||||
*
|
||||
* 注意:disk_total_space() 返回 float,参数必须收 float,
|
||||
* 否则严格类型下直接 TypeError(页面 500)。
|
||||
*
|
||||
* @param float $bytes
|
||||
* @return string
|
||||
*/
|
||||
public static function formatBytes(float $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,11 @@
|
||||
|
||||
declare (strict_types = 1);
|
||||
return [
|
||||
\think\middleware\AllowCrossDomain::class,
|
||||
// 后台管理应用中间件。
|
||||
// 安全策略:不注册任何跨域中间件(后台零跨域,纯同源访问);
|
||||
// CSRF 同源校验见 ywxapp\middleware\VerifyCsrfToken + config/csrf.php。
|
||||
\ywxapp\middleware\VerifyCsrfToken::class,
|
||||
// 表结构兜底:每个 /admin/* 请求先把后台 6 张核心表(admin/log/power/role/role_access/role_power)
|
||||
// 一次性补齐,避免老库缺表首次撞 1146。对称 appmall 的 AppmallSchemaMiddleware。
|
||||
\app\backend\middleware\BackendSchemaMiddleware::class,
|
||||
];
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp <admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\backend\middleware;
|
||||
|
||||
use ywxapp\model\BackendAdmin;
|
||||
|
||||
/**
|
||||
* 后台「表结构兜底」中间件(应用级注册于 app/backend/middleware.php)。
|
||||
*
|
||||
* 作用:任何进入 /admin/* 的请求(登录页 / 后台首页 / 后台 API / 未注册路由)
|
||||
* 都先把后台 6 张核心表
|
||||
* (backend_admin / backend_log / backend_power / backend_role /
|
||||
* backend_role_access / backend_role_power)
|
||||
* 一次性补齐,杜绝「线上库缺表 / 老库缺字段 → 1146 Table doesn't exist /
|
||||
* 1054 Unknown column」首次撞错的问题。
|
||||
*
|
||||
* 设计要点(与 AppmallSchemaMiddleware 对称):
|
||||
* - 幂等且极轻:BaseModel::ensureTableFromInstall() 内部已做进程内 memo,
|
||||
* 每请求每表最多一次 SHOW TABLES,已建好的库几乎零开销;
|
||||
* - 无版本号:不做版本比对、不需要迁移脚本,纯探测式兜底;
|
||||
* - 绝不阻断业务:DB 未连接、账号无 CREATE 权限等异常一律静默吞掉,
|
||||
* 由后续业务报错暴露(与框架既有自愈引擎的降级策略一致)。
|
||||
*
|
||||
* 调用点:BackendAdmin::ensureSchema() 已一站式覆盖全部 6 张表(见该方法循环),
|
||||
* 无需在每个 backend 模型里反复调,确保「一次请求一个兜底入口」。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class BackendSchemaMiddleware
|
||||
{
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
try {
|
||||
BackendAdmin::ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
// 自愈失败不影响本次请求,由后续业务报错暴露
|
||||
}
|
||||
|
||||
// appmall 菜单兜底:模块迁出 addon/ 后 createMenu 不再执行,
|
||||
// 这里随 /admin/* 请求幂等注入「应用市场」侧边栏菜单(客户机自动跳过,
|
||||
// 菜单清单内置于 AppmallMenu 常量)。class_exists 守卫:
|
||||
// 万一未来移除 appmall 应用,不影响后台。
|
||||
try {
|
||||
if (class_exists(\app\appmall\library\AppmallMenu::class)) {
|
||||
\app\appmall\library\AppmallMenu::ensure();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 菜单兜底失败不影响本次请求
|
||||
}
|
||||
|
||||
// 新增后台菜单自愈:与上面 appmall 菜单同构。
|
||||
// 关键:菜单只靠「访问页面时补录」是补不上的(菜单不存在 ⇒ 点不进去 ⇒ 永不触发),
|
||||
// 所以放在请求级:database / adminlog / attachment / system 四个入口随请求出现在侧边栏。
|
||||
try {
|
||||
\app\backend\library\MenuRegistry::ensure();
|
||||
} catch (\Throwable $e) {
|
||||
// 菜单补录失败不影响本次请求
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,42 @@ Route::group('navbar', function () {
|
||||
Route::post('delete', 'Navbar/delete'); // 删除(兼容)
|
||||
Route::patch('status/:id/:status', 'Navbar/status'); // 状态切换
|
||||
});
|
||||
|
||||
// 数据库备份 / 还原
|
||||
Route::group('database', function () {
|
||||
Route::get('index', 'Database/index'); // 列表(表 / 备份记录)
|
||||
Route::post('backup', 'Database/backup'); // 备份
|
||||
Route::post('recovery', 'Database/recovery'); // 还原(避开基类 restore)
|
||||
Route::post('delete', 'Database/delete'); // 删除备份
|
||||
Route::post('optimize', 'Database/optimize'); // 优化表
|
||||
Route::get('download', 'Database/download'); // 下载备份
|
||||
});
|
||||
|
||||
// 数据表管理(结构层面:可视化建表 / 查看结构 / 优化 / 删表)
|
||||
Route::group('datatable', function () {
|
||||
Route::get('index', 'Datatable/index'); // 表列表
|
||||
Route::get('structure', 'Datatable/structure'); // 表结构(字段 / 索引 / 建表语句)
|
||||
Route::post('preview', 'Datatable/preview'); // 生成建表 SQL(不执行)
|
||||
Route::post('create', 'Datatable/create'); // 执行建表
|
||||
Route::post('drop', 'Datatable/drop'); // 删表(需回传表名二次确认)
|
||||
Route::post('optimize', 'Datatable/optimize'); // 优化表
|
||||
Route::post('export', 'Datatable/export'); // 导出安装 SQL(回写 install.sql 用,不执行)
|
||||
});
|
||||
|
||||
// 操作日志
|
||||
Route::group('adminlog', function () {
|
||||
Route::get('index', 'Adminlog/index'); // 日志列表
|
||||
Route::post('delete', 'Adminlog/delete'); // 删除日志
|
||||
Route::post('clean', 'Adminlog/clean'); // 按保留天数清理
|
||||
});
|
||||
|
||||
// 清理缓存:「系统信息」页已下线,能力收进控制台首页(Console::clean)
|
||||
Route::group('console', function () {
|
||||
Route::post('clean', 'Console/clean'); // 清理 runtime 缓存
|
||||
});
|
||||
|
||||
// 附件统一管理
|
||||
Route::group('attachment', function () {
|
||||
Route::get('index', 'Attachment/index'); // 全站附件列表
|
||||
Route::post('delete', 'Attachment/delete'); // 删除(物理文件 + 软删记录)
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-04-29 02:32:33
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-09-28 00:00:00
|
||||
* @Description: 权限节点(backend_power)校验
|
||||
* @FilePath: \ywxapp_dev\app\backend\validate\Power.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\backend\validate;
|
||||
|
||||
use think\Validate;
|
||||
use ywxapp\model\BackendPower;
|
||||
|
||||
/**
|
||||
* Power 校验器
|
||||
*
|
||||
* 控制器 Power::save() / update() 会 validate(Power::class),
|
||||
* 此前该校验器缺失 ⇒ 新增 / 编辑权限节点直接报类不存在。
|
||||
*
|
||||
* 关于 route:type=1 目录 与 type=5 分组标题 不需要路由(分组标题只作侧边栏视觉分组),
|
||||
* 故不做 require,仅限制长度。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Power extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'title' => 'require|max:50',
|
||||
'name' => 'require|max:100',
|
||||
'type' => 'require|checkType',
|
||||
'pid' => 'number',
|
||||
'route' => 'max:100',
|
||||
'icon' => 'max:100',
|
||||
'sort' => 'number',
|
||||
'status' => 'in:0,1',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'title.require' => '节点名称不能为空',
|
||||
'title.max' => '节点名称不能超过50个字符',
|
||||
'name.require' => '节点标识不能为空',
|
||||
'name.max' => '节点标识不能超过100个字符',
|
||||
'type.require' => '请选择节点类型',
|
||||
'type.checkType' => '节点类型只能是 目录/菜单/按钮/API/分组标题',
|
||||
'pid.number' => '上级节点不正确',
|
||||
'route.max' => '路由不能超过100个字符',
|
||||
'icon.max' => '图标类名不能超过100个字符',
|
||||
'sort.number' => '排序必须是数字',
|
||||
'status.in' => '状态值不正确',
|
||||
];
|
||||
|
||||
/**
|
||||
* 更新场景:必须带 id
|
||||
*/
|
||||
public function sceneUpdate()
|
||||
{
|
||||
return $this->append('id', 'require|number');
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型必须是已定义的类型(与 BackendPower 常量保持同步)
|
||||
*/
|
||||
public function checkType($value, $rule, $data = []): bool|string
|
||||
{
|
||||
$allowed = [
|
||||
BackendPower::TYPE_DIR,
|
||||
BackendPower::TYPE_MENU,
|
||||
BackendPower::TYPE_BUTTON,
|
||||
BackendPower::TYPE_API,
|
||||
BackendPower::TYPE_GROUP_TITLE,
|
||||
];
|
||||
return in_array((int) $value, $allowed, true) ?: '节点类型不正确';
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,38 @@ use think\Validate;
|
||||
class Score extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'name' => 'require|max:100',
|
||||
'action' => 'max:50',
|
||||
'type' => 'require|in:1,2',
|
||||
'value' => 'require|integer|gt:0',
|
||||
'sort' => 'number',
|
||||
'status' => 'in:0,1',
|
||||
'title' => 'require|max:100',
|
||||
'action' => 'require|max:50',
|
||||
'type' => 'require|in:1,2',
|
||||
'value' => 'require|integer|gt:0',
|
||||
'addon' => 'max:50',
|
||||
'limit_type' => 'in:0,1,2,3,4',
|
||||
'limit_times' => 'integer|egt:0',
|
||||
'unique_type' => 'in:0,1',
|
||||
'remark' => 'max:255',
|
||||
'sort' => 'number',
|
||||
'status' => 'in:0,1',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'name.require' => '请输入规则名称',
|
||||
'name.max' => '规则名称最多100个字符',
|
||||
'type.require' => '请选择规则类型',
|
||||
'type.in' => '规则类型只能是获取或消费',
|
||||
'value.require' => '请输入变动值',
|
||||
'value.integer' => '变动值必须是整数',
|
||||
'value.gt' => '变动值必须大于0',
|
||||
'title.require' => '请输入规则名称',
|
||||
'title.max' => '规则名称最多100个字符',
|
||||
'action.require' => '请输入触发动作标识',
|
||||
'action.max' => '触发动作最多50个字符',
|
||||
'type.require' => '请选择规则类型',
|
||||
'type.in' => '规则类型只能是获取或消费',
|
||||
'value.require' => '请输入变动值',
|
||||
'value.integer' => '变动值必须是整数',
|
||||
'value.gt' => '变动值必须大于0',
|
||||
'addon.max' => '插件标识最多50个字符',
|
||||
'limit_type.in' => '限次周期只能是 0不限/1每日/2每周/3每月/4累计',
|
||||
'limit_times.integer' => '限次次数必须是整数',
|
||||
'limit_times.egt' => '限次次数不能为负数',
|
||||
'unique_type.in' => '去重方式只能是 0不去重/1按来源业务ID',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['name', 'action', 'type', 'value', 'sort', 'status'],
|
||||
'edit' => ['name', 'action', 'type', 'value', 'sort', 'status'],
|
||||
'add' => ['title', 'action', 'type', 'value', 'addon', 'limit_type', 'limit_times', 'unique_type', 'remark', 'sort', 'status'],
|
||||
'edit' => ['title', 'action', 'type', 'value', 'addon', 'limit_type', 'limit_times', 'unique_type', 'remark', 'sort', 'status'],
|
||||
];
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace app\backend\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class Shop extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'title' => 'require|max:200',
|
||||
'price' => 'float',
|
||||
'stock' => 'number',
|
||||
'sort' => 'number',
|
||||
'status' => 'in:0,1',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['title', 'price', 'stock', 'description', 'sort', 'status'],
|
||||
'edit' => ['title', 'price', 'stock', 'description', 'sort', 'status'],
|
||||
];
|
||||
}
|
||||
@@ -68,8 +68,20 @@
|
||||
|
||||
<!-- 右侧操作列模板 (配置/卸载) -->
|
||||
|
||||
<!-- 版本号列:可升级时附带市场最新版本徽章 -->
|
||||
<script type="text/html" id="versionTpl">
|
||||
{{= d.version || '-' }}
|
||||
{{# if(d.upgradable){ }}
|
||||
<span class="layui-badge layui-bg-orange" style="margin-left:4px;"
|
||||
title="市场最新版本 {{= d.latest_version }}">可升级 {{= d.latest_version }}</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="operateTpl">
|
||||
<div class="layui-clear-space">
|
||||
{{# if(d.upgradable){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="upgrade" data-perm="addon:edit">升级</a>
|
||||
{{# } }}
|
||||
{{# if(d.hasConfig){ }}
|
||||
<a class="layui-btn layui-btn-xs" lay-event="config" data-perm="addon:config"><i class="layui-icon layui-icon-set"></i> 配置</a>
|
||||
{{# } }}
|
||||
|
||||
@@ -97,6 +97,32 @@
|
||||
{/volist}
|
||||
{elseif $f.type == 'number' /}
|
||||
<input type="number" name="{$f.name}" value="{$f.value}" class="layui-input">
|
||||
{elseif $f.type == 'switch' /}
|
||||
<!-- 隐藏域保证「关闭」也能提交 0(layui switch 未勾选时不提交) -->
|
||||
<input type="hidden" name="{$f.name}" value="0">
|
||||
<input type="checkbox" name="{$f.name}" value="1" lay-skin="switch" lay-text="开|关" {if $f.value}checked{/if}>
|
||||
{elseif $f.type == 'array' /}
|
||||
<textarea name="{$f.name}" class="layui-textarea" rows="5"
|
||||
placeholder="每行一条,格式:键=值(不写等号则作为列表项)">{$f.value}</textarea>
|
||||
{elseif $f.type == 'date' /}
|
||||
<input type="text" name="{$f.name}" value="{$f.value}" class="layui-input cfg-date"
|
||||
placeholder="YYYY-MM-DD" autocomplete="off">
|
||||
{elseif $f.type == 'image' /}
|
||||
<input type="text" name="{$f.name}" value="{$f.value}" class="layui-input" placeholder="图片地址">
|
||||
{if $f.value}
|
||||
<div style="margin-top:6px;"><img src="{$f.value}" style="max-height:80px;"></div>
|
||||
{/if}
|
||||
{elseif $f.type == 'file' /}
|
||||
<input type="text" name="{$f.name}" value="{$f.value}" class="layui-input" placeholder="文件路径或 URL">
|
||||
{elseif $f.type == 'color' /}
|
||||
<input type="color" name="{$f.name}" value="{$f.value|default='#1E9FFF'}" class="layui-input"
|
||||
style="width:80px;padding:2px;height:38px;">
|
||||
{elseif $f.type == 'tags' /}
|
||||
<input type="text" name="{$f.name}" value="{$f.value}" class="layui-input" placeholder="多个用逗号分隔">
|
||||
{elseif $f.type == 'password' /}
|
||||
<input type="password" name="{$f.name}" value="{$f.value}" class="layui-input" autocomplete="new-password">
|
||||
{elseif $f.type == 'editor' /}
|
||||
<textarea name="{$f.name}" class="layui-textarea cfg-editor" rows="8" placeholder="支持 HTML">{$f.value}</textarea>
|
||||
{else /}
|
||||
<input type="text" name="{$f.name}" value="{$f.value}" class="layui-input">
|
||||
{/if}
|
||||
@@ -114,12 +140,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<!-- 本页是独立页(不走 layout),必须自己引入 ywxapp.js 才能用集成的 http -->
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function () {
|
||||
var form = layui.form, $ = layui.jquery, layer = layui.layer;
|
||||
layui.use(['form', 'layer', 'ywxapp', 'http', 'laydate'], function () {
|
||||
var form = layui.form, $ = layui.jquery, layer = layui.layer, http = layui.http, laydate = layui.laydate;
|
||||
// date 型字段:逐个绑定日期选择(laydate 的 class 选择器只渲染首个,故遍历 DOM)
|
||||
Array.prototype.forEach.call(document.querySelectorAll('input.cfg-date'), function (el) {
|
||||
laydate.render({ elem: el, type: 'date' });
|
||||
});
|
||||
form.on('submit(cfgForm)', function (data) {
|
||||
$.post(window.location.href, data.field, function (r) {
|
||||
if (r.code === 0) {
|
||||
http.post(window.location.href, data.field).then(function (r) {
|
||||
if (r && r.code === 0) {
|
||||
layer.msg(r.message || '保存成功', { icon: 1 });
|
||||
// 保存成功后自动跳回插件列表(兼容 layui admin 的 tab 场景),
|
||||
// 避免停留在配置 tab 需要手动关闭重开才能看到列表。
|
||||
@@ -127,9 +159,11 @@
|
||||
window.location.href = '{:url("addon/index")}';
|
||||
}, 800);
|
||||
} else {
|
||||
layer.msg(r.message || '保存失败', { icon: 2 });
|
||||
layer.msg((r && r.message) || '保存失败', { icon: 2 });
|
||||
}
|
||||
}, 'json');
|
||||
}).catch(function (err) {
|
||||
layer.msg('请求失败:' + (err && err.message ? err.message : '网络异常'), { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
</div>
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="新建节点" lay-event="dataCreate"> <i class="layui-icon layui-icon-add-1"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除资源" lay-event="dataDelete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="资源回收站" lay-event="dataRecybin"><i class="layui-icon layui-icon-home"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="新建节点" lay-event="dataCreate" data-perm="admin:create"> <i class="layui-icon layui-icon-add-1"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除资源" lay-event="dataDelete" data-perm="admin:delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="资源回收站" lay-event="dataRecybin" data-perm="admin:index"><i class="layui-icon layui-icon-home"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataBar">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<div class="layui-fluid"><div class="layui-card">
|
||||
<div class="layui-card-header">操作日志</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-form" lay-filter="search-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="keyword" placeholder="账号 / 昵称 / IP / 备注" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<select name="action">
|
||||
<option value="">全部动作</option>
|
||||
{foreach $actions as $act}
|
||||
<option value="{$act.action}">{$act.action}({$act.total})</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="start" id="start" placeholder="开始日期" autocomplete="off" class="layui-input" style="width:120px">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="end" id="end" placeholder="结束日期" autocomplete="off" class="layui-input" style="width:120px">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn">
|
||||
<i class="layui-icon layui-icon-search"></i> 搜索
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-primary" id="btn-reset">
|
||||
<i class="layui-icon layui-icon-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="deleteSelected" data-perm="adminlog:delete">
|
||||
<i class="layui-icon layui-icon-delete"></i> 删除选中
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-warm" lay-event="clean" data-perm="adminlog:clean">
|
||||
<i class="layui-icon layui-icon-clear"></i> 按天数清理
|
||||
</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="dataBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete" data-perm="adminlog:delete">删除</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['adminlog'], function () {});
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-sm4">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<div style="color:#666">附件总数</div>
|
||||
<div style="font-size:26px;font-weight:600">{$stat.total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm4">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<div style="color:#666">占用空间</div>
|
||||
<div style="font-size:26px;font-weight:600">{$stat.size_text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm4">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<div style="color:#666">图片</div>
|
||||
<div style="font-size:26px;font-weight:600">{$stat.image}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">附件管理</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-form" lay-filter="search-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="keyword" placeholder="文件名 / 路径" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<select name="module">
|
||||
<option value="">全部模块</option>
|
||||
{foreach $modules as $m}
|
||||
<option value="{$m.module}">{$m.module}({$m.total})</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<input type="checkbox" name="only_image" value="1" title="只看图片" lay-skin="primary">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn">
|
||||
<i class="layui-icon layui-icon-search"></i> 搜索
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-primary" id="btn-reset">
|
||||
<i class="layui-icon layui-icon-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="deleteSelected" data-perm="attachment:delete">
|
||||
<i class="layui-icon layui-icon-delete"></i> 删除选中
|
||||
</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="dataBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-xs layui-btn-normal" lay-event="open">查看</button>
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete" data-perm="attachment:delete">删除</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="thumbTpl">
|
||||
{{# if (d.is_image == 1) { }}
|
||||
<img src="{{= d.url }}" alt="" style="width:40px;height:40px;object-fit:cover;cursor:pointer" lay-event="open">
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-gray">{{= d.ext }}</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['attachment'], function () {});
|
||||
</script>
|
||||
@@ -69,13 +69,17 @@
|
||||
app: "{$site.app}",
|
||||
routeBase: "{$route_base}",
|
||||
controller: "{$site.controller}",
|
||||
action: "{$site.action}"
|
||||
action: "{$site.action}",
|
||||
powers: {:json_encode($powers ?? [])}
|
||||
};
|
||||
</script>
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
|
||||
{:hook('backend.layout.head', ['site' => $site ?? []])}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{:hook('backend.layout.body_start', ['site' => $site ?? []])}
|
||||
{__CONTENT__}
|
||||
{:hook('backend.layout.body_end', ['site' => $site ?? []])}
|
||||
</body>
|
||||
</html>
|
||||
+217
-217
@@ -1,17 +1,148 @@
|
||||
<div class="layui-fluid">
|
||||
|
||||
<!-- 系统概览:与「系统信息」页同源(app\backend\library\SystemInfo),控制台只做展示 -->
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-body">
|
||||
<div style="color:#666">管理员</div>
|
||||
<div style="font-size:24px;font-weight:600">{$sys.stat.admin}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-body">
|
||||
<div style="color:#666">插件</div>
|
||||
<div style="font-size:24px;font-weight:600">{$sys.stat.addon}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-body">
|
||||
<div style="color:#666">附件</div>
|
||||
<div style="font-size:24px;font-weight:600">{$sys.stat.attachment}</div>
|
||||
<div style="color:#999;font-size:12px">占用 {$sys.stat.attach_size_text}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-body">
|
||||
<div style="color:#666">数据库</div>
|
||||
<div style="font-size:24px;font-weight:600">{$sys.stat.db_size_text}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md6">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">运行环境</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table layui-table-sm" lay-skin="nob">
|
||||
<tbody>
|
||||
{foreach $sys.env as $row}
|
||||
<tr>
|
||||
<td style="width:120px;color:#666">{$row[0]}</td>
|
||||
<td>{$row[1]}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">磁盘</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-progress layui-progress-big" lay-showPercent="true">
|
||||
<div class="layui-progress-bar" style="width:{$sys.disk.percent}%"></div>
|
||||
</div>
|
||||
<div style="margin-top:10px;color:#666">
|
||||
已用 {$sys.disk.used_text} / 共 {$sys.disk.total_text},剩余 {$sys.disk.free_text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-col-md6">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">目录权限</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table layui-table-sm" lay-skin="nob">
|
||||
<thead><tr><th>目录</th><th>存在</th><th>可写</th></tr></thead>
|
||||
<tbody>
|
||||
{foreach $sys.dirs as $d}
|
||||
<tr>
|
||||
<td>{$d.name}</td>
|
||||
<td>{if $d.exists}<span class="layui-badge layui-bg-green">是</span>{else}<span class="layui-badge">否</span>{/if}</td>
|
||||
<td>{if $d.writable}<span class="layui-badge layui-bg-green">可写</span>{else}<span class="layui-badge layui-bg-red">不可写</span>{/if}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:#999;font-size:12px">不可写的目录会导致缓存无法生成、上传失败、插件无法安装。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">维护</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-normal" id="sys-clean-cache" data-perm="console:clean">清理数据缓存</button>
|
||||
<button class="layui-btn" id="sys-clean-temp" data-perm="console:clean">清理模板缓存</button>
|
||||
<button class="layui-btn layui-btn-warm" id="sys-clean-all" data-perm="console:clean">全量清理</button>
|
||||
</div>
|
||||
<div style="margin-top:10px;color:#999;font-size:12px">
|
||||
只清理 runtime/cache 与 runtime/temp;<strong>不会删除 runtime/log</strong>(操作日志与错误日志保留)。
|
||||
</div>
|
||||
<div style="margin-top:12px;color:#666">
|
||||
近 7 天操作 <strong>{$sys.stat.log_week}</strong> 条,其中登录失败 <strong style="color:#ff5722">{$sys.stat.login_fail}</strong> 次。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 框架升级:与「系统管理 → 框架升级」同源,共用 /admin/framework/check|upgrade -->
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">框架升级</div>
|
||||
<div class="layui-card-body">
|
||||
<p style="margin-bottom:8px">当前版本:<span class="layui-badge layui-bg-blue" id="fw-current">{$version}</span></p>
|
||||
<div id="fw-status" class="layui-text" style="margin:10px 0;min-height:24px;"></div>
|
||||
|
||||
<div id="fw-pkg-box" style="display:none;margin:10px 0;padding:10px 12px;background:#fafafa;border:1px solid #eee;border-radius:4px;">
|
||||
<div style="margin-bottom:8px;color:#555;">升级包类型:</div>
|
||||
<div class="layui-btn-group" id="fw-pkg-group">
|
||||
<button type="button" class="layui-btn layui-btn-sm layui-btn-normal fw-pkg" data-type="auto">自动(推荐)</button>
|
||||
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary fw-pkg" data-type="full">完整包</button>
|
||||
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary fw-pkg" data-type="patch">增量小包</button>
|
||||
</div>
|
||||
<div id="fw-pkg-tip" style="margin-top:8px;color:#e67700;font-size:13px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-normal" id="fw-check" data-perm="framework:check">
|
||||
<i class="layui-icon layui-icon-refresh"></i> 检查更新
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-danger" id="fw-upgrade" data-perm="framework:upgrade" style="display:none;">
|
||||
<i class="layui-icon layui-icon-release"></i> 立即升级
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top:10px;color:#999;font-size:12px">
|
||||
升级前会自动备份当前核心文件;本卡片与「系统管理 → 框架升级」是同一套接口与权限。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
访问量
|
||||
<span class="layui-badge layui-bg-blue layuiadmin-badge">周</span>
|
||||
会员总数
|
||||
<span class="layui-badge layui-bg-blue layuiadmin-badge">总</span>
|
||||
</div>
|
||||
<div class="layui-card-body layuiadmin-card-list">
|
||||
<p class="layuiadmin-big-font">9,999,888</p>
|
||||
<p class="layuiadmin-big-font">{$stats.total.member}</p>
|
||||
<p>
|
||||
总计访问量
|
||||
<span class="layuiadmin-span-color">88万 <i class="layui-inline layui-icon layui-icon-flag"></i></span>
|
||||
近 7 天新增
|
||||
<span class="layuiadmin-span-color">{$stats.total.member_week} 人 <i class="layui-inline layui-icon layui-icon-user"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,14 +150,14 @@
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
下载
|
||||
<span class="layui-badge layui-bg-cyan layuiadmin-badge">月</span>
|
||||
今日新增会员
|
||||
<span class="layui-badge layui-bg-green layuiadmin-badge">日</span>
|
||||
</div>
|
||||
<div class="layui-card-body layuiadmin-card-list">
|
||||
<p class="layuiadmin-big-font">33,555</p>
|
||||
<p class="layuiadmin-big-font">{$stats.today.member}</p>
|
||||
<p>
|
||||
新下载
|
||||
<span class="layuiadmin-span-color">10% <i class="layui-inline layui-icon layui-icon-face-smile-b"></i></span>
|
||||
会员表实时统计
|
||||
<span class="layuiadmin-span-color">今日 <i class="layui-inline layui-icon layui-icon-friends"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,15 +165,14 @@
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
点赞
|
||||
<span class="layui-badge layui-bg-green layuiadmin-badge">年</span>
|
||||
今日操作
|
||||
<span class="layui-badge layui-bg-cyan layuiadmin-badge">日</span>
|
||||
</div>
|
||||
<div class="layui-card-body layuiadmin-card-list">
|
||||
|
||||
<p class="layuiadmin-big-font">999,888</p>
|
||||
<p class="layuiadmin-big-font">{$stats.today.log}</p>
|
||||
<p>
|
||||
总赞
|
||||
<span class="layuiadmin-span-color">*** <i class="layui-inline layui-icon layui-icon-heart"></i></span>
|
||||
后台操作记录
|
||||
<span class="layuiadmin-span-color">累计 {$stats.total.log} <i class="layui-inline layui-icon layui-icon-log"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,15 +180,14 @@
|
||||
<div class="layui-col-sm6 layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
活跃用户
|
||||
<span class="layui-badge layui-bg-orange layuiadmin-badge">月</span>
|
||||
今日蜘蛛来访
|
||||
<span class="layui-badge layui-bg-orange layuiadmin-badge">日</span>
|
||||
</div>
|
||||
<div class="layui-card-body layuiadmin-card-list">
|
||||
|
||||
<p class="layuiadmin-big-font">66,888</p>
|
||||
<p class="layuiadmin-big-font">{$stats.today.spider}</p>
|
||||
<p>
|
||||
最近一个月
|
||||
<span class="layuiadmin-span-color">15% <i class="layui-inline layui-icon layui-icon-user"></i></span>
|
||||
搜索引擎抓取
|
||||
<span class="layuiadmin-span-color">累计 {$stats.total.spider} <i class="layui-inline layui-icon layui-icon-chart-screen"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,229 +195,78 @@
|
||||
<div class="layui-col-sm12">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
访问量
|
||||
<div class="layui-btn-group layuiadmin-btn-group">
|
||||
<a href="javascript:;" class="layui-btn layui-btn-primary layui-btn-xs">去年</a>
|
||||
<a href="javascript:;" class="layui-btn layui-btn-primary layui-btn-xs">今年</a>
|
||||
</div>
|
||||
近 7 天趋势
|
||||
<span class="layui-badge layui-bg-blue layuiadmin-badge">会员注册 / 后台操作</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-row">
|
||||
<div class="layui-col-sm8">
|
||||
<div class="layui-carousel layadmin-carousel layadmin-dataview" data-anim="fade" lay-filter="LAY-index-pagetwo">
|
||||
<div carousel-item id="LAY-index-pagetwo">
|
||||
<div><i class="layui-icon layui-icon-loading1 layadmin-loading"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm4">
|
||||
<div class="layuiadmin-card-list">
|
||||
<p class="layuiadmin-normal-font">月访问数</p>
|
||||
<span>同上期增长</span>
|
||||
<div class="layui-progress layui-progress-big" lay-showPercent="yes">
|
||||
<div class="layui-progress-bar" lay-percent="30%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layuiadmin-card-list">
|
||||
<p class="layuiadmin-normal-font">月下载数</p>
|
||||
<span>同上期增长</span>
|
||||
<div class="layui-progress layui-progress-big" lay-showPercent="yes">
|
||||
<div class="layui-progress-bar" lay-percent="20%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layuiadmin-card-list">
|
||||
<p class="layuiadmin-normal-font">月点赞数</p>
|
||||
<span>同上期增长</span>
|
||||
<div class="layui-progress layui-progress-big" lay-showPercent="yes">
|
||||
<div class="layui-progress-bar" lay-percent="25%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-carousel layadmin-carousel layadmin-dataview" data-anim="fade" lay-filter="LAY-index-trendview">
|
||||
<div carousel-item id="LAY-index-trend">
|
||||
<div><i class="layui-icon layui-icon-loading1 layadmin-loading"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm4">
|
||||
<div class="layui-col-sm6">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">用户留言</div>
|
||||
<div class="layui-card-header">最新会员</div>
|
||||
<div class="layui-card-body">
|
||||
<ul class="layuiadmin-card-status layuiadmin-home2-usernote">
|
||||
<li>
|
||||
<h3>张三</h3>
|
||||
<p>哦好吧</p>
|
||||
<span>5月30日 00:00</span>
|
||||
<a href="javascript:;" layadmin-event="replyNote" data-id="7" class="layui-btn layui-btn-xs layuiadmin-reply">回复</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>李白</h3>
|
||||
<p>应是天仙狂醉,乱把白云揉碎</p>
|
||||
<span>5月02日 00:00</span>
|
||||
<a href="javascript:;" layadmin-event="replyNote" data-id="5" class="layui-btn layui-btn-xs layuiadmin-reply">回复</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>张三</h3>
|
||||
<p>嗯嗯</p>
|
||||
<span>5月11日 00:00</span>
|
||||
<a href="javascript:;" layadmin-event="replyNote" data-id="6" class="layui-btn layui-btn-xs layuiadmin-reply">回复</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>杜甫</h3>
|
||||
<p>人才虽高,不务学问,不能致圣。刘向十日画一水,五日画一石。</p>
|
||||
<span>4月11日 00:00</span>
|
||||
<a href="javascript:;" layadmin-event="replyNote" data-id="2" class="layui-btn layui-btn-xs layuiadmin-reply">回复</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>鲁迅</h3>
|
||||
<p>路本是无所谓有和无的,走的人多了,就没路了。。</p>
|
||||
<span>4月28日 00:00</span>
|
||||
<a href="javascript:;" layadmin-event="replyNote" data-id="4" class="layui-btn layui-btn-xs layuiadmin-reply">回复</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>张爱玲</h3>
|
||||
<p>于千万人之中遇到你所要遇到的人,于千万年之中,时间的无涯的荒野中,没有早一步,也没有晚一步,刚巧赶上了,那也没有别的话好说,唯有轻轻的问一声:“噢,原来你也在这里?”</p>
|
||||
<span>4月11日 00:00</span>
|
||||
<a href="javascript:;" layadmin-event="replyNote" data-id="1" class="layui-btn layui-btn-xs layuiadmin-reply">回复</a>
|
||||
</li>
|
||||
</ul>
|
||||
<table class="layui-table layuiadmin-page-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号</th>
|
||||
<th>昵称</th>
|
||||
<th>注册时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{empty name="stats.members"}
|
||||
<tr><td colspan="3" style="text-align:center;color:#999;">暂无数据</td></tr>
|
||||
{/empty}
|
||||
{foreach $stats.members as $m}
|
||||
<tr>
|
||||
<td>{$m.account}</td>
|
||||
<td>{$m.nickname}</td>
|
||||
<td>{$m.create_at|date='m-d H:i'}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm8">
|
||||
<div class="layui-col-sm6">
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-sm6">
|
||||
<div class="layui-col-sm12">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">本周活跃用户列表</div>
|
||||
<div class="layui-card-header">最近操作日志</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table layuiadmin-page-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>最后登录时间</th>
|
||||
<th>状态</th>
|
||||
<th>获得赞</th>
|
||||
<th>管理员</th>
|
||||
<th>操作</th>
|
||||
<th>IP</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{empty name="stats.logs"}
|
||||
<tr><td colspan="4" style="text-align:center;color:#999;">暂无数据</td></tr>
|
||||
{/empty}
|
||||
{foreach $stats.logs as $l}
|
||||
<tr>
|
||||
<td><span class="first">张三</span></td>
|
||||
<td><i class="layui-icon layui-icon-log"> 11:20</i></td>
|
||||
<td><span>在线</span></td>
|
||||
<td>22 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="second">张三</span></td>
|
||||
<td><i class="layui-icon layui-icon-log"> 10:40</i></td>
|
||||
<td><span>在线</span></td>
|
||||
<td>21 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="third">李四</span></td>
|
||||
<td><i class="layui-icon layui-icon-log"> 01:30</i></td>
|
||||
<td><i>离线</i></td>
|
||||
<td>66 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>王五</td>
|
||||
<td><i class="layui-icon layui-icon-log"> 21:18</i></td>
|
||||
<td><i>离线</i></td>
|
||||
<td>45 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>许上进</td>
|
||||
<td><i class="layui-icon layui-icon-log"> 09:30</i></td>
|
||||
<td><span>在线</span></td>
|
||||
<td>21 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>小蚊子</td>
|
||||
<td><i class="layui-icon layui-icon-log"> 21:18</i></td>
|
||||
<td><i>在线</i></td>
|
||||
<td>45 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>张三</td>
|
||||
<td><i class="layui-icon layui-icon-log"> 09:30</i></td>
|
||||
<td><span>在线</span></td>
|
||||
<td>21 <i class="layui-icon layui-icon-praise"></i></td>
|
||||
<td>{$l.admin}</td>
|
||||
<td>{$l.action}</td>
|
||||
<td>{$l.ip}</td>
|
||||
<td>{$l.time|date='m-d H:i'}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm6">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">项目进展</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-tab-content">
|
||||
<div class="layui-tab-item layui-show">
|
||||
<table id="LAY-index-prograss"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-sm12">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">访客地区分布</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-sm4">
|
||||
<table class="layui-table layuiadmin-page-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>地区</th>
|
||||
<th>人数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>浙江</td>
|
||||
<td>62310</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>上海</td>
|
||||
<td>59190</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td>广东</td>
|
||||
<td>55891</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td>北京</td>
|
||||
<td>51919</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>山东</td>
|
||||
<td>39231</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>湖北</td>
|
||||
<td>37109</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="layui-col-sm8">
|
||||
|
||||
<div class="layui-carousel layadmin-carousel layadmin-dataview" data-anim="fade" lay-filter="LAY-index-pagethree">
|
||||
<div carousel-item id="LAY-index-pagethree">
|
||||
<div><i class="layui-icon layui-icon-loading1 layadmin-loading"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,6 +274,28 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/layui/layui.js?t=1"></script>
|
||||
<script type="text/javascript">
|
||||
// 本页会再次加载 layui,覆盖掉 layout 里注入的 layui.app(含当前登录者权限集 powers)。
|
||||
// 必须在这里补回来:auth 模块在加载时读 layui.app.powers,拿不到就会把
|
||||
// 所有带 [data-perm] 的按钮当成无权限移除(控制台的清理按钮会消失)。
|
||||
layui.app = {
|
||||
root: "{$site.root|default=''}",
|
||||
assetUrl: "/assets",
|
||||
module: "{$site.module}",
|
||||
app: "{$site.app}",
|
||||
routeBase: "{$route_base}",
|
||||
controller: "{$site.controller}",
|
||||
action: "{$site.action}",
|
||||
powers: {:json_encode($powers ?? [])}
|
||||
};
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
// 首页下半区真实统计(Console::collectStats 产出),给 console.js 画趋势图用。
|
||||
// json_encode 带 HEX_* 选项:模板 default_filter 会把 <>&'" 转义成实体,
|
||||
// 先转成 \u003C 这类 unicode 才能保证丢到 JS 里仍是合法 JSON。
|
||||
// 第二个参数 15 = JSON_HEX_TAG|HEX_AMP|HEX_APOS|HEX_QUOT(这里不能写竖线,模板把它当修饰符分隔符)
|
||||
layui.stats = {:json_encode($stats ?? [], 15)};
|
||||
</script>
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
|
||||
<script>
|
||||
layui.use('console');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<div class="layui-fluid"><div class="layui-card">
|
||||
<div class="layui-card-header">数据库备份 / 还原</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-tab layui-tab-brief" lay-filter="dbTab">
|
||||
<ul class="layui-tab-title">
|
||||
<li class="layui-this" lay-id="table">数据表</li>
|
||||
<li lay-id="backup">备份记录</li>
|
||||
</ul>
|
||||
<div class="layui-tab-content" style="padding:10px 0">
|
||||
<!-- 数据表 -->
|
||||
<div class="layui-tab-item layui-show">
|
||||
<div class="layui-form" lay-filter="search-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="keyword" placeholder="表名 / 备注" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn">
|
||||
<i class="layui-icon layui-icon-search"></i> 搜索
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-primary" id="btn-reset">
|
||||
<i class="layui-icon layui-icon-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="layui-hide" id="tableList" lay-filter="tableList"></table>
|
||||
</div>
|
||||
|
||||
<!-- 备份记录 -->
|
||||
<div class="layui-tab-item">
|
||||
<table class="layui-hide" id="backupList" lay-filter="backupList"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- 数据表工具条 -->
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-sm" lay-event="backupSelected" data-perm="database:backup">
|
||||
<i class="layui-icon layui-icon-template-1"></i> 备份选中
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-normal" lay-event="backupAll" data-perm="database:backup">
|
||||
<i class="layui-icon layui-icon-fonts-code"></i> 备份全部
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-warm" lay-event="optimize" data-perm="database:optimize">
|
||||
<i class="layui-icon layui-icon-set"></i> 优化表
|
||||
</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 备份记录工具条 -->
|
||||
<script type="text/html" id="backupBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-sm layui-btn-primary" lay-event="reload">
|
||||
<i class="layui-icon layui-icon-refresh"></i> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 备份记录行操作 -->
|
||||
<script type="text/html" id="backupAction">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-xs layui-btn-normal" lay-event="download" data-perm="database:index">下载</button>
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger" lay-event="recovery" data-perm="database:recovery">还原</button>
|
||||
<button class="layui-btn layui-btn-xs" lay-event="delete" data-perm="database:delete">删除</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['database'], function () {});
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
<div class="layui-fluid"><div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
数据表管理
|
||||
<span class="layui-word-aux" style="margin-left:10px">
|
||||
表前缀:<b>{$prefix}</b> 建表成功后可直接用 addon:crud 生成 CRUD
|
||||
</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-form" lay-filter="search-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="keyword" placeholder="表名 / 表备注" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<select name="unregistered">
|
||||
<option value="0">全部表</option>
|
||||
<option value="1">仅未归档(不在任何 install.sql)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn">
|
||||
<i class="layui-icon layui-icon-search"></i> 搜索
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-primary" id="btn-reset">
|
||||
<i class="layui-icon layui-icon-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="layui-hide" id="tableList" lay-filter="tableList"></table>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- 顶部工具条 -->
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-sm" lay-event="create" data-perm="datatable:create">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 新建表
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-warm" lay-event="optimize" data-perm="datatable:optimize">
|
||||
<i class="layui-icon layui-icon-set"></i> 优化选中
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-normal" lay-event="export" data-perm="datatable:export">
|
||||
<i class="layui-icon layui-icon-export"></i> 导出安装 SQL
|
||||
</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-primary" lay-event="reload" data-perm="datatable:index">
|
||||
<i class="layui-icon layui-icon-refresh"></i> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 行操作 -->
|
||||
<script type="text/html" id="tableAction">
|
||||
<div class="layui-btn-group">
|
||||
<button class="layui-btn layui-btn-xs layui-btn-normal" lay-event="structure" data-perm="datatable:index">结构</button>
|
||||
<button class="layui-btn layui-btn-xs" lay-event="exportRow" data-perm="datatable:export">导出SQL</button>
|
||||
<button class="layui-btn layui-btn-xs layui-btn-warm" lay-event="optimizeRow" data-perm="datatable:optimize">优化</button>
|
||||
{{# if(!d.protected){ }}
|
||||
<button class="layui-btn layui-btn-xs layui-btn-danger" lay-event="drop" data-perm="datatable:drop">删除</button>
|
||||
{{# } }}
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 新建表弹窗 -->
|
||||
<script type="text/html" id="createTpl">
|
||||
<div style="padding:15px">
|
||||
<form class="layui-form layui-form-pane" lay-filter="create-form" id="create-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px">表名</label>
|
||||
<div class="layui-input-inline" style="width:150px">
|
||||
<input type="text" name="table" placeholder="demo_item" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px">表备注</label>
|
||||
<div class="layui-input-inline" style="width:180px">
|
||||
<input type="text" name="comment" placeholder="演示表" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px">引擎</label>
|
||||
<div class="layui-input-inline" style="width:110px">
|
||||
<select name="engine"><option value="InnoDB">InnoDB</option><option value="MyISAM">MyISAM</option><option value="MEMORY">MEMORY</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label" style="width:70px">字符集</label>
|
||||
<div class="layui-input-inline" style="width:120px">
|
||||
<select name="charset"><option value="{$charset}">{$charset}</option><option value="utf8">utf8</option><option value="gbk">gbk</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="layui-elem-field">
|
||||
<legend>字段(至少 1 个)</legend>
|
||||
<div class="layui-field-box" id="fieldRows"></div>
|
||||
<button type="button" class="layui-btn layui-btn-xs layui-btn-primary" id="btn-add-field">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 添加字段
|
||||
</button>
|
||||
<button type="button" class="layui-btn layui-btn-xs layui-btn-primary" id="btn-add-id">补一个 id 主键行</button>
|
||||
</fieldset>
|
||||
|
||||
<div class="layui-form-item" style="margin-top:10px">
|
||||
<label class="layui-form-label" style="width:70px">SQL 预览</label>
|
||||
<div class="layui-input-block" style="margin-left:100px">
|
||||
<textarea id="sqlPreview" class="layui-textarea" rows="8" readonly placeholder="点「生成 SQL 预览」后显示,确认无误再执行"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block" style="margin-left:0;text-align:right">
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="btn-preview">
|
||||
<i class="layui-icon layui-icon-fonts-code"></i> 生成 SQL 预览
|
||||
</button>
|
||||
<button type="button" class="layui-btn" id="btn-exec" data-perm="datatable:create">
|
||||
<i class="layui-icon layui-icon-ok"></i> 执行创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 导出安装 SQL 弹窗 -->
|
||||
<script type="text/html" id="exportTpl">
|
||||
<div style="padding:15px">
|
||||
<div id="exportSummary" class="layui-word-aux" style="margin-bottom:8px"></div>
|
||||
<table class="layui-table layui-table-sm">
|
||||
<thead><tr><th>表</th><th style="width:120px">归档状态</th><th>建议写入</th></tr></thead>
|
||||
<tbody id="exportBody"></tbody>
|
||||
</table>
|
||||
<textarea id="exportSqlText" class="layui-textarea" rows="14" readonly></textarea>
|
||||
<div style="margin-top:8px;text-align:right">
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="btn-copy-sql">复制</button>
|
||||
<button type="button" class="layui-btn" id="btn-download-sql">下载 .sql</button>
|
||||
</div>
|
||||
<div class="layui-word-aux" style="margin-top:6px">
|
||||
表名已换成 <code>__PREFIX__</code> 占位符,可直接粘进对应 install.sql;
|
||||
若该表还需运行期自愈,记得在对应 Schema 类里登记 ensureTable()。
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 表结构弹窗 -->
|
||||
<script type="text/html" id="structureTpl">
|
||||
<div style="padding:15px">
|
||||
<div class="layui-tab layui-tab-brief">
|
||||
<ul class="layui-tab-title">
|
||||
<li class="layui-this">字段</li>
|
||||
<li>建表语句</li>
|
||||
</ul>
|
||||
<div class="layui-tab-content" style="padding:10px 0">
|
||||
<div class="layui-tab-item layui-show">
|
||||
<table class="layui-table layui-table-sm" id="columnTable">
|
||||
<thead><tr>
|
||||
<th>字段</th><th>类型</th><th>可空</th><th>默认</th><th>键</th><th>额外</th><th>注释</th>
|
||||
</tr></thead>
|
||||
<tbody id="columnBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="layui-tab-item">
|
||||
<textarea id="createSqlText" class="layui-textarea" rows="16" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
layui.use(['datatable'], function () {});
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">模板钩子(共 {$total} 个埋点)</div>
|
||||
<div class="layui-card-body">
|
||||
<blockquote class="layui-elem-quote layui-quote-nm">
|
||||
模板里 <code>{:hook('钩子名', $vars)}</code> 是埋点;插件用
|
||||
<code>HookService::mount()</code> / <code>register()</code> 或监听
|
||||
<code>HookRender</code> 事件挂内容。本页只做诊断:埋点是否存在、挂了谁。
|
||||
<br>
|
||||
收集型事件监听:{if $hasListener}<span class="layui-badge layui-bg-green">已注册</span>{else}<span class="layui-badge">无</span>{/if}
|
||||
(事件方式无法枚举到具体插件,排查时优先看「已挂载」列)
|
||||
</blockquote>
|
||||
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="layui-btn layui-btn-sm {if $devOn}layui-btn-danger{else /}layui-btn-normal{/if}"
|
||||
id="btnDevMode" data-on="{if $devOn}0{else /}1{/if}">
|
||||
<i class="layui-icon layui-icon-template-1"></i>
|
||||
{if $devOn}关闭钩点标注{else /}开启钩点标注{/if}
|
||||
</button>
|
||||
<span class="layui-word-aux" style="margin-left:8px;">
|
||||
开启后打开任意后台页面:每个 hook() 埋点会显示蓝色虚线框 + 「⟡ 钩子名」标签(灰色「(空)」表示尚无插件挂载)。
|
||||
仅在本机浏览器生效(cookie),不会随前台页面暴露给访客。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table class="layui-table" lay-skin="line">
|
||||
<colgroup>
|
||||
<col width="240">
|
||||
<col width="240">
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>钩子名</th>
|
||||
<th>已挂载</th>
|
||||
<th>埋点位置</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{empty name="rows"}
|
||||
<tr>
|
||||
<td colspan="3" style="text-align:center;">暂无埋点</td>
|
||||
</tr>
|
||||
{/empty}
|
||||
{volist name="rows" id="row"}
|
||||
<tr>
|
||||
<td><code>{$row.name}</code></td>
|
||||
<td>
|
||||
{empty name="row.mounts"}
|
||||
<span class="layui-badge">未挂载</span>
|
||||
{else /}
|
||||
{volist name="row.mounts" id="m"}
|
||||
<div style="margin-bottom:4px;">
|
||||
<span class="layui-badge layui-bg-cyan">{$m.addon ?: '核心'}</span>
|
||||
<span class="layui-badge layui-bg-gray">w{$m.weight}</span>
|
||||
<span style="font-size:12px;">{$m.target}</span>
|
||||
</div>
|
||||
{/volist}
|
||||
{/empty}
|
||||
</td>
|
||||
<td style="font-size:12px;color:#666;">
|
||||
{volist name="row.files" id="f"}
|
||||
<div>{$f}</div>
|
||||
{/volist}
|
||||
</td>
|
||||
</tr>
|
||||
{/volist}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['jquery', 'layer', 'http'], function () {
|
||||
var $ = layui.jquery, layer = layui.layer, http = layui.http;
|
||||
$('#btnDevMode').on('click', function () {
|
||||
var btn = $(this);
|
||||
http.post('{:url("devMode")}', { on: btn.data('on') }).then(function (r) {
|
||||
if (r && r.code === 0) {
|
||||
layer.msg(r.msg || '已切换', { icon: 1 });
|
||||
setTimeout(function () { location.reload(); }, 800);
|
||||
} else {
|
||||
layer.msg((r && r.msg) || '切换失败', { icon: 2 });
|
||||
}
|
||||
}).catch(function (err) {
|
||||
layer.msg('请求失败:' + (err && err.message ? err.message : '网络异常'), { icon: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,23 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<script>
|
||||
/^http(s*):\/\//.test(location.href) || alert('请通过 localhost 访问');;
|
||||
|
||||
</script>
|
||||
</head>
|
||||
<title>YwxApp</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="renderer" content="webkit">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link href="/assets/layui/css/layui.css" rel="stylesheet">
|
||||
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
|
||||
|
||||
|
||||
|
||||
<body class="layui-layout-body" id="LAY_home_iframe">
|
||||
|
||||
<div id="LAY_app">
|
||||
<div class="layui-layout layui-layout-admin">
|
||||
<div class="layui-header">
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<!-- 本页是独立页(不走 layout),必须自己引入 ywxapp.js 才能用集成的 http -->
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
|
||||
<script>
|
||||
layui.use(['form', 'jquery'], function () {
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
layui.use(['form', 'jquery', 'ywxapp', 'http'], function () {
|
||||
var form = layui.form, $ = layui.jquery, http = layui.http;
|
||||
form.on('submit(searchBtn)', function (data) {
|
||||
$.get('/backend/other/search', { q: data.field.q }, function (res) {
|
||||
http.get('/backend/other/search', { q: data.field.q }).then(function (res) {
|
||||
var html = '';
|
||||
if (res.code === 0 && res.data && res.data.length) {
|
||||
if (res && res.code === 0 && res.data && res.data.length) {
|
||||
res.data.forEach(function (r) {
|
||||
html += '<tr><td>' + (r.id || '') + '</td><td>' + (r.title || '') + '</td><td>' + (r.create_at || '') + '</td></tr>';
|
||||
});
|
||||
@@ -34,6 +36,8 @@
|
||||
html = '<tr><td colspan="3" style="text-align:center;color:#999;">无结果</td></tr>';
|
||||
}
|
||||
$('#resultBody').html(html);
|
||||
}).catch(function () {
|
||||
$('#resultBody').html('<tr><td colspan="3" style="text-align:center;color:#999;">请求失败</td></tr>');
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -54,10 +54,11 @@
|
||||
<option value="2" {{ d.type == 2 ? 'selected' : '' }}>菜单</option>
|
||||
<option value="3" {{ d.type == 3 ? 'selected' : '' }}>按钮</option>
|
||||
<option value="4" {{ d.type == 4 ? 'selected' : '' }}>API</option>
|
||||
<option value="5" {{ d.type == 5 ? 'selected' : '' }}>分组标题</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-form-item-url {{ d.type == 3 ? 'layui-hide' : '' }}" id="urlField">
|
||||
<div class="layui-form-item layui-form-item-url {{ (d.type == 3 || d.type == 5) ? 'layui-hide' : '' }}" id="urlField">
|
||||
<label class="layui-form-label">路由</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="route" placeholder="请输入路由路径" value="{{ d.route || '' }}" class="layui-input">
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
</div>
|
||||
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<!-- 本页是独立页(不走 layout),必须自己引入 ywxapp.js 才能用集成的 http -->
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function () {
|
||||
var form = layui.form, layer = layui.layer, $ = layui.$;
|
||||
layui.use(['form', 'layer', 'ywxapp', 'http'], function () {
|
||||
var form = layui.form, layer = layui.layer, $ = layui.$, http = layui.http;
|
||||
|
||||
form.verify({
|
||||
pass: [/^[\S]{6,12}$/, '密码必须 6 到 12 位,且不能出现空格'],
|
||||
@@ -60,8 +62,8 @@
|
||||
});
|
||||
|
||||
form.on('submit(setmypass)', function (obj) {
|
||||
$.post('{:url("Profile/passwordSave")}', obj.field, function (res) {
|
||||
if (res.code === 1 || res.code === 0) {
|
||||
http.post('{:url("Profile/passwordSave")}', obj.field).then(function (res) {
|
||||
if (res && (res.code === 1 || res.code === 0)) {
|
||||
layer.msg(res.msg || '修改成功', { icon: 1 });
|
||||
setTimeout(function () {
|
||||
if (window !== top && parent.layui && parent.layui.ywxapp) {
|
||||
@@ -71,10 +73,10 @@
|
||||
}
|
||||
}, 800);
|
||||
} else {
|
||||
layer.msg(res.msg || '修改失败', { icon: 2 });
|
||||
layer.msg((res && res.msg) || '修改失败', { icon: 2 });
|
||||
}
|
||||
}, 'json').fail(function () {
|
||||
layer.msg('请求失败', { icon: 2 });
|
||||
}).catch(function (err) {
|
||||
layer.msg('请求失败:' + (err && err.message ? err.message : '网络异常'), { icon: 2 });
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">积分规则管理</div>
|
||||
<div class="layui-card-body">
|
||||
<blockquote class="layui-elem-quote layui-quote-nm">
|
||||
规则按「触发动作」命中:业务点或插件调用 <code>ScoreService::trigger('动作标识', $uid)</code> 时,
|
||||
本表所有启用规则按顺序执行。动作可下拉选择,也支持直接输入插件自定义动作。
|
||||
</blockquote>
|
||||
<form class="layui-form" lay-filter="data-search-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="name" placeholder="规则名称" autocomplete="off" class="layui-input">
|
||||
<input type="text" name="title" placeholder="规则名称" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<select name="action">
|
||||
<option value="">全部动作</option>
|
||||
{volist name="actionList" id="vo" key="k"}
|
||||
<option value="{$k}">{$vo.title}{if condition="!empty($vo['addon'])"}(插件:{$vo.addon}){/if}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn"><i class="layui-icon"></i> 搜索</button>
|
||||
@@ -20,6 +32,7 @@
|
||||
<button class="layui-btn layui-btn-sm" lay-event="dataCreate" data-perm="score:add"><i class="layui-icon"></i> 新增</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="dataDelete" data-perm="score:del"><i class="layui-icon"></i> 删除</button>
|
||||
<button class="layui-btn layui-btn-sm" lay-event="dataRecybin" data-perm="score:restore"><i class="layui-icon"></i> 回收站</button>
|
||||
{:hook('backend.score.toolbar')}
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataBar">
|
||||
@@ -36,6 +49,13 @@
|
||||
<script type="text/html" id="statusTpl">
|
||||
<input type="checkbox" name="status" value="{{ d.id }}" lay-skin="switch" lay-text="开|关" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
|
||||
</script>
|
||||
<script type="text/html" id="addonTpl">
|
||||
{{# if(!d.addon){ }}
|
||||
<span class="layui-badge layui-bg-blue">核心</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-cyan">{{ d.addon }}</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,40 +66,84 @@
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">规则名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" placeholder="请输入规则名称" class="layui-input">
|
||||
<input type="text" name="title" value="{{ d.title || '' }}" placeholder="请输入规则名称" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">触发动作</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="action" placeholder="如 task_complete/signin/register" class="layui-input">
|
||||
<input type="text" name="action" value="{{ d.action || '' }}" list="action-list" placeholder="如 user.register / demo.checkin" class="layui-input">
|
||||
<datalist id="action-list"></datalist>
|
||||
<div class="layui-form-mid layui-word-aux">插件可用 ScoreService::registerAction() 注册自己的动作</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">规则类型</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="type" value="1" title="获取" checked>
|
||||
<input type="radio" name="type" value="2" title="消费">
|
||||
<input type="radio" name="type" value="1" title="获取" {{ d.type == 2 ? '' : 'checked' }}>
|
||||
<input type="radio" name="type" value="2" title="消费" {{ d.type == 2 ? 'checked' : '' }}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">变动值</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="value" placeholder="请输入积分变动值(正数)" class="layui-input">
|
||||
<input type="text" name="value" value="{{ d.value || '' }}" placeholder="请输入积分变动值(正数)" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">限次周期</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="limit_type">
|
||||
{volist name="limitTypeList" id="v" key="k"}
|
||||
<option value="{$k}" {{# if(d.limit_type == {$k}){ }}selected{{# } }}>{$v}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">限次次数</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="limit_times" value="{{ d.limit_times || 0 }}" placeholder="周期内最多触发次数,0=不限" class="layui-input">
|
||||
<div class="layui-form-mid layui-word-aux">配合「限次周期」使用,上限到点后不再发放</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">去重方式</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="unique_type">
|
||||
{volist name="uniqueTypeList" id="v" key="k"}
|
||||
<option value="{$k}" {{# if(d.unique_type == {$k}){ }}selected{{# } }}>{$v}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
<div class="layui-form-mid layui-word-aux">按来源业务ID去重可防止回调重试导致重复发放</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">归属插件</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="addon" value="{{ d.addon || '' }}" placeholder="留空=核心规则,插件规则填插件标识" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">流水备注</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="remark" value="{{ d.remark || '' }}" placeholder="如 完成任务:{title}" class="layui-input">
|
||||
<div class="layui-form-mid layui-word-aux">支持 {key} 占位,取 trigger() 传入的 context</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="sort" placeholder="请输入排序" class="layui-input">
|
||||
<input type="text" name="sort" value="{{ d.sort || 0 }}" placeholder="请输入排序" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用" value="1" checked>
|
||||
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用" value="1" {{ d.id ? (d.status == 1 ? 'checked' : '') : 'checked' }}>
|
||||
</div>
|
||||
</div>
|
||||
{:hook('backend.score.form')}
|
||||
</form>
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="uid" placeholder="用户ID" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="action" placeholder="触发动作" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="source" placeholder="来源模块/插件" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn"><i class="layui-icon"></i> 搜索</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="btn-reset">重置</button>
|
||||
@@ -41,6 +47,9 @@
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 70},
|
||||
{field: 'uid', title: '用户ID', width: 90},
|
||||
{field: 'action', title: '触发动作', width: 150},
|
||||
{field: 'source', title: '来源', width: 100},
|
||||
{field: 'source_id', title: '来源业务ID', width: 120},
|
||||
{field: 'type_text', title: '类型', width: 90, templet: '#typeTpl'},
|
||||
{field: 'value', title: '变动值', width: 90},
|
||||
{field: 'balance', title: '余额', width: 90},
|
||||
@@ -60,7 +69,7 @@
|
||||
});
|
||||
|
||||
$('#btn-reset').on('click', function () {
|
||||
form.val('data-search-form', {uid: ''});
|
||||
form.val('data-search-form', {uid: '', action: '', source: ''});
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">电子商务管理</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="data-search-form">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="title" placeholder="标题" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="data-search-btn"><i class="layui-icon"></i> 搜索</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="btn-reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-container">
|
||||
<button class="layui-btn layui-btn-sm" lay-event="dataCreate" data-perm="shop:add"><i class="layui-icon"></i> 新增</button>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="dataDelete" data-perm="shop:del"><i class="layui-icon"></i> 删除</button>
|
||||
<button class="layui-btn layui-btn-sm" lay-event="dataRecybin" data-perm="shop:restore"><i class="layui-icon"></i> 回收站</button>
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataBar">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="update" data-perm="shop:edit">编辑</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="delete" data-perm="shop:del">删除</a>
|
||||
</script>
|
||||
<script type="text/html" id="statusTpl">
|
||||
<input type="checkbox" name="status" value="{{ d.id }}" lay-skin="switch" lay-text="开|关" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<form class="layui-form" id="wxapp-form" lay-filter="wxapp-form" style="padding:15px;">
|
||||
<input type="hidden" name="id" value="{{ d.id || '' }}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">商品名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" placeholder="请输入商品名称" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">价格</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="price" placeholder="请输入价格" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">库存</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="stock" placeholder="请输入库存" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">描述</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="description" placeholder="请输入描述" class="layui-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="sort" placeholder="请输入排序" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用" value="1" checked>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="dataRecybinTpl">
|
||||
<table class="layui-hide" id="dataRecybinTable" lay-filter="dataRecybinTable"></table>
|
||||
<script type="text/html" id="dataRecybinBarTpl">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="restore" data-perm="shop:restore">恢复</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="forcedelete" data-perm="shop:destroy">彻底删除</a>
|
||||
</script>
|
||||
</script>
|
||||
<script>
|
||||
layui.use('shop', layui.factory('shop'));
|
||||
</script>
|
||||
@@ -1,5 +1,10 @@
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">蜘蛛汇总(今日 / 近7日 / 近30日 / 总量)</div>
|
||||
<div class="layui-card-header">
|
||||
蜘蛛汇总(今日 / 近7日 / 近30日 / 总量 · 真伪拆分)
|
||||
<span style="float:right;font-weight:400;color:#999;font-size:12px;">
|
||||
真伪由 rDNS 双向验证(PTR 反查 + 官方域名 + A/AAAA 正查)判定
|
||||
</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" id="summaryTable" lay-size="sm">
|
||||
<thead>
|
||||
@@ -9,13 +14,16 @@
|
||||
<th>近7日</th>
|
||||
<th>近30日</th>
|
||||
<th>总量</th>
|
||||
<th>真蜘蛛</th>
|
||||
<th>假蜘蛛</th>
|
||||
<th style="min-width:220px;">近30日趋势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="summaryBody">
|
||||
<tr><td colspan="6" style="text-align:center;color:#999;">加载中…</td></tr>
|
||||
<tr><td colspan="8" style="text-align:center;color:#999;">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="dnsWarn" style="display:none;margin-top:8px;" class="layui-bg-orange"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +38,14 @@
|
||||
<option value="">全部蜘蛛</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<select name="verified" id="verifiedSelect">
|
||||
<option value="">全部真伪</option>
|
||||
<option value="1">仅真蜘蛛</option>
|
||||
<option value="2">仅假蜘蛛</option>
|
||||
<option value="0">待验证</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="ip" placeholder="来源IP" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
@@ -56,6 +72,7 @@
|
||||
<!-- 顶部工具条 -->
|
||||
<script type="text/html" id="tableBar">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-normal" title="对历史「待验证」记录重新做 rDNS 校验" lay-event="dataRecheck"><i class="layui-icon layui-icon-refresh-3"></i> 重新校验真伪</a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="清理30天前明细" lay-event="dataClear" data-perm="spider:clear"><i class="layui-icon layui-icon-delete"></i> 清理旧日志</a>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
@@ -47,12 +47,14 @@
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="uninstall">卸载</a>
|
||||
</script>
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer', 'upload', 'jquery'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, upload = layui.upload, $ = layui.jquery;
|
||||
layui.use(['table', 'form', 'layer', 'upload', 'jquery', 'http'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, upload = layui.upload
|
||||
, $ = layui.jquery, http = layui.http;
|
||||
var state = { installed: [], addon: [], active_map: {} };
|
||||
|
||||
// 统一走集成的 http(/assets/ywxapp/ywxapp.js 提供),不再用 $.ajax
|
||||
function api(url, data) {
|
||||
return $.ajax({ url: url, type: 'POST', data: data || {}, dataType: 'json' });
|
||||
return http.post(url, data || {});
|
||||
}
|
||||
|
||||
function load() {
|
||||
|
||||
@@ -26,38 +26,228 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card" style="margin:15px;">
|
||||
<div class="layui-card-header">
|
||||
站点 layui 配色
|
||||
<span class="layui-word-aux" style="margin-left:8px;">全站生效:后台 / 会员中心 / 前台 / 所有插件前台</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md7">
|
||||
<form class="layui-form" id="themeForm">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">启用主题</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" id="themeEnable" lay-skin="switch" lay-text="开|关">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">关闭后停止注入主题样式,用于排查样式冲突</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">主色</label>
|
||||
<div class="layui-input-inline" style="width:90px;">
|
||||
<input type="color" id="primaryColor" class="layui-input" style="padding:2px;height:36px;">
|
||||
</div>
|
||||
<div class="layui-input-inline" style="width:150px;">
|
||||
<input type="text" id="primaryText" class="layui-input" placeholder="#16b777">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">表单聚焦、复选框、开关、进度条、时间轴</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">强调色</label>
|
||||
<div class="layui-input-inline" style="width:90px;">
|
||||
<input type="color" id="accentColor" class="layui-input" style="padding:2px;height:36px;">
|
||||
</div>
|
||||
<div class="layui-input-inline" style="width:150px;">
|
||||
<input type="text" id="accentText" class="layui-input" placeholder="#16baaa">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">按钮、导航、分页当前页、上传、Logo</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">圆角</label>
|
||||
<div class="layui-input-inline" style="width:150px;">
|
||||
<select id="radiusInput">
|
||||
<option value="0">直角 0</option>
|
||||
<option value="2px">默认 2px</option>
|
||||
<option value="4px">4px</option>
|
||||
<option value="6px">6px</option>
|
||||
<option value="10px">10px</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">按钮 / 输入框 / 卡片的统一圆角</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">字体</label>
|
||||
<div class="layui-input-block" style="margin-right:15px;">
|
||||
<input type="text" id="fontInput" class="layui-input" placeholder="-apple-system, 'Microsoft YaHei', sans-serif">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="saveTheme">保存配色</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="resetTheme">恢复默认</button>
|
||||
<span class="layui-word-aux">保存后立即全站生效,无需清缓存</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="layui-col-md5">
|
||||
<fieldset class="layui-elem-field layui-field-title" style="margin-top:0;">
|
||||
<legend style="font-size:14px;">实时预览</legend>
|
||||
</fieldset>
|
||||
<div id="themePreview" style="padding:12px;border:1px dashed #e2e2e2;border-radius:4px;">
|
||||
<button type="button" class="layui-btn">主按钮</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary">次按钮</button>
|
||||
<div style="margin-top:10px;">
|
||||
<span class="layui-form-checkbox layui-form-checked"><i class="layui-icon layui-icon-ok"></i>已选中</span>
|
||||
<span class="layui-form-onswitch" style="margin-left:10px;"><em></em><i></i></span>
|
||||
</div>
|
||||
<div class="layui-progress" style="margin-top:12px;">
|
||||
<div class="layui-progress-bar" style="width:60%;"></div>
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<ul class="layui-nav" style="background:none;padding:0;">
|
||||
<li class="layui-nav-item layui-this"><a href="javascript:;" style="color:#333;">选中项</a></li>
|
||||
<li class="layui-nav-item"><a href="javascript:;" style="color:#666;">普通项</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="layui-box layui-laypage layui-laypage-default" style="margin-top:12px;">
|
||||
<span class="layui-laypage-curr"><em class="layui-laypage-em"></em><em>1</em></span>
|
||||
<a href="javascript:;">2</a><a href="javascript:;">3</a>
|
||||
</div>
|
||||
<blockquote class="layui-elem-quote" style="margin-top:12px;">引用区块:主色左边框</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-alert layui-alert-warn" id="menuTip" style="display:none;margin-top:10px;">
|
||||
后台菜单中缺少「界面」入口,
|
||||
<a href="javascript:;" id="syncMenu" style="color:#01AAED;">点此补建</a>
|
||||
(幂等,可重复执行)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['form', 'layer', 'jquery'], function () {
|
||||
var form = layui.form, layer = layui.layer, $ = layui.jquery;
|
||||
layui.use(['form', 'layer', 'jquery', 'http'], function () {
|
||||
var form = layui.form, layer = layui.layer, $ = layui.jquery, http = layui.http;
|
||||
|
||||
// 拉取已安装模板填充下拉 + 当前设置
|
||||
$.ajax({
|
||||
url: 'index', type: 'POST', dataType: 'json',
|
||||
success: function (res) {
|
||||
if (res.code !== 0) { return; }
|
||||
var inst = res.data.installed || [];
|
||||
var gd = res.data.global_default || 'default';
|
||||
var sel = $('#defaultTemplate');
|
||||
inst.forEach(function (t) {
|
||||
sel.append('<option value="' + t.name + '">' + t.title + ' (' + t.version + ')</option>');
|
||||
});
|
||||
sel.val(gd);
|
||||
$('#allowSelect').prop('checked', !!res.data.allow_member_select);
|
||||
form.render('select');
|
||||
form.render('checkbox');
|
||||
}
|
||||
http.post('index', {}).then(function (res) {
|
||||
if (!res || res.code !== 0) { return; }
|
||||
var inst = res.data.installed || [];
|
||||
var gd = res.data.global_default || 'default';
|
||||
var sel = $('#defaultTemplate');
|
||||
inst.forEach(function (t) {
|
||||
sel.append('<option value="' + t.name + '">' + t.title + ' (' + t.version + ')</option>');
|
||||
});
|
||||
sel.val(gd);
|
||||
$('#allowSelect').prop('checked', !!res.data.allow_member_select);
|
||||
form.render('select');
|
||||
form.render('checkbox');
|
||||
}).catch(function () {
|
||||
layer.msg('加载失败', { icon: 2 });
|
||||
});
|
||||
|
||||
form.on('submit(saveSetting)', function (data) {
|
||||
$.ajax({
|
||||
url: 'setting', type: 'POST', data: data.field, dataType: 'json',
|
||||
success: function (res) {
|
||||
if (res.code === 0) { layer.msg('已保存', { icon: 1 }); }
|
||||
else { layer.msg(res.message || '保存失败', { icon: 2 }); }
|
||||
},
|
||||
error: function () { layer.msg('请求失败', { icon: 2 }); }
|
||||
});
|
||||
http.post('setting', data.field).then(function (res) {
|
||||
if (res && res.code === 0) { layer.msg('已保存', { icon: 1 }); }
|
||||
else { layer.msg((res && res.message) || '保存失败', { icon: 2 }); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
return false;
|
||||
});
|
||||
|
||||
/* ===== 站点 layui 配色:实时预览 + 保存 ===== */
|
||||
var THEME_DEFAULT = {
|
||||
primary: '#16b777',
|
||||
accent: '#16baaa',
|
||||
radius: '2px',
|
||||
font: "-apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'PingFang SC', sans-serif"
|
||||
};
|
||||
|
||||
// 预览只写预览容器自身的 CSS 变量,不动全局即可看到效果
|
||||
function applyPreview() {
|
||||
var el = document.getElementById('themePreview');
|
||||
var pick = function (id, key) {
|
||||
var v = (document.getElementById(id).value || '').replace(/^\s+|\s+$/g, '');
|
||||
return v === '' ? THEME_DEFAULT[key] : v;
|
||||
};
|
||||
el.style.setProperty('--ywx-primary', pick('primaryText', 'primary'));
|
||||
el.style.setProperty('--ywx-accent', pick('accentText', 'accent'));
|
||||
el.style.setProperty('--ywx-radius', pick('radiusInput', 'radius'));
|
||||
el.style.setProperty('--ywx-font', pick('fontInput', 'font'));
|
||||
}
|
||||
|
||||
// 色板 <-> 文本框 双向绑定
|
||||
function bindColor(colorId, textId) {
|
||||
$('#' + colorId).on('input change', function () {
|
||||
$('#' + textId).val(this.value);
|
||||
applyPreview();
|
||||
});
|
||||
$('#' + textId).on('input', function () {
|
||||
var v = (this.value || '').replace(/^\s+|\s+$/g, '');
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) { $('#' + colorId).val(v); }
|
||||
applyPreview();
|
||||
});
|
||||
}
|
||||
bindColor('primaryColor', 'primaryText');
|
||||
bindColor('accentColor', 'accentText');
|
||||
$('#radiusInput,#fontInput').on('input change', applyPreview);
|
||||
|
||||
// 载入当前配置
|
||||
http.get('siteTheme').then(function (res) {
|
||||
if (!res || res.code !== 0) { return; }
|
||||
var v = res.data.vars || {};
|
||||
$('#primaryText').val(v.primary || '');
|
||||
$('#primaryColor').val(v.primary || THEME_DEFAULT.primary);
|
||||
$('#accentText').val(v.accent || '');
|
||||
$('#accentColor').val(v.accent || THEME_DEFAULT.accent);
|
||||
$('#radiusInput').val(v.radius || THEME_DEFAULT.radius);
|
||||
$('#fontInput').val(v.font || '');
|
||||
$('#themeEnable').prop('checked', !!res.data.enable);
|
||||
if (!res.data.menu_ok) { $('#menuTip').show(); }
|
||||
form.render('select');
|
||||
form.render('checkbox');
|
||||
applyPreview();
|
||||
}).catch(function () {
|
||||
layer.msg('主题配置加载失败', { icon: 2 });
|
||||
});
|
||||
|
||||
// 保存
|
||||
form.on('submit(saveTheme)', function () {
|
||||
var data = {
|
||||
primary: $('#primaryText').val(),
|
||||
accent: $('#accentText').val(),
|
||||
radius: $('#radiusInput').val(),
|
||||
font: $('#fontInput').val(),
|
||||
enable: $('#themeEnable').prop('checked') ? 1 : 0
|
||||
};
|
||||
http.post('siteTheme', { vars: data }).then(function (res) {
|
||||
if (res && res.code === 0) { layer.msg('已保存,全站生效', { icon: 1 }); }
|
||||
else { layer.msg((res && res.message) || '保存失败', { icon: 2 }); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
return false;
|
||||
});
|
||||
|
||||
// 恢复默认(清空提交,服务端自动回落默认值)
|
||||
$('#resetTheme').on('click', function () {
|
||||
$('#primaryText,#accentText,#fontInput').val('');
|
||||
$('#radiusInput').val(THEME_DEFAULT.radius);
|
||||
$('#primaryColor').val(THEME_DEFAULT.primary);
|
||||
$('#accentColor').val(THEME_DEFAULT.accent);
|
||||
$('#themeEnable').prop('checked', true);
|
||||
form.render('select');
|
||||
form.render('checkbox');
|
||||
applyPreview();
|
||||
layer.msg('已恢复默认配色,点「保存配色」生效', { icon: 3 });
|
||||
});
|
||||
|
||||
// 补建菜单入口(幂等)
|
||||
$('#syncMenu').on('click', function () {
|
||||
http.post('syncMenu', {}).then(function (res) {
|
||||
layer.msg((res && res.message) || '已同步', { icon: (res && res.code === 0) ? 1 : 2 });
|
||||
if (res && res.code === 0) { $('#menuTip').hide(); }
|
||||
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -25,8 +25,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
layui.use(['jquery', 'layer'], function () {
|
||||
var $ = layui.jquery, layer = layui.layer;
|
||||
layui.use(['jquery', 'layer', 'http'], function () {
|
||||
var $ = layui.jquery, layer = layui.layer, http = layui.http;
|
||||
var selectedType = 'auto';
|
||||
|
||||
function setDisabled($el, dis) {
|
||||
@@ -46,8 +46,8 @@ layui.use(['jquery', 'layer'], function () {
|
||||
|
||||
function check() {
|
||||
$('#status').html('<i class="layui-icon layui-icon-loading layui-anim layui-anim-rotate"></i> 正在检查...');
|
||||
$.get('{:url("framework/check")}', function (res) {
|
||||
if (res.code === 0) {
|
||||
http.get('{:url("framework/check")}', {}, {timeout: 15000}).then(function (res) {
|
||||
if (res && res.code === 0) {
|
||||
var d = res.data;
|
||||
if (d.has_update) {
|
||||
var autoMode = d.use_patch ? '增量补丁' : '整包';
|
||||
@@ -85,8 +85,11 @@ layui.use(['jquery', 'layer'], function () {
|
||||
$('#pkgTypeBox').hide();
|
||||
}
|
||||
} else {
|
||||
$('#status').html('<span style="color:#dc2626;">' + (res.message || '检查失败') + '</span>');
|
||||
$('#status').html('<span style="color:#dc2626;">' + ((res && res.message) || '检查失败') + '</span>');
|
||||
}
|
||||
}).catch(function (err) {
|
||||
$('#status').html('<span style="color:#dc2626;">请求失败:'
|
||||
+ (err && err.message ? err.message : '网络异常') + '</span>');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,14 +99,18 @@ layui.use(['jquery', 'layer'], function () {
|
||||
var label = {auto: '自动模式', full: '完整包', patch: '增量小包'}[selectedType] || selectedType;
|
||||
layer.confirm('将以【' + label + '】升级,将备份当前核心并覆盖,确定继续?', function () {
|
||||
$('#status').html('<i class="layui-icon layui-icon-loading layui-anim layui-anim-rotate"></i> 正在下载并升级...');
|
||||
$.post('{:url("framework/upgrade")}', {type: selectedType}, function (res) {
|
||||
if (res.code === 0) {
|
||||
// 下载 + 备份 + 覆盖可能很久,放宽到 10 分钟
|
||||
http.post('{:url("framework/upgrade")}', {type: selectedType}, {timeout: 600000}).then(function (res) {
|
||||
if (res && res.code === 0) {
|
||||
$('#status').html('<span style="color:#16a34a;">✓ ' + res.message + '</span>');
|
||||
$('#btnUpgrade').hide();
|
||||
$('#pkgTypeBox').hide();
|
||||
} else {
|
||||
$('#status').html('<span style="color:#dc2626;">' + (res.message || '升级失败') + '</span>');
|
||||
$('#status').html('<span style="color:#dc2626;">' + ((res && res.message) || '升级失败') + '</span>');
|
||||
}
|
||||
}).catch(function (err) {
|
||||
$('#status').html('<span style="color:#dc2626;">请求失败:'
|
||||
+ (err && err.message ? err.message : '网络异常') + '</span>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user