feat: 公共表更名 common/member 前缀 + 插件命令自动加载 + 版本 1.1.1
- 18 张公共表更名(addon/attachment/configure/links/spider_log/spider_stat/sms/notice/ad/task/prop/medal/help/card -> common_*,member_wallets->member_wallet,score_rule/score_log -> member_*,addon_config->common_addonconf),模型全部对齐新表名,Db 直引用清零 - Attachment 模型补 表名绑定,修复富文本上传查 wxapp_attachment 1146 隐患 - install.sql + 迁移 SQL:backend_admin/backend_role delete_at 默认 0,修复软删除(NULL != 0)误过滤导致后台菜单为空 - AppService::boot() 支持插件 info.php 声明 commands 自动注册插件命令(psr-4 自动加载,坏类名自动跳过) - 各插件(haonav/mqttbroker/wxchat/articles/blog/forum 等)字段与配置同步调整 - 框架版本 1.1.0 -> 1.1.1
This commit is contained in:
@@ -6,7 +6,7 @@ return [
|
||||
'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。',
|
||||
'author' => 'YwxApp',
|
||||
'website' => 'https://www.ywxapp.cn',
|
||||
'version' => '1.0.20',
|
||||
'version' => '1.0.21',
|
||||
'state' => 1,
|
||||
'license' => false,
|
||||
'events' => [
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
{
|
||||
"top_title": "应用中心",
|
||||
"superior": "service_center",
|
||||
"backend": [
|
||||
{
|
||||
"name": "appmall",
|
||||
"title": "应用市场",
|
||||
"type": 1,
|
||||
"icon": "fa fa-shopping-cart",
|
||||
"centerOnly": true,
|
||||
"sublist": [
|
||||
"child": [
|
||||
{
|
||||
"name": "addonreview",
|
||||
"title": "插件审核",
|
||||
"type": 2,
|
||||
"route": "/appmall/backend/addonreview/index"
|
||||
},
|
||||
{
|
||||
"name": "developer",
|
||||
"title": "开发者管理",
|
||||
"type": 2,
|
||||
"route": "/appmall/backend/developer/index"
|
||||
},
|
||||
{
|
||||
"name": "revenuelist",
|
||||
"title": "收益账本",
|
||||
"type": 2,
|
||||
"route": "/appmall/backend/revenue/index"
|
||||
},
|
||||
{
|
||||
"name": "withdrawallist",
|
||||
"title": "提现管理",
|
||||
"type": 2,
|
||||
"route": "/appmall/backend/revenue/withdrawals"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"icon": "fa fa-cloud-download",
|
||||
"centerOnly": true,
|
||||
"name": "frameworklist",
|
||||
"title": "框架管理",
|
||||
"type": 2,
|
||||
"route": "/appmall/backend/framework/index"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -38,7 +38,7 @@ class AddonLicense extends BaseModel
|
||||
*/
|
||||
public function addon()
|
||||
{
|
||||
return $this->belongsTo(\ywxapp\model\AddonModel::class, 'aid', 'id');
|
||||
return $this->belongsTo(\ywxapp\model\CommonAddon::class, 'aid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ use ywxapp\AddonBase;
|
||||
/**
|
||||
* 文章CMS插件启动类
|
||||
*/
|
||||
class Addon extends addon
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
// 插件基本信息
|
||||
public $info = [
|
||||
|
||||
@@ -6,7 +6,7 @@ return [
|
||||
'description' => '完整文章类 CMS 插件:文章、分类、标签、评论,前台套用 layuiSimpleNews 模板',
|
||||
'status' => 1,
|
||||
'author' => 'ywxapp',
|
||||
'version' => '1.0.0',
|
||||
'version' => '1.0.1',
|
||||
'type' => 1,
|
||||
'state' => 0,
|
||||
'install_time' => 1786348115,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
|
||||
{
|
||||
"backend": [
|
||||
{
|
||||
"name": "articles",
|
||||
"title": "文章CMS",
|
||||
"icon": "layui-icon layui-icon-list",
|
||||
|
||||
"sublist": [
|
||||
|
||||
|
||||
{
|
||||
"name": "article/index",
|
||||
"title": "文章列表",
|
||||
@@ -31,7 +28,6 @@
|
||||
"title": "评论管理",
|
||||
"route": "articles/backend/comment/index"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class Category extends BackendBase
|
||||
}
|
||||
$owners = [];
|
||||
if ($uids) {
|
||||
$owners = \think\facade\Db::name('member')
|
||||
$owners = \think\facade\Db::name('member_user')
|
||||
->whereIn('uid', array_unique($uids))
|
||||
->column('nickname', 'uid');
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ return [
|
||||
'intro' => '',
|
||||
'author' => '',
|
||||
'website' => '',
|
||||
'version' => '1.0.2',
|
||||
'version' => '1.0.3',
|
||||
'state' => 1,
|
||||
'url' => '/blog',
|
||||
'license' => '',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\demo;
|
||||
|
||||
use ywxapp\library\Menu;
|
||||
use ywxapp\AddonBase;
|
||||
use think\Request;
|
||||
|
||||
class Addon extends addon
|
||||
{
|
||||
/**
|
||||
* 插件安装方法
|
||||
* @return bool
|
||||
*/
|
||||
public function install()
|
||||
{
|
||||
$menu = [];
|
||||
Menu::create($menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件卸载方法
|
||||
* @return bool
|
||||
*/
|
||||
public function uninstall()
|
||||
{
|
||||
Menu::delete('demo');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件启用方法
|
||||
*/
|
||||
public function enable()
|
||||
{
|
||||
Menu::enable('demo');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件禁用方法
|
||||
*/
|
||||
public function disable()
|
||||
{
|
||||
Menu::disable('demo');
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件升级方法(覆盖升级时的数据/配置迁移)
|
||||
* @param string $currentVersion 已安装版本号
|
||||
*/
|
||||
public function upgrade($currentVersion = '')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端菜单方法
|
||||
*/
|
||||
public function frontMenu(){}
|
||||
|
||||
/**
|
||||
* 会员菜单方法
|
||||
*/
|
||||
public function memberMenu(){}
|
||||
|
||||
/**
|
||||
* 后台菜单方法
|
||||
*/
|
||||
public function backMenu(){}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// 这是系统自动生成的公共文件
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
// 插件配置项(后台「配置」表单数据源)
|
||||
return [];
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\demo\controller;
|
||||
|
||||
use think\Request;
|
||||
|
||||
class Index extends \ywxapp\controller\FrontendBase
|
||||
{
|
||||
/**
|
||||
* Summary of needLogin
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* Summary of needRight
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 控制器初始化 _initialize
|
||||
* @return void
|
||||
*/
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 显示资源列表
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
|
||||
$data = \ywxapp\model\BackendPower::find(1);
|
||||
echo $data->name;
|
||||
return "这是一个addon\demo 插件应用控制器addon\demo\controller";
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示创建资源表单页.
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新建的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示指定的资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function read($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示编辑资源表单页.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存更新的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\demo\controller\api;
|
||||
|
||||
use think\Request;
|
||||
|
||||
class Index extends \ywxapp\controller\ApiBase
|
||||
{
|
||||
/**
|
||||
* Summary of needLogin
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* Summary of needRight
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 控制器初始化 _initialize
|
||||
* @return void
|
||||
*/
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 显示资源列表
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return "这是一个addon\demo 插件应用控制器addon\demo\controller\api";
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示创建资源表单页.
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新建的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示指定的资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function read($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示编辑资源表单页.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存更新的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\demo\controller\backend;
|
||||
|
||||
use think\Request;
|
||||
|
||||
class Index extends \ywxapp\controller\BackendBase
|
||||
{
|
||||
/**
|
||||
* Summary of needLogin
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* Summary of needRight
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 控制器初始化 _initialize
|
||||
* @return void
|
||||
*/
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 显示资源列表
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return "这是一个addon\demo 插件应用控制器addon\demo\controller\backend";
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示创建资源表单页.
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新建的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示指定的资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function read($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示编辑资源表单页.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存更新的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\demo\controller\member;
|
||||
|
||||
use think\Request;
|
||||
|
||||
class Index extends \ywxapp\controller\MemberBase
|
||||
{
|
||||
/**
|
||||
* Summary of needLogin
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* Summary of needRight
|
||||
* @var array
|
||||
*/
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 控制器初始化 _initialize
|
||||
* @return void
|
||||
*/
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 显示资源列表
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return "这是一个addon\demo 插件应用控制器addon\demo\controller\member";
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示创建资源表单页.
|
||||
*
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新建的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function save(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示指定的资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function read($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示编辑资源表单页.
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function edit($id = null)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存更新的资源
|
||||
*
|
||||
* @param \think\Request $request
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定资源
|
||||
*
|
||||
* @param int $id
|
||||
* @return \think\Response
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
//这是demo应用的配置文件
|
||||
return [
|
||||
'name' => 'demo',
|
||||
'title' => '',
|
||||
'intro' => '',
|
||||
'author' => '',
|
||||
'website' => '',
|
||||
'version' => '1.0.0',
|
||||
'state' => 1,
|
||||
'url' => '/demo',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
'config' => [],
|
||||
'events' => [
|
||||
// 事件绑定,
|
||||
'bind' => [],
|
||||
// 事件监听
|
||||
'listen' => [],
|
||||
// 事件订阅
|
||||
'subscribe' => [],
|
||||
],
|
||||
'middleware' => [
|
||||
// 别名 => 中间件类
|
||||
'alias' => [],
|
||||
// 中间件优先级,越靠前优先级越高
|
||||
'priority' => [],
|
||||
],
|
||||
'services' => [],
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"frontend": [],
|
||||
"member": [],
|
||||
"backend": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use think\facade\Route;
|
||||
|
||||
|
||||
// 公共前缀 /demo,同时设置默认命名空间为 addon\demo\controller
|
||||
|
||||
// 后台 → addon\demo\controller\backend\Index
|
||||
Route::group('backend', function () {
|
||||
Route::get('index/index', 'index/index')->name('demo_backend_index');
|
||||
})->namespace('addon\demo\controller\backend');
|
||||
|
||||
// 用户中心 → addon\demo\controller\member\Index
|
||||
Route::group('member', function () {
|
||||
Route::get('index/index', 'index/index')->name('demo_member_index');
|
||||
})->namespace('addon\demo\controller\member');
|
||||
|
||||
// API → apaddonp\demo\controller\api\Index
|
||||
Route::group('api', function () {
|
||||
Route::get('index/index', 'index/index')->name('demo_api_index');
|
||||
})->namespace('addon\demo\controller\api');
|
||||
|
||||
// 前台 → addon\demo\controller\Index(不加子分组)
|
||||
Route::get('index/index', 'index/index')->name('demo_index');
|
||||
+10
-1
@@ -1,4 +1,13 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-01 23:18:56
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-17 00:31:35
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\docs\Addon.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
@@ -22,7 +31,7 @@ use ywxapp\AddonBase;
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Addon extends addon
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
/**
|
||||
* 安装钩子
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ return [
|
||||
'intro' => '多项目 / 多版本在线文档系统,支持 UEditor 可视化编辑、Word 与 Markdown 导入、三栏阅读与全文搜索',
|
||||
'author' => 'ywxapp',
|
||||
'website' => 'https://www.ywxapp.cn',
|
||||
'version' => '1.0.1',
|
||||
'version' => '1.0.2',
|
||||
'state' => 1,
|
||||
'url' => '/docs',
|
||||
'license' => '',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\AddonBase;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 下载站插件
|
||||
*
|
||||
* 安装由框架自动导入 install.sql 建表(wxapp_download_category / resource);
|
||||
* 卸载按统一前缀清理全部表;升级时确保表存在(install.sql 幂等建表)。
|
||||
*/
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
public function install(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载钩子:清理本插件全部表(与 install.sql 表名前缀严格对齐)。
|
||||
*/
|
||||
public function uninstall(): bool
|
||||
{
|
||||
$prefix = 'wxapp_download_';
|
||||
$tables = [
|
||||
'category',
|
||||
'resource',
|
||||
];
|
||||
foreach ($tables as $t) {
|
||||
try {
|
||||
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
|
||||
} catch (\Exception $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级时确保全部表存在(读 install.sql,CREATE TABLE IF NOT EXISTS 幂等建表)。
|
||||
* 单一事实源 = install.sql,避免复制 DDL 导致漂移。
|
||||
*/
|
||||
private function ensureTablesFromInstallSql(): void
|
||||
{
|
||||
$sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql';
|
||||
if (!is_file($sqlFile)) {
|
||||
return;
|
||||
}
|
||||
$content = (string) file_get_contents($sqlFile);
|
||||
$content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content);
|
||||
$stmts = array_filter(
|
||||
array_map('trim', explode(';', $content)),
|
||||
function ($s) {
|
||||
return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s);
|
||||
}
|
||||
);
|
||||
foreach ($stmts as $sql) {
|
||||
if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) {
|
||||
BaseModel::ensureTable($m[1], $sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function upgrade($currentVersion = ''): bool
|
||||
{
|
||||
$this->ensureTablesFromInstallSql();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
// 下载站插件默认配置(与 install.sql 无关,仅作为 readConfig 第一层默认值)。
|
||||
// 后台「插件配置」保存会写入 addon_config 表并覆盖此处。
|
||||
return [
|
||||
'title' => '下载',
|
||||
'page_size' => 20,
|
||||
'local_path' => '/static/download/files/',
|
||||
'need_audit' => 1,
|
||||
'show_hot' => 1,
|
||||
'show_new' => 1,
|
||||
'beian' => '',
|
||||
'copyright' => '© 下载站',
|
||||
];
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download\controller;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Lang;
|
||||
use think\facade\Cookie;
|
||||
use think\Response;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
use addon\download\model\Category;
|
||||
use addon\download\model\Resource;
|
||||
use addon\download\service\DownloadService;
|
||||
|
||||
class Index extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function initialize()
|
||||
{
|
||||
// 多语言:支持 ?lang=zh-cn|en-us 切换并写 cookie;插件级 lang 显式加载兜底
|
||||
$lang = $this->request->param('lang', '');
|
||||
if (in_array($lang, ['zh-cn', 'en-us'], true)) {
|
||||
Lang::setLangSet($lang);
|
||||
Cookie::set('download_lang', $lang);
|
||||
} elseif ($cookie = Cookie::get('download_lang')) {
|
||||
Lang::setLangSet($cookie);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 固定页面文案注入模板
|
||||
$this->view->assign([
|
||||
'site_name' => lang('site_name'),
|
||||
'all_category_text' => lang('all_category'),
|
||||
'hot_text' => lang('hot'),
|
||||
'latest_text' => lang('latest'),
|
||||
'list_text' => lang('list'),
|
||||
'detail_text' => lang('detail'),
|
||||
'search_text' => lang('search'),
|
||||
'search_placeholder' => lang('search_placeholder'),
|
||||
'download_btn_text' => lang('download_btn'),
|
||||
]);
|
||||
|
||||
// 插件前台接入「全站动态布局」:视图只写正文,由核心 frontend 的
|
||||
// common/layout.html 包裹全站 header / footer(与开发文档约定一致)。
|
||||
// 注意:layout_name 必须带 .html 扩展名,否则 think-template 的
|
||||
// parseTemplateFile() 会把无扩展名的绝对路径里的盘符冒号(D:)当成
|
||||
// 模板分隔符替换成当前 viewPath,导致「模板文件不存在」。
|
||||
$this->view->config([
|
||||
'layout_on' => true,
|
||||
'layout_name' => $this->app->getRootPath() . 'app' . DIRECTORY_SEPARATOR
|
||||
. 'frontend' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
|
||||
. 'common' . DIRECTORY_SEPARATOR . 'layout.html',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本插件配置(运行时由 AppInit 注入 config('download'))
|
||||
*/
|
||||
protected function pluginConfig(): array
|
||||
{
|
||||
return Config::get('download', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页:分类导航 + 热门/最新(按 config 开关)
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$cfg = $this->pluginConfig();
|
||||
|
||||
$categories = Category::with(['resources' => function ($q) {
|
||||
$q->order('downloads', 'desc')->limit(8);
|
||||
}])
|
||||
->where('pid', 0)
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
|
||||
$hot = [];
|
||||
$news = [];
|
||||
if (!empty($cfg['show_hot'])) {
|
||||
$hot = Resource::scope('visible')->order('downloads', 'desc')->limit(10)->select();
|
||||
}
|
||||
if (!empty($cfg['show_new'])) {
|
||||
$news = Resource::scope('visible')->order('id', 'desc')->limit(10)->select();
|
||||
}
|
||||
|
||||
$this->view->assign('categories', $categories);
|
||||
$this->view->assign('hot', $hot);
|
||||
$this->view->assign('news', $news);
|
||||
$this->view->assign('config', $cfg);
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表页
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$cid = $this->request->param('cid/d', 0);
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$cfg = $this->pluginConfig();
|
||||
$size = (int)($cfg['page_size'] ?? 20);
|
||||
|
||||
$cat = $cid ? Category::find($cid) : null;
|
||||
if ($cid && !$cat) {
|
||||
$this->error('分类不存在');
|
||||
}
|
||||
|
||||
$list = Resource::scope('visible');
|
||||
if ($cid) {
|
||||
$list = $list->where('cid', $cid);
|
||||
}
|
||||
$list = $list->order('downloads', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $size]);
|
||||
|
||||
$this->view->assign('cat', $cat);
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('pager', $list->render());
|
||||
$this->view->assign('config', $cfg);
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$id = $this->request->param('id/d', 0);
|
||||
$resource = Resource::scope('visible')->find($id);
|
||||
if (!$resource) {
|
||||
$this->error('资源不存在或已下架');
|
||||
}
|
||||
|
||||
// 查看计数(防刷交给 service 内的简单锁,此处仅点击+1)
|
||||
DownloadService::incClicks($id);
|
||||
|
||||
$cat = $resource['cid'] ? Category::find($resource['cid']) : null;
|
||||
|
||||
$this->view->assign('resource', $resource);
|
||||
$this->view->assign('cat', $cat);
|
||||
$this->view->assign('config', $this->pluginConfig());
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
$q = $this->request->param('q', $this->request->param('kw', ''));
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$cfg = $this->pluginConfig();
|
||||
$size = (int)($cfg['page_size'] ?? 20);
|
||||
|
||||
$list = Resource::scope('visible');
|
||||
if ($q) {
|
||||
$list = $list->whereLike('title', "%{$q}%");
|
||||
}
|
||||
$list = $list->order('downloads', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $size]);
|
||||
|
||||
$this->view->assign('keyword', $q);
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('pager', $list->render());
|
||||
$this->view->assign('config', $cfg);
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发下载:外链 302 跳转 / 本地文件流式输出
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$id = $this->request->param('id/d', 0);
|
||||
$ip = $this->request->ip();
|
||||
try {
|
||||
$ret = DownloadService::dispatch($id, $ip);
|
||||
} catch (\think\Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if ($ret['type'] === 'redirect') {
|
||||
return redirect($ret['url']);
|
||||
}
|
||||
|
||||
// 本地文件流式输出(避免大文件占用内存)
|
||||
$path = $ret['path'];
|
||||
$name = ($ret['name'] ?? 'download') . '.' . pathinfo($path, PATHINFO_EXTENSION);
|
||||
return Response::create()->data(file_get_contents($path))->header([
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . rawurlencode($name) . '"',
|
||||
'Content-Length' => filesize($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download\controller\backend;
|
||||
|
||||
use think\exception\ValidateException;
|
||||
use think\facade\Db;
|
||||
use addon\download\model\Category as CategoryModel;
|
||||
use addon\download\model\Resource;
|
||||
|
||||
use ywxapp\controller\BackendBase;
|
||||
class Category extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->model = new CategoryModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$title = $this->request->param('title', '');
|
||||
$all = $this->model->order('sort', 'desc')->order('id', 'asc')->select()->toArray();
|
||||
//$tree = CategoryModel::toNestedTree($all);
|
||||
|
||||
// 关键字过滤:命中节点保留,并保留其祖先链
|
||||
// if ($title) {
|
||||
// $tree = $this->filterTree($tree, $title);
|
||||
// }
|
||||
$this->result->success($all);
|
||||
}
|
||||
return $this->view->fetch('category/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按标题关键字过滤树,保留命中节点及其祖先链
|
||||
*/
|
||||
protected function filterTree(array $tree, string $keyword): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($tree as $node) {
|
||||
$children = !empty($node['children']) ? $this->filterTree($node['children'], $keyword) : [];
|
||||
$hit = stripos($node['title'], $keyword) !== false;
|
||||
if ($hit || !empty($children)) {
|
||||
$node['children'] = $children;
|
||||
$result[] = $node;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$data = CategoryModel::cateTree($this->model->select()->toArray());
|
||||
$this->result->success(['data' => $data]);
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$params = $this->request->post();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$this->model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($this->model, '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('保存失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
$this->result->error('数据不存在');
|
||||
}
|
||||
if ($this->request->isAjax()) {
|
||||
$tree = CategoryModel::cateTree($this->model->select()->toArray());
|
||||
$this->result->success(['power' => $tree, 'info' => $model]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
if ($this->request->isAjax() && $this->request->isPut()) {
|
||||
$params = $this->request->param();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
throw new ValidateException('数据不存在');
|
||||
}
|
||||
$model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($model, '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('更新失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if ($this->request->isAjax() && $this->request->isDelete()) {
|
||||
$ids = $this->request->param('ids', '');
|
||||
$force = $this->request->param('force/d', 0);
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的数据');
|
||||
}
|
||||
$idArr = array_filter(array_map('intval', explode(',', $ids)));
|
||||
try {
|
||||
Db::transaction(function () use ($idArr, $force) {
|
||||
foreach ($idArr as $id) {
|
||||
$this->deleteCascade($id, (bool) $force);
|
||||
}
|
||||
});
|
||||
$this->result->success();
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $th) {
|
||||
$this->result->error('删除失败: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归级联删除:force=true 时先删子分类下的资源与子分类,再删自身
|
||||
*/
|
||||
protected function deleteCascade($id, bool $force): void
|
||||
{
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
if ($force) {
|
||||
Resource::where('cid', $id)->delete();
|
||||
$children = $this->model->where('pid', $id)->select();
|
||||
foreach ($children as $child) {
|
||||
$this->deleteCascade($child['id'], true);
|
||||
}
|
||||
}
|
||||
// 非 force 时若仍有子分类/资源,onBeforeDelete 会抛出异常阻止删除
|
||||
$model->delete();
|
||||
}
|
||||
|
||||
protected function validateSave(array $params): void
|
||||
{
|
||||
if (empty($params['title'])) {
|
||||
throw new ValidateException('分类名称不能为空');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download\controller\backend;
|
||||
|
||||
use think\exception\ValidateException;
|
||||
use think\facade\Db;
|
||||
use addon\download\model\Resource as ResourceModel;
|
||||
use addon\download\model\Category as CategoryModel;
|
||||
use ywxapp\controller\BackendBase;
|
||||
class Resource extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->model = new ResourceModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$title = $this->request->param('title', '');
|
||||
$cid = $this->request->param('cid/d', 0);
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
$data = $this->model
|
||||
->with(['category'])
|
||||
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
|
||||
->when($cid, fn($q, $c) => $q->where('cid', $c))
|
||||
->order('id', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
$this->result->setCount($data->total());
|
||||
$this->result->success($data->items());
|
||||
}
|
||||
$categories = (new CategoryModel())
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
$this->view->assign('categories', $categories);
|
||||
return $this->view->fetch('resource/index');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$categories = (new CategoryModel())
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
$this->result->success(['categories' => $categories]);
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$params = $this->request->post();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$this->model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($this->model, '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('保存失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
$this->result->error('数据不存在');
|
||||
}
|
||||
if ($this->request->isAjax()) {
|
||||
$categories = (new CategoryModel())
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
$this->result->success(['categories' => $categories, 'info' => $model]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
if ($this->request->isAjax() && $this->request->isPut()) {
|
||||
$params = $this->request->param();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
throw new ValidateException('数据不存在');
|
||||
}
|
||||
$model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($model, '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('更新失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if ($this->request->isAjax() && $this->request->isDelete()) {
|
||||
$ids = $this->request->param('ids', '');
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的数据');
|
||||
}
|
||||
try {
|
||||
Db::transaction(function () use ($ids) {
|
||||
$this->model->destroy($ids);
|
||||
});
|
||||
$this->result->success();
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $th) {
|
||||
$this->result->error('删除失败: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateSave(array $params): void
|
||||
{
|
||||
if (empty($params['title'])) {
|
||||
throw new ValidateException('资源标题不能为空');
|
||||
}
|
||||
if (empty($params['cid'])) {
|
||||
throw new ValidateException('请选择分类');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'download',
|
||||
'title' => '下载站',
|
||||
'intro' => '资源下载站,支持分类管理、资源管理与下载统计。',
|
||||
'author' => 'ywxapp',
|
||||
'website' => 'https://github.com',
|
||||
'version' => '1.0.0',
|
||||
'state' => 1,
|
||||
'url' => '/download',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
'config' => [
|
||||
['name' => 'page_size', 'title' => '列表每页数量', 'type' => 'number', 'value' => 20],
|
||||
['name' => 'local_path', 'title' => '本地存储目录', 'type' => 'text', 'value' => '/static/download/files/'],
|
||||
['name' => 'need_audit', 'title' => '用户投稿需审核', 'type' => 'switch', 'value' => 1],
|
||||
['name' => 'show_hot', 'title' => '首页显示热门下载', 'type' => 'switch', 'value' => 1],
|
||||
['name' => 'show_new', 'title' => '首页显示最新上传', 'type' => 'switch', 'value' => 1],
|
||||
],
|
||||
'events' => [
|
||||
'bind' => [],
|
||||
'listen' => [],
|
||||
'subscribe' => [],
|
||||
],
|
||||
'middleware' => [
|
||||
'alias' => [],
|
||||
'priority' => [],
|
||||
],
|
||||
'services' => [],
|
||||
'install_time' => 1787000000,
|
||||
'update_time' => 1787000000,
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 下载站插件安装脚本(前缀硬写 wxapp_download_,与 Addon.php 卸载清单一致)
|
||||
-- 注意:种子文案禁 -- 注释整段;字符串内禁 \' ,用 '' 转义。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wxapp_download_category` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`pid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0=一级',
|
||||
`title` varchar(50) NOT NULL DEFAULT '' COMMENT '分类名',
|
||||
`cover` varchar(255) NOT NULL DEFAULT '' COMMENT '图标/封面',
|
||||
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序,越大越靠前',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏',
|
||||
`create_at` int(11) NOT NULL DEFAULT 0,
|
||||
`update_at` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `pid` (`pid`),
|
||||
KEY `status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载分类';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wxapp_download_resource` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`cid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '分类ID',
|
||||
`title` varchar(150) NOT NULL DEFAULT '' COMMENT '资源标题',
|
||||
`cover` varchar(255) NOT NULL DEFAULT '' COMMENT '封面图',
|
||||
`intro` text COMMENT '简介',
|
||||
`author` varchar(50) NOT NULL DEFAULT '' COMMENT '作者/出品方',
|
||||
`version` varchar(30) NOT NULL DEFAULT '' COMMENT '版本号',
|
||||
`file_size` bigint(20) NOT NULL DEFAULT 0 COMMENT '文件字节数',
|
||||
`file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '外链地址或本地相对路径',
|
||||
`is_local` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1=本地存储 0=外链',
|
||||
`is_free` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=免费 0=收费',
|
||||
`price` int(11) NOT NULL DEFAULT 0 COMMENT '所需积分/价格',
|
||||
`clicks` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '查看次数',
|
||||
`downloads` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '下载次数',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=正常 0=下架',
|
||||
`is_audit` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=已审 0=待审',
|
||||
`source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=后台 1=用户投稿',
|
||||
`create_at` int(11) NOT NULL DEFAULT 0,
|
||||
`update_at` int(11) NOT NULL DEFAULT 0,
|
||||
`delete_at` int(11) NOT NULL DEFAULT 0 COMMENT '软删除,0=未删',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `cid` (`cid`),
|
||||
KEY `status` (`status`),
|
||||
KEY `is_audit` (`is_audit`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载资源';
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-18 16:46:29
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-18 17:25:53
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\download\lang\en-us.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
|
||||
// 下载站插件 - 英文语言包
|
||||
// 键与 zh-cn.php 一一对应;未在中文包中列出的固定文案(如自由录入的资源名)
|
||||
// 由 lang() 透传原文(中文),在此无需声明。
|
||||
|
||||
return [
|
||||
// 站点 / 页面
|
||||
'site_name' => 'Downloads',
|
||||
'all_category' => 'All Categories',
|
||||
'hot' => 'Popular',
|
||||
'latest' => 'Latest',
|
||||
'search' => 'Search',
|
||||
'search_placeholder' => 'Search resources',
|
||||
'detail' => 'Detail',
|
||||
'list' => 'List',
|
||||
'download_btn' => 'Download',
|
||||
'no_cover' => 'No cover',
|
||||
|
||||
// 示例:固定分类名
|
||||
'category.dev' => 'Dev Tools',
|
||||
'category.design' => 'Design Assets',
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
// 下载站插件 - 简体中文语言包
|
||||
// 约定:title 字段在数据库存默认语言(中文),lang() 对未定义键原样透传,
|
||||
// 因此自由录入的资源/分类名无需在此列出;此处仅维护「固定页面文案」与
|
||||
// 「需要英文翻译的固定分类名」的映射。
|
||||
|
||||
return [
|
||||
// 站点 / 页面
|
||||
'site_name' => '下载站',
|
||||
'all_category' => '分类',
|
||||
'hot' => '热门',
|
||||
'latest' => '最新',
|
||||
'search' => '搜索',
|
||||
'search_placeholder' => '搜索资源名称',
|
||||
'detail' => '详情',
|
||||
'list' => '列表',
|
||||
'download_btn' => '立即下载',
|
||||
'no_cover' => '暂无封面',
|
||||
|
||||
// 示例:固定分类名(若想让英文站点显示英文分类名,在此给出映射;
|
||||
// 自由录入的分类名 lang() 会原样透传中文)
|
||||
'category.dev' => '开发工具',
|
||||
'category.design' => '设计素材',
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"backend": [
|
||||
{
|
||||
"name": "download",
|
||||
"title": "下载站",
|
||||
"icon": "fa fa-download",
|
||||
"type": 1,
|
||||
"sort": 60,
|
||||
"status": 1,
|
||||
"child": [
|
||||
{ "name": "download/category", "title": "分类管理", "icon": "fa fa-list", "type": 2, "sort": 0, "route": "/download/backend/category/index" },
|
||||
{ "name": "download/resource", "title": "资源管理", "icon": "fa fa-file-archive-o", "type": 2, "sort": 1, "route": "/download/backend/resource/index" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"member": [],
|
||||
"frontend": []
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\download\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
class Category extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'download_category',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 子分类(pid 指向自身)
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'pid', 'id')
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类下的资源
|
||||
*/
|
||||
public function resources()
|
||||
{
|
||||
return $this->hasMany(Resource::class, 'cid', 'id')
|
||||
->where('status', 1)
|
||||
->where('is_audit', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平铺分类列表转带前缀的下拉选项(供新增/编辑表单的上级分类 select 使用)
|
||||
* 直接复用父类 BaseModel::cateTree($cate, $name='title', $lefthtml='|— ', $pid=0, $level=0),
|
||||
* 返回的 title 自带层级前缀,无需在子类重复声明(会与父类签名冲突 fatal error)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将平铺分类列表转换为嵌套树(供 layui.treeTable 使用)
|
||||
* 返回形如 [['id'=>..,'children'=>[...]], ...]
|
||||
*/
|
||||
public static function toNestedTree(array $list): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($list as &$item) {
|
||||
$item['children'] = [];
|
||||
$map[$item['id']] = &$item;
|
||||
}
|
||||
unset($item);
|
||||
$tree = [];
|
||||
foreach ($list as &$item) {
|
||||
if (!empty($item['pid']) && isset($map[$item['pid']])) {
|
||||
$map[$item['pid']]['children'][] = &$item;
|
||||
} else {
|
||||
$tree[] = &$item;
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
// 清理空的 children,保持返回结构干净
|
||||
return self::cleanEmptyChildren($tree);
|
||||
}
|
||||
|
||||
protected static function cleanEmptyChildren(array $tree): array
|
||||
{
|
||||
foreach ($tree as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$node['children'] = self::cleanEmptyChildren($node['children']);
|
||||
} else {
|
||||
unset($node['children']);
|
||||
}
|
||||
}
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前保护:存在子分类或资源时禁止删除
|
||||
*/
|
||||
public static function onBeforeDelete($model)
|
||||
{
|
||||
$childCount = self::where('pid', $model->id)->count();
|
||||
if ($childCount > 0) {
|
||||
throw new \think\Exception('该分类下还存在 ' . $childCount . ' 个子分类,请先处理子分类');
|
||||
}
|
||||
$resCount = \addon\download\model\Resource::where('cid', $model->id)->count();
|
||||
if ($resCount > 0) {
|
||||
throw new \think\Exception('该分类下还存在 ' . $resCount . ' 个资源,请先移走或删除这些资源');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\download\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
class Resource extends BaseModel
|
||||
{
|
||||
protected $defaultSoftDelete = 0;
|
||||
protected $deleteTime = 'delete_at';
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'download_resource',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属分类
|
||||
*/
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'cid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小友好显示(字节 -> KB/MB/GB)
|
||||
*/
|
||||
public function getSizeTextAttr($value, $data)
|
||||
{
|
||||
$size = (int)($data['file_size'] ?? 0);
|
||||
if ($size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = 0;
|
||||
while ($size >= 1024 && $i < count($units) - 1) {
|
||||
$size /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return round($size, 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表查询作用域:仅正常且已审
|
||||
*/
|
||||
public function scopeVisible($query)
|
||||
{
|
||||
return $query->where('status', 1)->where('is_audit', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类名称(依赖预载入的 category 关联)
|
||||
*/
|
||||
public function getCategoryTitleAttr($value, $data)
|
||||
{
|
||||
if (isset($this->category) && $this->category) {
|
||||
return $this->category->title;
|
||||
}
|
||||
$cid = (int)($data['cid'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
return (new Category())->where('id', $cid)->value('title') ?: '-';
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | 本文件由 ywxapp/service/AppService::loadAddonRoutes() 在 boot 阶段 include,
|
||||
// | 并统一被外层 Route::group('download', ...) 包住,下方均写【相对规则】,
|
||||
// | 最终自动加 /download 前缀:前台 -> /download/* ,后台 -> /download/backend/* 。
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
use think\facade\Route;
|
||||
|
||||
// 前台页面 / 接口
|
||||
Route::rule('index', 'Index/index');
|
||||
Route::rule('list/:cid', 'Index/list');
|
||||
Route::rule('detail/:id', 'Index/detail');
|
||||
Route::rule('search', 'Index/search');
|
||||
Route::rule('down/:id', 'Index/down'); // 触发下载(外链跳转 / 本地落盘)
|
||||
|
||||
// 后台管理路由(对应 controller/backend/ 下的控制器)
|
||||
Route::group('backend', function () {
|
||||
Route::rule('category', 'Category/index');
|
||||
Route::rule('category/:action', 'Category/:action');
|
||||
Route::rule('resource', 'Resource/index');
|
||||
Route::rule('resource/:action', 'Resource/:action');
|
||||
})->prefix('backend/');
|
||||
@@ -0,0 +1,61 @@
|
||||
-- 下载站测试数据(可直接前台展示:status=1 / is_audit=1)
|
||||
-- 前缀 wxapp_ 与 install.sql 保持一致。执行前如已存在测试数据先清空:
|
||||
-- TRUNCATE `wxapp_download_category`; TRUNCATE `wxapp_download_resource`;
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ===================== 一级分类 =====================
|
||||
INSERT INTO `wxapp_download_category` (`id`, `pid`, `title`, `cover`, `sort`, `status`, `create_at`, `update_at`) VALUES
|
||||
(1, 0, '办公软件', '📄', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(2, 0, '安全杀毒', '🛡️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(3, 0, '图形图像', '🎨', 80, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(4, 0, '影音播放', '🎬', 70, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(5, 0, '开发工具', '💻', 60, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(6, 0, '游戏娱乐', '🎮', 50, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
-- ===================== 二级分类 =====================
|
||||
INSERT INTO `wxapp_download_category` (`id`, `pid`, `title`, `cover`, `sort`, `status`, `create_at`, `update_at`) VALUES
|
||||
(11, 1, '文档处理', '📝', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(12, 1, '表格计算', '📊', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(13, 2, '杀毒软件', '🦠', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(14, 2, '防火墙', '🔥', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(15, 3, '图像处理', '🖼️', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(16, 3, '矢量绘图', '✏️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(17, 4, '视频播放', '📺', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(18, 4, '音乐播放', '🎵', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(19, 5, '编辑器', '⌨️', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(20, 5, '数据库', '🗄️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
-- ===================== 资源(全部可见 status=1 / is_audit=1) =====================
|
||||
INSERT INTO `wxapp_download_resource`
|
||||
(`cid`, `title`, `cover`, `intro`, `author`, `version`, `file_size`, `file_url`, `is_local`, `is_free`, `price`, `clicks`, `downloads`, `status`, `is_audit`, `source`, `create_at`, `update_at`, `delete_at`) VALUES
|
||||
-- 办公软件 / 文档处理
|
||||
(11, '极速文档 2026 专业版', '📄', '轻量级文档处理工具,支持 Word/PDF 互转,启动快、占用低。', '极速软件', '2026.1.0', 88450390, 'https://pc.qq.com/', 0, 1, 0, 1320, 980, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(11, '云笔记 Markdown 编辑器', '📝', '支持双向链接与大纲视图的本地优先笔记软件。', '云栈科技', '3.4.2', 45208700, 'https://pc.qq.com/', 0, 1, 0, 880, 612, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(12, '表格大师 计算版', '📊', '海量数据秒级计算,自带数据透视与图表模板。', '数擎信息', '11.0', 120560000, 'https://pc.qq.com/', 0, 1, 0, 540, 330, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(12, '财务报表一键生成器', '💡', '内置 200+ 财务模板,快速生成合规报表。', '财通软件', '2.8.1', 30990000, 'https://pc.qq.com/', 0, 0, 50, 410, 188, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 安全杀毒
|
||||
(13, '护盾杀毒 免费版', '🦠', '云查杀引擎,体积仅 30MB,低内存占用。', '护盾实验室', '2026.0.3', 31457280, 'https://pc.qq.com/', 0, 1, 0, 2200, 1900, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(13, '木马专杀工具箱', '🧰', '针对顽固木马与流氓插件的专杀合集。', '净网团队', '5.2', 15800300, 'https://pc.qq.com/', 0, 1, 0, 960, 720, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(14, '个人防火墙 极简版', '🔥', '仅允许白名单程序联网,杜绝后台偷跑流量。', '安域科技', '1.9.7', 9870000, 'https://pc.qq.com/', 0, 1, 0, 320, 150, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 图形图像
|
||||
(15, '美图秀秀 电脑版', '🖼️', '一键美颜、抠图、拼图,海量素材免费下载。', '美图公司', '2026.2', 156000000, 'https://pc.qq.com/', 0, 1, 0, 5300, 4700, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(15, '光影魔术手', '🌅', '照片后期调色利器,批量处理更高效。', '光影工作室', '4.5.1', 68000000, 'https://pc.qq.com/', 0, 1, 0, 1200, 880, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(16, '矢量绘图画板', '✏️', '类似 Illustrator 的开源矢量绘图工具。', '开源社区', '1.2.0', 42000000, 'https://pc.qq.com/', 0, 1, 0, 700, 430, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 影音播放
|
||||
(17, '全能影音播放器', '📺', '支持 4K/HDR,几乎通吃所有视频格式。', '全能影音', '9.3.0', 88000000, 'https://pc.qq.com/', 0, 1, 0, 6100, 5400, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(17, '本地视频剪辑', '🎞️', '轻量剪辑,导出无水印,适合短视频创作。', '剪映轻量版', '3.0.1', 210000000, 'https://pc.qq.com/', 0, 1, 0, 3400, 2600, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(18, '高保真音乐播放器', '🎵', '支持无损 FLAC/APE,歌词自动匹配。', '声海科技', '7.1.4', 39000000, 'https://pc.qq.com/', 0, 1, 0, 1900, 1500, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 开发工具
|
||||
(19, '代码编辑器 Pro', '⌨️', '智能补全、远程开发、多光标编辑,插件丰富。', '码云开源', '4.12.0', 95000000, 'https://pc.qq.com/', 0, 1, 0, 2800, 2100, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(19, '终端利器 Tabby', '🖥️', '跨平台现代终端,支持 SSH/SFTP 与主题美化。', 'Tabby 社区', '1.0.205', 125000000, 'https://pc.qq.com/', 0, 1, 0, 1100, 760, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(20, '数据库管理工具', '🗄️', '同时连接 MySQL/PostgreSQL/SQLite,可视化建模。', '数据方舟', '6.4.2', 73000000, 'https://pc.qq.com/', 0, 0, 30, 640, 290, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 游戏娱乐
|
||||
(6, '休闲益智合集', '🎮', '100 款单机小游戏打包,离线即玩。', '乐玩工作室', '2026.春节版', 540000000, 'https://pc.qq.com/', 0, 1, 0, 4200, 3800, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(6, '模拟器大厅', '🕹️', '集成多平台复古游戏模拟器,手柄即插即用。', '怀旧游戏社', '2.1.0', 88000000, 'https://pc.qq.com/', 0, 1, 0, 1500, 970, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0);
|
||||
|
||||
-- ===================== 少量隐藏/待审数据(用于验证筛选) =====================
|
||||
INSERT INTO `wxapp_download_resource`
|
||||
(`cid`, `title`, `cover`, `intro`, `author`, `version`, `file_size`, `file_url`, `is_local`, `is_free`, `price`, `clicks`, `downloads`, `status`, `is_audit`, `source`, `create_at`, `update_at`, `delete_at`) VALUES
|
||||
(11, '(下架)旧版文档工具', '📄', '仅用于测试下架不展示。', '极速软件', '2020.0', 40000000, 'https://pc.qq.com/', 0, 1, 0, 100, 50, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(13, '(待审)杀毒内测版', '🦠', '仅用于测试待审不展示。', '护盾实验室', '2027.beta', 33000000, 'https://pc.qq.com/', 0, 1, 0, 20, 5, 1, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0);
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\download\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
use addon\download\model\Resource;
|
||||
|
||||
/**
|
||||
* 下载核心服务:计数 + 防盗链 + 本地/外链分发。
|
||||
*/
|
||||
class DownloadService
|
||||
{
|
||||
/**
|
||||
* 触发下载:返回 ['type'=>'redirect','url'=>...] 或 ['type'=>'file','path'=>...]
|
||||
* @param int $id 资源ID
|
||||
* @param string $clientIp 客户端IP(用于防刷)
|
||||
* @return array
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public static function dispatch(int $id, string $clientIp): array
|
||||
{
|
||||
$resource = Resource::find($id);
|
||||
if (!$resource || $resource->status != 1 || $resource->is_audit != 1) {
|
||||
throw new \think\Exception('资源不存在或已下架');
|
||||
}
|
||||
|
||||
// 防刷:同 IP + 同资源 5 秒内不重复计数
|
||||
$lockKey = 'dl_lock_' . md5($clientIp . '_' . $id);
|
||||
if (!Cache::get($lockKey)) {
|
||||
Db::name('download_resource')
|
||||
->where('id', $id)
|
||||
->inc('downloads', 1)
|
||||
->update();
|
||||
Cache::set($lockKey, 1, 5);
|
||||
}
|
||||
|
||||
if ((int)$resource->is_local === 1) {
|
||||
// 本地文件:返回服务器绝对路径,由控制器做流式输出
|
||||
$root = app()->getRootPath() . 'public';
|
||||
$path = $root . $resource->file_url;
|
||||
if (!is_file($path)) {
|
||||
throw new \think\Exception('文件不存在');
|
||||
}
|
||||
return ['type' => 'file', 'path' => $path, 'name' => $resource->title];
|
||||
}
|
||||
|
||||
if (empty($resource->file_url)) {
|
||||
throw new \think\Exception('下载地址为空');
|
||||
}
|
||||
return ['type' => 'redirect', 'url' => $resource->file_url];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看计数(详情页调用)
|
||||
*/
|
||||
public static function incClicks(int $id): void
|
||||
{
|
||||
Db::name('download_resource')
|
||||
->where('id', $id)
|
||||
->inc('clicks', 1)
|
||||
->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"> </div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/html" id="tableBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="新建分类" lay-event="dataCreate" data-perm="download:add"> <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="download: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="download:recyclebin"><i class="layui-icon layui-icon-home"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑分类" lay-event="update" data-perm="download:edit"><i class="layui-icon layui-icon-edit"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="添加子分类" lay-event="create" data-perm="download:add"><i class="layui-icon layui-icon-add-1"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="delete" data-perm="download:delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<!-- 添加/编辑分类表单模板 -->
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
|
||||
<input type="hidden" name="id" value="{{= d.id || '' }}">
|
||||
<input type="hidden" name="pid" value="{{= d.pid || 0 }}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分类名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="请输入分类名称" value="{{ d.title || '' }}" 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="cover" id="iconPicker" placeholder="支持 emoji 或图片URL" value="{{ d.cover || '' }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{{ d.sort || 50 }}" 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="启用|禁用" {{ d.status != 0 ? 'checked' : (!d.id ? 'checked' : '') }}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-hide">
|
||||
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
<script type="text/html" id="dataRecybinTpl">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"> </div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataRecybinTable" lay-filter="dataRecybinTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataRecybinBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="恢复数据" lay-event="restore"><i class="layui-icon layui-icon-edit"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<script>
|
||||
layui.use(['layer', 'http', 'auth'], function () {
|
||||
var $ = layui.$;
|
||||
var treeTable = layui.treeTable;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var laytpl = layui.laytpl;
|
||||
var http = layui.http;
|
||||
var auth = layui.auth;
|
||||
var _savedPerms = (layui.data('backend').permission) || [];
|
||||
auth.init(_savedPerms);
|
||||
auth.setController('download');
|
||||
treeTable.render({
|
||||
elem: '#dataTable',
|
||||
url: 'index',
|
||||
parseData: function (res) {
|
||||
return {
|
||||
code: res.code === 0 ? 0 : 1,
|
||||
data: res.data || [],
|
||||
msg: res.message || ''
|
||||
};
|
||||
},
|
||||
tree: {
|
||||
customName: {
|
||||
children: "children",
|
||||
isParent: "is_parent",
|
||||
name: "title",
|
||||
id: "id",
|
||||
pid: "pid",
|
||||
icon: "cover"
|
||||
},
|
||||
data: { isSimpleData: true, rootPid: 0 },
|
||||
view: {},
|
||||
async: {},
|
||||
callback: {}
|
||||
},
|
||||
height: 'full-100',
|
||||
toolbar: '#tableBarTpl',
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '分类名称', width: 220, fixed: 'left' },
|
||||
{ field: 'cover', title: '图标', width: 100, templet: function (d) {
|
||||
return d.cover ? '<span style="font-size:18px;">' + d.cover + '</span>' : '<span class="layui-badge-rim">无</span>';
|
||||
} },
|
||||
{ field: 'sort', title: '排序', width: 80, sort: true },
|
||||
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
|
||||
{ fixed: "right", title: "操作", width: 181, align: "center", toolbar: "#dataBarTpl" }
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
var view = $('#dataTable').next('.layui-table-view');
|
||||
view.find('[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
$('.layui-btn[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
treeTable.on('toolbar(dataTable)', function (obj) {
|
||||
var options = obj.config;
|
||||
switch (obj.event) {
|
||||
case 'dataCreate':
|
||||
active.dataCreate({ pid: 0 });
|
||||
break;
|
||||
case 'dataDelete':
|
||||
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(ids);
|
||||
break;
|
||||
case 'dataRecybin':
|
||||
active.dataRecybin();
|
||||
break;
|
||||
};
|
||||
});
|
||||
treeTable.on('tool(dataTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'update':
|
||||
active.dataEdit(data);
|
||||
break;
|
||||
case 'create':
|
||||
active.dataCreate({ pid: data.id });
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
// 状态开关
|
||||
form.on('switch(statusSwitch)', function (obj) {
|
||||
var id = this.value;
|
||||
var status = obj.elem.checked ? 1 : 0;
|
||||
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该分类吗?', function (index) {
|
||||
layui.request.post('update', { id: id, status: status }).then(function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.msg, { icon: 1 });
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg, { icon: 2 });
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
}
|
||||
});
|
||||
layer.close(index);
|
||||
}, function () {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
});
|
||||
});
|
||||
|
||||
var dataFromFun = function (data, callback, done) {
|
||||
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: data.id ? '编辑分类' : (data.pid ? '添加子分类' : '添加根分类'),
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
btnAlign: "l",
|
||||
area: ['520px', '98%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
btn: ['确定', '取消'],
|
||||
success: function (layero, index) {
|
||||
callback(layero, index);
|
||||
form.render();
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
|
||||
done(layero, index, elem);
|
||||
layui.off('submit(wxapp-form)', 'from');
|
||||
return false;
|
||||
});
|
||||
layero.contents().find("#wxapp-form-submit").trigger('click');
|
||||
}
|
||||
});
|
||||
};
|
||||
//事件
|
||||
var active = {
|
||||
dataCreate: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) {
|
||||
},
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.post('save', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataEdit: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) {
|
||||
},
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.put('update', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataDelete: function (ids, force = 0) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
layer.prompt({
|
||||
formType: 1
|
||||
, title: '敏感操作,请验证口令'
|
||||
}, function (value, index) {
|
||||
layer.close(index);
|
||||
layer.confirm('确定删除吗?其下级分类与关联资源将一并删除,且不可恢复。', function (index) {
|
||||
http.delete('delete', { ids: ids.join(','), force: force })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
layer.msg('已删除');
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
});
|
||||
});
|
||||
},
|
||||
dataRecybin: function (data = {}) {
|
||||
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: '分类回收站',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
area: ['60%', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
success: function (layero, index) {
|
||||
layui.table.render({
|
||||
elem: '#dataRecybinTable',
|
||||
url: 'recyclebin',
|
||||
height: 'full-100',
|
||||
toolbar: '#dataRecybinBarTpl',
|
||||
defaultToolbar: [{
|
||||
title: '批量删除数据',
|
||||
layEvent: 'dataDelete',
|
||||
icon: 'layui-icon-delete',
|
||||
onClick: function (obj) {
|
||||
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
console.log(ids);
|
||||
}
|
||||
}, 'filter', 'exports', 'print'],
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '分类名称', width: 200, fixed: 'left' },
|
||||
{
|
||||
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
|
||||
return `<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
|
||||
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div >`;
|
||||
}
|
||||
}
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
layui.table.on('tool(dataRecybinTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'restore':
|
||||
active.dataRestore([data.id]);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id], 1);
|
||||
break;
|
||||
}
|
||||
});
|
||||
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let idss = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(idss, 1);
|
||||
break;
|
||||
case 'dataRestore':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataRestore(ids);
|
||||
break;
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
dataRestore: function (ids) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
http.put('restore', { ids: ids.join(',') })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.msg('恢复成功');
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '恢复失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,397 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"></div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 表格顶部工具条 -->
|
||||
<script type="text/html" id="tableBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="新建资源"
|
||||
lay-event="dataCreate"
|
||||
data-perm="download:add">
|
||||
<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="download: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="download:recyclebin"
|
||||
><i class="layui-icon layui-icon-home"></i>
|
||||
</a>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 表格数据工具条 -->
|
||||
<script type="text/html" id="dataBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="编辑资源"
|
||||
lay-event="update"
|
||||
data-perm="download:edit"
|
||||
><i class="layui-icon layui-icon-edit"></i>
|
||||
</a>
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="删除资源"
|
||||
lay-event="delete"
|
||||
data-perm="download:delete"
|
||||
><i class="layui-icon layui-icon-delete"></i>
|
||||
</a>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 表格回收站 -->
|
||||
<script type="text/html" id="dataRecybinTpl">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"></div>
|
||||
<div class="layui-card-body">
|
||||
<table
|
||||
class="layui-hide"
|
||||
id="dataRecybinTable"
|
||||
lay-filter="dataRecybinTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 资源状态 -->
|
||||
<script type="text/html" id="statusTpl">
|
||||
{{# if(d.status == 1) { }}
|
||||
<span class="layui-badge layui-bg-green">正常</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-orange">下架</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<!-- 免费/收费 -->
|
||||
<script type="text/html" id="freeTpl">
|
||||
{{# if(d.is_free == 1) { }}
|
||||
<span class="layui-badge layui-bg-green">免费</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-orange">收费</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<!-- 添加/编辑资源表单 -->
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
|
||||
<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" required lay-verify="required" placeholder="请输入资源标题" autocomplete="off" class="layui-input" value="{{d.title||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">所属分类</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="cid" lay-verify="required">
|
||||
{volist name="categories" id="c"}
|
||||
<option value="{$c.id}" {{ d.cid==$c.id ? 'selected' : '' }}>{$c.title}</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="author" placeholder="请输入作者" autocomplete="off" class="layui-input" value="{{d.author||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="version" placeholder="如 1.0.0" autocomplete="off" class="layui-input" value="{{d.version||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">下载地址</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="file_url" placeholder="外链下载地址" autocomplete="off" class="layui-input" value="{{d.file_url||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">本地存储</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="is_local" lay-skin="switch" lay-text="本地|外链" {{ d.is_local==1 ? 'checked' : '' }} value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">是否免费</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="is_free" lay-skin="switch" lay-text="免费|收费" {{ d.is_free!=0 ? 'checked' : '' }} value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="status" lay-skin="switch" lay-text="正常|下架" {{ d.status!=0 ? 'checked' : '' }} value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="intro" placeholder="资源简介" class="layui-textarea">{{d.intro||''}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-hide">
|
||||
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
|
||||
</div>
|
||||
</form>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
layui.use(['http', 'auth'], function () {
|
||||
var $ = layui.$, table = layui.table, form = layui.form, layer = layui.layer
|
||||
, laytpl = layui.laytpl, http = layui.http
|
||||
, auth = layui.auth;
|
||||
var _savedPerms = (layui.data('backend').permission) || [];
|
||||
auth.init(_savedPerms);
|
||||
auth.setController('download');
|
||||
var dataTable = table.render({
|
||||
elem: '#dataTable'
|
||||
, url: 'index' // 后端数据接口
|
||||
, page: true
|
||||
, limit: 15
|
||||
, toolbar: '#tableBarTpl'
|
||||
, cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' }
|
||||
, { field: 'id', title: 'ID', width: 60, align: 'center', sort: true, fixed: 'left' }
|
||||
, { field: 'title', title: '标题', minWidth: 200, align: 'left', fixed: 'left' }
|
||||
, { field: 'category_title', title: '分类', width: 120, align: 'center' }
|
||||
, { field: 'author', title: '作者', width: 120, align: 'center' }
|
||||
, { field: 'version', title: '版本', width: 90, align: 'center' }
|
||||
, { field: 'downloads', title: '下载数', width: 90, align: 'center', sort: true }
|
||||
, { field: 'is_free', title: '类型', width: 80, align: 'center', templet: '#freeTpl' }
|
||||
, { field: 'status', title: '状态', width: 90, align: 'center', templet: '#statusTpl' }
|
||||
, { title: '操作', width: 120, align: 'left', toolbar: '#dataBarTpl', fixed: 'right', unresize: true }
|
||||
]]
|
||||
, done: function (res, curr, count) {
|
||||
var view = $('#dataTable').next('.layui-table-view');
|
||||
view.find('[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
$('.layui-btn[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
table.on('toolbar(dataTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataCreate':
|
||||
active.dataCreate({ });
|
||||
break;
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(ids);
|
||||
break;
|
||||
case 'dataRecybin':
|
||||
active.dataRecybin();
|
||||
break;
|
||||
};
|
||||
});
|
||||
|
||||
table.on('tool(dataTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'update':
|
||||
active.dataEdit(data);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id]);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
var dataFromFun = function (data, callback, done) {
|
||||
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: data.id ? '编辑资源' : '添加资源',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
btnAlign: "l",
|
||||
area: ['50% ', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
btn: ['确定', '取消'],
|
||||
success: function (layero, index) {
|
||||
callback(layero, index);
|
||||
form.render();
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
|
||||
done(layero, index, elem);
|
||||
layui.off('submit(wxapp-form)', 'from');
|
||||
return false;
|
||||
});
|
||||
layero.contents().find("#wxapp-form-submit").trigger('click');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//事件
|
||||
var active = {
|
||||
dataDelete: function (ids, force = 0) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
layer.prompt({
|
||||
formType: 1
|
||||
, title: '敏感操作,请验证口令'
|
||||
}, function (value, index) {
|
||||
layer.close(index);
|
||||
layer.confirm('确定删除吗?', function (index) {
|
||||
http.delete("delete", { ids: ids.join(','), force: force })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
dataCreate: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
http.post('save', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataEdit: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
http.put('update', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataRecybin: function (data = {}) {
|
||||
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: '资源回收站',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
area: ['60%', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
success: function (layero, index) {
|
||||
table.render({
|
||||
elem: '#dataRecybinTable',
|
||||
url: "recyclebin",
|
||||
toolbar: '#tableBarTpl',
|
||||
defaultToolbar: [{
|
||||
title: '批量删除数据',
|
||||
layEvent: 'dataDelete',
|
||||
icon: 'layui-icon-delete',
|
||||
onClick: function (obj) {
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
console.log(ids);
|
||||
}
|
||||
}, 'filter', 'exports', 'print'],
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '标题', width: 200, fixed: 'left' },
|
||||
{ field: 'category_title', title: '分类', width: 120, align: 'center' },
|
||||
{ field: 'author', title: '作者', width: 120, align: 'center' },
|
||||
{
|
||||
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
|
||||
return `<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
|
||||
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div >`;
|
||||
}
|
||||
}
|
||||
]],
|
||||
page: true,
|
||||
done: function () {
|
||||
table.on('tool(dataRecybinTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'restore':
|
||||
active.dataRestore([data.id]);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id], 1);
|
||||
break;
|
||||
}
|
||||
});
|
||||
table.on('toolbar(dataRecybinTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let idss = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(idss, 1);
|
||||
break;
|
||||
case 'dataRestore':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataRestore(ids);
|
||||
break;
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
dataRestore: function (ids) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
http.put("restore", { ids: ids.join(',') })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.msg('恢复成功');
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '恢复失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<div class="dl-crumb">
|
||||
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
|
||||
{present name="cat.title"}
|
||||
<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a><span>/</span>
|
||||
{/present}
|
||||
<strong>{$resource.title|lang}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dl-detail">
|
||||
<img class="dl-detail-cover" src="{$resource.cover|default='/static/download/img/default.svg'}" alt="{$resource.title|lang}">
|
||||
<div class="dl-detail-info">
|
||||
<h1 class="dl-detail-title">{$resource.title|lang}</h1>
|
||||
<div class="dl-detail-row">大小:{$resource.size_text|default='-'}</div>
|
||||
<div class="dl-detail-row">分类:{present name="cat.title"}<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a>{else/}—{/present}</div>
|
||||
<div class="dl-detail-row">更新:{$resource.update_at|default='-'}</div>
|
||||
<div class="dl-detail-row">下载:{$resource.downloads|default=0}</div>
|
||||
<div class="dl-detail-row">
|
||||
{if $resource.is_free == 1}<span class="dl-tag-free">免费</span>{else/}<span class="dl-tag-pay">付费</span>{/if}
|
||||
</div>
|
||||
<a class="dl-btn-down" href="{:url('index/down', ['id'=>$resource.id])}">{$download_btn_text}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-detail-intro">
|
||||
<h3>资源简介</h3>
|
||||
<p>{$resource.intro|default='暂无简介'}</p>
|
||||
</div>
|
||||
|
||||
<a class="dl-back" href="{:url('index/list', ['cid'=>$cat.id|default=0])}">← {$list_text}</a>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<section class="dl-hero">
|
||||
<div>
|
||||
<h2>{$site_name} · 海量资源 高速免费下载</h2>
|
||||
<p>精选软件、源码与工具,安全无捆绑,下载更省心。</p>
|
||||
<div class="slogan">一键下载 · 更新无插件 · 卸载无残留</div>
|
||||
</div>
|
||||
<a class="dl-hero-btn" href="{:url('index/list', ['cid'=>0])}">{$all_category_text}</a>
|
||||
</section>
|
||||
|
||||
{notempty name="hot"}
|
||||
<section class="dl-block">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$hot_text}</h2>
|
||||
<span class="dl-refresh" onclick="location.href='{:url(\'index/index\')}'">换一换</span>
|
||||
</div>
|
||||
<ul class="dl-grid">
|
||||
{volist name="hot" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</section>
|
||||
{/notempty}
|
||||
|
||||
{notempty name="news"}
|
||||
<section class="dl-block">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$latest_text}</h2>
|
||||
<span class="dl-refresh" onclick="location.href='{:url(\'index/index\')}'">换一换</span>
|
||||
</div>
|
||||
<ul class="dl-grid">
|
||||
{volist name="news" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</section>
|
||||
{/notempty}
|
||||
|
||||
<section class="dl-block">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$all_category_text}</h2>
|
||||
</div>
|
||||
<div class="dl-cat-grid">
|
||||
{volist name="categories" id="cat"}
|
||||
<div class="dl-cat">
|
||||
<h3><a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a></h3>
|
||||
<div class="dl-cat-list">
|
||||
{volist name="cat.resources" id="r"}
|
||||
<a href="{:url('index/detail', ['id'=>$r.id])}">{$r.title|lang}</a>
|
||||
{/volist}
|
||||
</div>
|
||||
<a class="dl-cat-more" href="{:url('index/list', ['cid'=>$cat.id])}">{$all_category_text} ›</a>
|
||||
</div>
|
||||
{/volist}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<div class="dl-crumb">
|
||||
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
|
||||
{present name="cat.title"}
|
||||
<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a>
|
||||
{else/}
|
||||
<strong>{$all_category_text}</strong>
|
||||
{/present}
|
||||
</div>
|
||||
|
||||
<form class="dl-searchbar" action="{:url('index/search')}" method="get">
|
||||
<input type="text" name="kw" placeholder="{$search_placeholder}" value="{$keyword|default=''}">
|
||||
<button type="submit">{$search_text}</button>
|
||||
</form>
|
||||
|
||||
{notempty name="list"}
|
||||
<ul class="dl-grid">
|
||||
{volist name="list" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
|
||||
{notempty name="pager"}
|
||||
<div class="dl-pager">{$pager|raw}</div>
|
||||
{/notempty}
|
||||
{else/}
|
||||
<div class="dl-empty">{$empty_text|default='暂无资源'}</div>
|
||||
{/notempty}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<div class="dl-crumb">
|
||||
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
|
||||
<strong>{$search_text}</strong>
|
||||
</div>
|
||||
|
||||
<form class="dl-searchbar" action="{:url('index/search')}" method="get">
|
||||
<input type="text" name="kw" placeholder="{$search_placeholder}" value="{$keyword|default=''}">
|
||||
<button type="submit">{$search_text}</button>
|
||||
</form>
|
||||
|
||||
{notempty name="list"}
|
||||
<div class="dl-block" style="border:0;padding:0;background:transparent;">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$search_text}:{$keyword|default=''}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="dl-grid">
|
||||
{volist name="list" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
|
||||
{notempty name="pager"}
|
||||
<div class="dl-pager">{$pager|raw}</div>
|
||||
{/notempty}
|
||||
{else/}
|
||||
<div class="dl-empty">{$empty_text|default='未找到相关资源'}</div>
|
||||
{/notempty}
|
||||
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ class Index extends ForumFrontend
|
||||
$board = ForumBoard::find((int) $topic->board_id);
|
||||
|
||||
// 作者信息(复用会员表),带默认值避免模板内联查库
|
||||
$author = Db::name('member')->where('id', $topic->user_id)
|
||||
$author = Db::name('member_user')->where('id', $topic->user_id)
|
||||
->field('id,nickname,avatar,intro')->find();
|
||||
if (!$author) {
|
||||
$author = ['id' => $topic->user_id, 'nickname' => '匿名', 'avatar' => '', 'intro' => ''];
|
||||
@@ -97,7 +97,7 @@ class Index extends ForumFrontend
|
||||
$uids = array_column($replies->toArray(), 'user_id');
|
||||
$users = [];
|
||||
if (!empty($uids)) {
|
||||
$users = Db::name('member')->where('id', 'in', array_unique($uids))
|
||||
$users = Db::name('member_user')->where('id', 'in', array_unique($uids))
|
||||
->column('nickname,avatar', 'id');
|
||||
}
|
||||
$replyList = [];
|
||||
@@ -313,7 +313,7 @@ class Index extends ForumFrontend
|
||||
$uids = array_filter(array_unique($uids));
|
||||
$users = [];
|
||||
if (!empty($uids)) {
|
||||
$users = Db::name('member')->where('id', 'in', $uids)
|
||||
$users = Db::name('member_user')->where('id', 'in', $uids)
|
||||
->column('nickname,avatar', 'id');
|
||||
}
|
||||
foreach ($collection as $t) {
|
||||
@@ -331,7 +331,7 @@ class Index extends ForumFrontend
|
||||
*/
|
||||
public function user($id = 0)
|
||||
{
|
||||
$user = Db::name('member')->where('id', (int) $id)->field('id,nickname,avatar,intro')->find();
|
||||
$user = Db::name('member_user')->where('id', (int) $id)->field('id,nickname,avatar,intro')->find();
|
||||
if (!$user) {
|
||||
$this->assign('msg', '用户不存在');
|
||||
return $this->fetch('index/notfound');
|
||||
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'title' => '轻社区论坛',
|
||||
'intro' => '基于 Fly-3.0 风格打造的轻量社区论坛:版块、发帖、回复、消息。',
|
||||
'author' => 'ywxapp',
|
||||
'version' => '1.0.2',
|
||||
'version' => '1.0.3',
|
||||
'state' => 1,
|
||||
'type' => 1,
|
||||
'install_time' => 1785604242,
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace addon\haonav;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use ywxapp\Addons;
|
||||
use ywxapp\AddonBase;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,7 @@ use ywxapp\model\BaseModel;
|
||||
* favorites / favorite_shares / ads / apply / click_stats);
|
||||
* 卸载时按统一前缀清理全部表;升级时经 callAddonHook('upgrade') 收敛表结构。
|
||||
*/
|
||||
class Addon extends Addons
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
/**
|
||||
* 安装钩子(框架自动导入 install.sql 后调用)。
|
||||
|
||||
@@ -93,10 +93,17 @@ class Index extends FrontendBase
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$data = CategoryModel::with(['links' =>
|
||||
function ($query) {
|
||||
$query->where('status', 1)->limit(10)->order('click_count', 'desc');
|
||||
}])->where('status', 1)->order('sort', 'desc')->select();
|
||||
$data = CategoryModel::with([
|
||||
'children' => function ($query) {
|
||||
$query->with(['subLinks' => function ($q) {
|
||||
$q->limit(12)->order('click_count', 'desc');
|
||||
}]);
|
||||
},
|
||||
'links' => function ($query) {
|
||||
// 首页不按二级分组,直接展示该一级分类下的全部链接(含已归属二级的)
|
||||
$query->where('status', 1)->order('click_count', 'desc')->limit(30);
|
||||
},
|
||||
])->where('pid', 0)->where('status', 1)->order('sort', 'desc')->select();
|
||||
|
||||
$hotspot = LinksModel::where('is_hot', 1)->where('status', 1)->limit(18)->order('click_count', 'desc')->select();
|
||||
$links = LinksModel::where('is_recommend', 1)->where('status', 1)->limit(10)->order('sort', 'desc')->select();
|
||||
@@ -441,11 +448,39 @@ class Index extends FrontendBase
|
||||
*/
|
||||
public function category($id = 0)
|
||||
{
|
||||
$data = CategoryModel::with(['links' =>
|
||||
function ($query) {
|
||||
$query->where('status', 1)->order('click_count', 'desc');
|
||||
}])->find($id);
|
||||
$this->view->assign('info', $data);
|
||||
$info = CategoryModel::with([
|
||||
'children' => function ($query) {
|
||||
$query->with(['subLinks' => function ($q) {
|
||||
$q->order('click_count', 'desc');
|
||||
}]);
|
||||
},
|
||||
])->where('status', 1)->find($id);
|
||||
|
||||
if (!$info) {
|
||||
return redirect('/haonav/index.html');
|
||||
}
|
||||
|
||||
// 顶级分类且有子分类:按二级子分类分组展示
|
||||
if ($info->pid == 0 && $info->children && $info->children->count() > 0) {
|
||||
// 未细分二级的链接(sub_cid=0)归入「常用」分组,排在最后
|
||||
$others = LinksModel::where('cid', $id)
|
||||
->where('sub_cid', 0)
|
||||
->where('status', 1)
|
||||
->order('click_count', 'desc')
|
||||
->select();
|
||||
$this->view->assign('others', $others);
|
||||
$this->view->assign('mode', 'group');
|
||||
} else {
|
||||
// 二级分类 / 叶子分类:直接列出该分类下的全部链接
|
||||
$links = ($info->pid == 0)
|
||||
? LinksModel::where('cid', $id)->where('status', 1)
|
||||
: LinksModel::where('sub_cid', $id)->where('status', 1);
|
||||
$links = $links->order('click_count', 'desc')->select();
|
||||
$this->view->assign('links', $links);
|
||||
$this->view->assign('mode', 'flat');
|
||||
}
|
||||
|
||||
$this->view->assign('info', $info);
|
||||
$this->assignSeo();
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
@@ -57,10 +57,11 @@ class Category extends HaonavBackend
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
$data = $this->model->withAttr('cate')
|
||||
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
$this->result->setCount($data->total());
|
||||
$this->result->success($data->items());
|
||||
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))->select();
|
||||
// ->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
// $this->result->setCount($data->total());
|
||||
// $this->result->success($data->items());
|
||||
$this->result->success($data);
|
||||
}
|
||||
return $this->view->fetch('category/index');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ return [
|
||||
'intro' => '简洁实用的网址导航,支持分类管理、链接收录、热门/推荐与点击统计。',
|
||||
'author' => 'ywxapp',
|
||||
'website' => 'https://github.com',
|
||||
'version' => '1.0.9',
|
||||
'version' => '1.0.10',
|
||||
'state' => 1,
|
||||
'url' => '/haonav',
|
||||
'license' => '',
|
||||
@@ -34,6 +34,6 @@ return [
|
||||
],
|
||||
'services' => [
|
||||
],
|
||||
'install_time' => 1785818546,
|
||||
'install_time' => 1786890830,
|
||||
'update_time' => 1786365870,
|
||||
];
|
||||
+3035
-4
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,24 @@ class Category extends BaseModel
|
||||
return $this->hasMany(Links::class, 'cid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级分类(pid 指向自身)
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'pid', 'id')->where('status', 1)->order('sort', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按二级分类分组的链接(首页/分类页用)
|
||||
* 返回二级分类及其下属链接,sub_cid=0 的链接归入「常用」
|
||||
*/
|
||||
public function subLinks()
|
||||
{
|
||||
return $this->hasMany(Links::class, 'sub_cid', 'id')
|
||||
->where('status', 1)->order('click_count', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前保护:存在子分类或下属链接时禁止删除,避免产生孤儿数据
|
||||
* @param \think\Model $model
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-07-31 13:28:15
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-10 23:40:51
|
||||
* @LastEditTime: 2026-08-20 12:27:18
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\haonav\route\app.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
@@ -84,6 +84,7 @@ Route::group('backend', function () {
|
||||
Route::rule('links/:action', 'Links/:action');
|
||||
Route::rule('category', 'Category/index') ;
|
||||
Route::rule('category/index', 'Category/index') ;
|
||||
Route::rule('category/update', 'Category/update') ;
|
||||
Route::rule('configs', 'Configs/index');
|
||||
Route::rule('configs/:action', 'Configs/:action');
|
||||
Route::rule('ad', 'Ad/index');
|
||||
|
||||
@@ -16,3 +16,83 @@ INSERT IGNORE INTO `__PREFIX__haonav_config` (`id`, `name`, `group`, `title`, `t
|
||||
(2, 'enable_submit', 'site', '开启网址投稿', '关闭后前台投稿将直接上架,无需审核', 'bool', '1', '', '', '', ''),
|
||||
(3, 'enable_hot_api', 'site', '启用外部热榜', '开启后将尝试聚合外部热搜(可能较慢,失败自动降级)', 'bool', '0', '', '', '', ''),
|
||||
(4, 'check_token', 'task', '死链检测令牌', 'cron 调用 /haonav/task/checklinks?token= 时校验,留空则拒绝执行', 'string', '', '', '', '', '');
|
||||
|
||||
-- 3. 链接表新增二级分类字段
|
||||
ALTER TABLE `__PREFIX__haonav_links`
|
||||
ADD COLUMN `sub_cid` int unsigned NOT NULL DEFAULT '0' COMMENT '所属二级分类ID,0表示未细分' AFTER `cid`;
|
||||
ALTER TABLE `__PREFIX__haonav_links`
|
||||
ADD INDEX `idx_sub_cid` (`sub_cid`);
|
||||
|
||||
-- 4. 写入二级分类(pid 指向一级,已存在则忽略)
|
||||
INSERT IGNORE INTO `__PREFIX__haonav_category` (`id`, `pid`, `title`, `icon`, `summary`, `keywords`, `description`, `sort`, `status`, `website_count`, `click_count`, `create_at`, `update_at`) VALUES
|
||||
(101, 1, '综合社交', '💬', '', '', '微博、朋友圈等综合社交平台', 100, 1, 0, 0, NULL, NULL),
|
||||
(102, 1, '即时通讯', '📱', '', '', '微信、QQ、Telegram 等通讯工具', 90, 1, 0, 0, NULL, NULL),
|
||||
(103, 1, '兴趣社区', '🏘️', '', '', '豆瓣、贴吧、知乎等兴趣社区', 80, 1, 0, 0, NULL, NULL),
|
||||
(104, 1, '职场交友', '🤝', '', '', '脉脉、探探、Soul 等职场与交友', 70, 1, 0, 0, NULL, NULL),
|
||||
(105, 2, '长视频', '🎬', '', '', '腾讯视频、爱奇艺、优酷等长视频', 100, 1, 0, 0, NULL, NULL),
|
||||
(106, 2, '短视频', '📱', '', '', '抖音、快手、B站等短视频', 90, 1, 0, 0, NULL, NULL),
|
||||
(107, 2, '动漫漫画', '🎨', '', '', '哔哩漫画、腾讯动漫、快看', 80, 1, 0, 0, NULL, NULL),
|
||||
(108, 2, '直播娱乐', '🎥', '', '', '直播平台与综合娱乐', 70, 1, 0, 0, NULL, NULL),
|
||||
(109, 3, '综合电商', '🛍️', '', '', '淘宝、京东、拼多多等综合电商', 100, 1, 0, 0, NULL, NULL),
|
||||
(110, 3, '特卖二手', '♻️', '', '', '唯品会、闲鱼、转转等特卖与二手', 90, 1, 0, 0, NULL, NULL),
|
||||
(111, 3, '本地生活', '🍜', '', '', '美团、大众点评、旅游出行', 80, 1, 0, 0, NULL, NULL),
|
||||
(112, 3, '汽车房产', '🚗', '', '', '汽车之家、链家、贝壳等', 70, 1, 0, 0, NULL, NULL),
|
||||
(113, 5, '在线课程', '📝', '', '', 'MOOC、网易云课堂、极客时间', 100, 1, 0, 0, NULL, NULL),
|
||||
(114, 5, '学术检索', '🔬', '', '', '知网、万方、Google Scholar', 90, 1, 0, 0, NULL, NULL),
|
||||
(115, 5, '基础教育', '📐', '', '', '可汗学院、学堂在线等', 80, 1, 0, 0, NULL, NULL),
|
||||
(116, 6, '综合新闻', '📰', '', '', '人民日报、新华社、央视网', 100, 1, 0, 0, NULL, NULL),
|
||||
(117, 6, '财经科技', '💡', '', '', '财新、36氪、界面新闻', 90, 1, 0, 0, NULL, NULL),
|
||||
(118, 6, '健康医疗', '🩺', '', '', '丁香医生、好大夫在线', 80, 1, 0, 0, NULL, NULL),
|
||||
(119, 7, '游戏平台', '🕹️', '', '', 'Steam、TapTap、腾讯游戏', 100, 1, 0, 0, NULL, NULL),
|
||||
(120, 7, '小游戏', '🎲', '', '', '4399、7k7k 等小游戏', 90, 1, 0, 0, NULL, NULL),
|
||||
(121, 8, '搜索引擎', '🔍', '', '', '百度、Google、Bing 等', 100, 1, 0, 0, NULL, NULL),
|
||||
(122, 8, '门户邮箱', '📧', '', '', '新浪、搜狐、QQ邮箱、Gmail', 90, 1, 0, 0, NULL, NULL),
|
||||
(123, 8, '翻译地图', '🗺️', '', '', '翻译、地图等工具门户', 80, 1, 0, 0, NULL, NULL),
|
||||
(124, 9, '代码托管', '📦', '', '', 'GitHub、GitLab、Gitee', 100, 1, 0, 0, NULL, NULL),
|
||||
(125, 9, '技术社区', '👨💻', '', '', 'CSDN、掘金、Stack Overflow', 90, 1, 0, 0, NULL, NULL),
|
||||
(126, 9, '云服务', '☁️', '', '', '阿里云、腾讯云、Cloudflare', 80, 1, 0, 0, NULL, NULL),
|
||||
(127, 9, '设计素材', '🎨', '', '', '站酷、花瓣、摄图网', 70, 1, 0, 0, NULL, NULL),
|
||||
(128, 10, '网络文学', '📖', '', '', '起点、晋江、纵横等', 100, 1, 0, 0, NULL, NULL),
|
||||
(129, 10, '电子阅读', '📚', '', '', '微信读书、掌阅、番茄小说', 90, 1, 0, 0, NULL, NULL),
|
||||
(130, 11, '办公套件', '📊', '', '', 'WPS、Office 等办公套件', 100, 1, 0, 0, NULL, NULL),
|
||||
(131, 11, '企业协同', '👥', '', '', '钉钉、飞书、企业微信', 90, 1, 0, 0, NULL, NULL),
|
||||
(132, 11, '云存储', '💾', '', '', '百度网盘、阿里云盘', 80, 1, 0, 0, NULL, NULL),
|
||||
(133, 11, '音乐音频', '🎵', '', '', '网易云、QQ音乐、Spotify', 70, 1, 0, 0, NULL, NULL),
|
||||
(134, 12, '证券理财', '📈', '', '', '东方财富、同花顺、雪球', 100, 1, 0, 0, NULL, NULL),
|
||||
(135, 12, '支付银行', '🏦', '', '', '支付宝、招商银行等', 90, 1, 0, 0, NULL, NULL);
|
||||
|
||||
-- 5. 已装库旧数据按 URL 特征归入二级(仅更新 sub_cid=0 的记录,安全可重复执行)
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 102 WHERE `sub_cid` = 0 AND (`url` LIKE '%weixin.qq%' OR `url` LIKE '%im.qq%' OR `url` LIKE '%telegram%' OR `url` LIKE '%whatsapp%' OR `url` LIKE '%skype%' OR `url` LIKE '%discord%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 103 WHERE `sub_cid` = 0 AND (`url` LIKE '%douban%' OR `url` LIKE '%tieba%' OR `url` LIKE '%zhihu%' OR `url` LIKE '%weibo%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 104 WHERE `sub_cid` = 0 AND (`url` LIKE '%maimai%' OR `url` LIKE '%tantan%' OR `url` LIKE '%soulapp%' OR `url` LIKE '%immomo%' OR `url` LIKE '%jiayuan%' OR `url` LIKE '%yidui%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 105 WHERE `sub_cid` = 0 AND (`url` LIKE '%v.qq%' OR `url` LIKE '%iqiyi%' OR `url` LIKE '%youku%' OR `url` LIKE '%mgtv%' OR `url` LIKE '%sohu.com%tv%' OR `url` LIKE '%miguvideo%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 106 WHERE `sub_cid` = 0 AND (`url` LIKE '%douyin%' OR `url` LIKE '%kuaishou%' OR `url` LIKE '%bilibili%' OR `url` LIKE '%ixigua%' OR `url` LIKE '%haokan%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 107 WHERE `sub_cid` = 0 AND (`url` LIKE '%manga%' OR `url` LIKE '%manhua%' OR `url` LIKE '%ac.qq%' OR `url` LIKE '%u17%' OR `url` LIKE '%dmzj%' OR `url` LIKE '%manhuadb%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 108 WHERE `sub_cid` = 0 AND (`url` LIKE '%douyu%' OR `url` LIKE '%huya%' OR `url` LIKE '%live%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 109 WHERE `sub_cid` = 0 AND (`url` LIKE '%taobao%' OR `url` LIKE '%tmall%' OR `url` LIKE '%jd.com%' OR `url` LIKE '%pinduoduo%' OR `url` LIKE '%suning%' OR `url` LIKE '%amazon%' OR `url` LIKE '%mi.com%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 110 WHERE `sub_cid` = 0 AND (`url` LIKE '%vip.com%' OR `url` LIKE '%dangdang%' OR `url` LIKE '%goofish%' OR `url` LIKE '%zhuanzhuan%' OR `url` LIKE '%xianyu%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 111 WHERE `sub_cid` = 0 AND (`url` LIKE '%meituan%' OR `url` LIKE '%ele.me%' OR `url` LIKE '%dianping%' OR `url` LIKE '%ctrip%' OR `url` LIKE '%fliggy%' OR `url` LIKE '%qunar%' OR `url` LIKE '%mafengwo%' OR `url` LIKE '%12306%' OR `url` LIKE '%ly.com%' OR `url` LIKE '%kfc%' OR `url` LIKE '%mcdonalds%' OR `url` LIKE '%xiachufang%' OR `url` LIKE '%meishij%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 112 WHERE `sub_cid` = 0 AND (`url` LIKE '%autohome%' OR `url` LIKE '%dongchedi%' OR `url` LIKE '%yiche%' OR `url` LIKE '%lianjia%' OR `url` LIKE '%ke.com%' OR `url` LIKE '%anjuke%' OR `url` LIKE '%5i5j%' OR `url` LIKE '%guazi%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 113 WHERE `sub_cid` = 0 AND (`url` LIKE '%icourse%' OR `url` LIKE '%study.163%' OR `url` LIKE '%ke.qq%' OR `url` LIKE '%xuetangx%' OR `url` LIKE '%imooc%' OR `url` LIKE '%geekbang%' OR `url` LIKE '%jikexueyuan%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 114 WHERE `sub_cid` = 0 AND (`url` LIKE '%cnki%' OR `url` LIKE '%wanfang%' OR `url` LIKE '%cqvip%' OR `url` LIKE '%scholar.google%' OR `url` LIKE '%xueshu.baidu%' OR `url` LIKE '%smartedu%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 115 WHERE `sub_cid` = 0 AND (`url` LIKE '%khanacademy%' OR `url` LIKE '%coursera%' OR `url` LIKE '%edx%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 116 WHERE `sub_cid` = 0 AND (`url` LIKE '%people.com%' OR `url` LIKE '%news.cn%' OR `url` LIKE '%cctv%' OR `url` LIKE '%chinanews%' OR `url` LIKE '%huanqiu%' OR `url` LIKE '%cankaoxiaoxi%' OR `url` LIKE '%thepaper%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 117 WHERE `sub_cid` = 0 AND (`url` LIKE '%caixin%' OR `url` LIKE '%jiemian%' OR `url` LIKE '%36kr%' OR `url` LIKE '%tmtpost%' OR `url` LIKE '%leiphone%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 118 WHERE `sub_cid` = 0 AND (`url` LIKE '%dxy.com%' OR `url` LIKE '%haodf%' OR `url` LIKE '%jk.cn%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 119 WHERE `sub_cid` = 0 AND (`url` LIKE '%steampowered%' OR `url` LIKE '%taptap%' OR `url` LIKE '%game.qq%' OR `url` LIKE '%game.163%' OR `url` LIKE '%epicgames%' OR `url` LIKE '%wegame%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 120 WHERE `sub_cid` = 0 AND (`url` LIKE '%4399%' OR `url` LIKE '%7k7k%' OR `url` LIKE '%2144%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 121 WHERE `sub_cid` = 0 AND (`url` LIKE '%baidu.com%' OR `url` LIKE '%google.com%' OR `url` LIKE '%bing%' OR `url` LIKE '%sogou%' OR `url` LIKE '%yahoo%' OR `url` LIKE '%duckduckgo%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 122 WHERE `sub_cid` = 0 AND (`url` LIKE '%sina%' OR `url` LIKE '%sohu%' OR `url` LIKE '%mail.qq%' OR `url` LIKE '%mail.163%' OR `url` LIKE '%outlook%' OR `url` LIKE '%gmail%' OR `url` LIKE '%foxmail%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 123 WHERE `sub_cid` = 0 AND (`url` LIKE '%fanyi%' OR `url` LIKE '%map.baidu%' OR `url` LIKE '%amap%' OR `url` LIKE '%translate%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 124 WHERE `sub_cid` = 0 AND (`url` LIKE '%github%' OR `url` LIKE '%gitlab%' OR `url` LIKE '%gitee%' OR `url` LIKE '%bitbucket%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 125 WHERE `sub_cid` = 0 AND (`url` LIKE '%csdn%' OR `url` LIKE '%juejin%' OR `url` LIKE '%stackoverflow%' OR `url` LIKE '%oschina%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 126 WHERE `sub_cid` = 0 AND (`url` LIKE '%aliyun%' OR `url` LIKE '%cloud.tencent%' OR `url` LIKE '%huaweicloud%' OR `url` LIKE '%qiniu%' OR `url` LIKE '%cloudflare%' OR `url` LIKE '%cloud.baidu%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 127 WHERE `sub_cid` = 0 AND (`url` LIKE '%zcool%' OR `url` LIKE '%huaban%' OR `url` LIKE '%ui.cn%' OR `url` LIKE '%699pic%' OR `url` LIKE '%58pic%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 128 WHERE `sub_cid` = 0 AND (`url` LIKE '%qidian%' OR `url` LIKE '%jjwxc%' OR `url` LIKE '%zongheng%' OR `url` LIKE '%hongxiu%' OR `url` LIKE '%xxsy%' OR `url` LIKE '%heiyan%' OR `url` LIKE '%fanqie%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 129 WHERE `sub_cid` = 0 AND (`url` LIKE '%weread%' OR `url` LIKE '%zhangyue%' OR `url` LIKE '%read.douban%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 130 WHERE `sub_cid` = 0 AND (`url` LIKE '%wps%' OR `url` LIKE '%office%' OR `url` LIKE '%yozosoft%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 131 WHERE `sub_cid` = 0 AND (`url` LIKE '%dingtalk%' OR `url` LIKE '%feishu%' OR `url` LIKE '%work.weixin%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 132 WHERE `sub_cid` = 0 AND (`url` LIKE '%pan.baidu%' OR `url` LIKE '%aliyundrive%' OR `url` LIKE '%lanzou%' OR `url` LIKE '%cloud.189%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 133 WHERE `sub_cid` = 0 AND (`url` LIKE '%music.163%' OR `url` LIKE '%y.qq%' OR `url` LIKE '%kugou%' OR `url` LIKE '%kuwo%' OR `url` LIKE '%spotify%' OR `url` LIKE '%music.apple%' OR `url` LIKE '%ximalaya%' OR `url` LIKE '%qtfm%' OR `url` LIKE '%lizhi%' OR `url` LIKE '%kg.qq%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 134 WHERE `sub_cid` = 0 AND (`url` LIKE '%eastmoney%' OR `url` LIKE '%10jqka%' OR `url` LIKE '%xueqiu%' OR `url` LIKE '%finance.sina%' OR `url` LIKE '%hexun%' OR `url` LIKE '%dzh%');
|
||||
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 135 WHERE `sub_cid` = 0 AND (`url` LIKE '%alipay%' OR `url` LIKE '%cmbchina%' OR `url` LIKE '%icbc%' OR `url` LIKE '%ccb%' OR `url` LIKE '%boc.cn%');
|
||||
|
||||
@@ -1,59 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{$title | default='YwxApp'}</title>
|
||||
<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">
|
||||
|
||||
<!-- HTML5 shim, for IE6-8 support of HTML5 elements. All other JS at the end of file. -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="__CDN__/assets/js/html5shiv.js"></script>
|
||||
<script src="__CDN__/assets/js/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
<script type="text/javascript">
|
||||
var config = {
|
||||
root: "/",
|
||||
};
|
||||
var require = {
|
||||
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-form layui-card-header layuiadmin-card-header-auto">
|
||||
<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 layuiadmin-btn-useradmin" lay-submit lay-filter="tableSearchButton">
|
||||
<i class="layui-icon layui-icon-search layuiadmin-button-btn"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card-header"> </div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="dataBarTpl">
|
||||
<script type="text/html" id="dataBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑节点" lay-event="update"><i class="layui-icon layui-icon-edit"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="添加子节点" lay-event="create"><i class="layui-icon layui-icon-add-1"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除节点" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<!-- 添加/编辑菜单表单模板 -->
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<!-- 添加/编辑菜单表单模板 -->
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
|
||||
<input type="hidden" name="id" value="{{= d.id || '' }}">
|
||||
<input type="hidden" name="pid" value="{{= d.pid || 0 }}">
|
||||
@@ -105,11 +67,11 @@
|
||||
</div>
|
||||
</form>
|
||||
</script>
|
||||
<!-- 状态开关 -->
|
||||
<script type="text/html" id="statusTpl">
|
||||
<!-- 状态开关 -->
|
||||
<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="dataRecybinTpl">
|
||||
<script type="text/html" id="dataRecybinTpl">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"> </div>
|
||||
<div class="layui-card-body">
|
||||
@@ -118,303 +80,294 @@
|
||||
</div>
|
||||
</script>
|
||||
|
||||
</script>
|
||||
<script src="/assets/layui/layui.js"></script>
|
||||
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
|
||||
<script>
|
||||
layui.use(['layer', 'http'], function () {
|
||||
var $ = layui.$;
|
||||
var treeTable = layui.treeTable;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var laytpl = layui.laytpl;
|
||||
var http = layui.http;
|
||||
console.log('layui.treeTable:', layui.setter.layerArea()); // 调试输出
|
||||
treeTable.render({
|
||||
defaultToolbar: [{
|
||||
title: '创建资源', layEvent: 'dataCreate', icon: 'layui-icon-add-1'
|
||||
}, {
|
||||
title: '删除资源', layEvent: 'dataDelete', icon: 'layui-icon-delete'
|
||||
}, {
|
||||
title: '资源回收站', layEvent: 'dataRecybin', icon: 'layui-icon-home'
|
||||
}, 'filter', 'exports', 'print'
|
||||
],
|
||||
elem: '#dataTable',
|
||||
url: 'category/index',
|
||||
parseData: function (res) { // 预处理返回数据
|
||||
console.log('原始数据:', res); // 调试输入
|
||||
return {
|
||||
code: res.code === 0 ? 0 : 1, // 0 标识成功
|
||||
data: res.data || [], // 数据数组
|
||||
msg: res.message || '' // 错误信息
|
||||
};
|
||||
},
|
||||
tree: {
|
||||
customName: {
|
||||
children: "children",
|
||||
isParent: "is_parent",
|
||||
name: "title",
|
||||
id: "id",
|
||||
pid: "pid",
|
||||
icon: "icon"
|
||||
},
|
||||
data: { isSimpleData: true, rootPid: 0 },
|
||||
view: {},
|
||||
async: {},
|
||||
callback: {}
|
||||
},
|
||||
height: 'full-100',
|
||||
toolbar: '#tableBarTpl',
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '名称', width: 180, fixed: 'left' },
|
||||
{ field: 'icon', title: '图标', width: 80 },
|
||||
{ field: 'sort', title: '排序', width: 80, sort: true },
|
||||
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
|
||||
{ fixed: "right", title: "操作", align: "center", toolbar: "#dataBarTpl", fixed: 'right' }
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) { }
|
||||
});
|
||||
treeTable.on('toolbar(dataTable)', function (obj) {
|
||||
var options = obj.config;
|
||||
switch (obj.event) {
|
||||
case 'dataCreate':
|
||||
active.dataCreate({ pid: 0, type: 1 });
|
||||
break;
|
||||
case 'dataDelete':
|
||||
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(ids);
|
||||
break;
|
||||
case 'dataRecybin':
|
||||
active.dataRecybin();
|
||||
break;
|
||||
<script>
|
||||
layui.use(['layer', 'http'], function () {
|
||||
var $ = layui.$;
|
||||
var treeTable = layui.treeTable;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var laytpl = layui.laytpl;
|
||||
var http = layui.http;
|
||||
var cols = [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '名称', width: 180, fixed: 'left' },
|
||||
{ field: 'icon', title: '图标', width: 80 },
|
||||
{ field: 'sort', title: '排序', width: 80, sort: true },
|
||||
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
|
||||
{ fixed: "right", title: "操作", align: "center", toolbar: "#dataBarTpl", fixed: 'right' }
|
||||
]];
|
||||
treeTable.render({
|
||||
elem: '#dataTable',
|
||||
url: 'index',
|
||||
parseData: function (res) { // 预处理返回数据
|
||||
console.log('原始数据:', res); // 调试输入
|
||||
return {
|
||||
code: res.code === 0 ? 0 : 1, // 0 标识成功
|
||||
data: res.data || [], // 数据数组
|
||||
msg: res.message || '' // 错误信息
|
||||
};
|
||||
},
|
||||
tree: {
|
||||
customName: {
|
||||
children: "children",
|
||||
isParent: "is_parent",
|
||||
name: "title",
|
||||
id: "id",
|
||||
pid: "pid",
|
||||
icon: "icon"
|
||||
},
|
||||
data: { isSimpleData: true, rootPid: 0 },
|
||||
view: {},
|
||||
async: {},
|
||||
callback: {}
|
||||
},
|
||||
height: 'full-100',
|
||||
toolbar: '#tableBarTpl',
|
||||
cols: cols,
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
var view = $('#dataTable').next('.layui-table-view');
|
||||
view.find('[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
$('.layui-btn[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
treeTable.on('toolbar(dataTable)', function (obj) {
|
||||
var options = obj.config;
|
||||
switch (obj.event) {
|
||||
case 'dataCreate':
|
||||
active.dataCreate({ pid: 0, type: 1 });
|
||||
break;
|
||||
case 'dataDelete':
|
||||
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(ids);
|
||||
break;
|
||||
case 'dataRecybin':
|
||||
active.dataRecybin();
|
||||
break;
|
||||
};
|
||||
});
|
||||
treeTable.on('tool(dataTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'update':
|
||||
active.dataEdit(data);
|
||||
break;
|
||||
case 'create':
|
||||
active.dataCreate({ pid: data.id });
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 状态开关
|
||||
form.on('switch(statusSwitch)', function (obj) {
|
||||
var data = this.value;
|
||||
var status = obj.elem.checked ? 1 : 0;
|
||||
console.log(data);
|
||||
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该数据吗?', function (index) {
|
||||
layui.http.put('update', { id: data.id,title:data.title, status: status }).then(function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.msg, { icon: 1 });
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg, { icon: 2 });
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
}
|
||||
});
|
||||
layer.close(index);
|
||||
}, function () {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
});
|
||||
treeTable.on('tool(dataTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'update':
|
||||
active.dataEdit(data);
|
||||
break;
|
||||
case 'create':
|
||||
active.dataCreate({ pid: data.id });
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
});
|
||||
|
||||
var dataFromFun = function (data, callback, done) {
|
||||
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: data.id ? '编辑数据' : (data.pid ? '添加子数据' : '添加根数据'),
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
btnAlign: "l",
|
||||
area: ['520px', '98%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
btn: ['确定', '取消'],
|
||||
success: function (layero, index) {
|
||||
callback(layero, index);
|
||||
form.render();
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
|
||||
done(layero, index, elem);
|
||||
layui.off('submit(wxapp-form)', 'from');
|
||||
return false;
|
||||
});
|
||||
layero.contents().find("#wxapp-form-submit").trigger('click');
|
||||
}
|
||||
});
|
||||
|
||||
form.on('submit(tableSearchButton)', function (data) {
|
||||
var field = data.field;
|
||||
treeTable.reload('dataTable', {
|
||||
where: field
|
||||
});
|
||||
});
|
||||
// 状态开关
|
||||
form.on('switch(statusSwitch)', function (obj) {
|
||||
var id = this.value;
|
||||
var status = obj.elem.checked ? 1 : 0;
|
||||
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该数据吗?', function (index) {
|
||||
layui.http.put('update', { id: id, status: status }).then(function (res) {
|
||||
if (res.code == 0) {
|
||||
layer.msg(res.msg, { icon: 1 });
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg, { icon: 2 });
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
}
|
||||
});
|
||||
};
|
||||
//事件
|
||||
var active = {
|
||||
dataCreate: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.post('save', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataEdit: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.put('update', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataDelete: function (ids, force = 0) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
layer.prompt({
|
||||
formType: 1
|
||||
, title: '敏感操作,请验证口令'
|
||||
}, function (value, index) {
|
||||
layer.close(index);
|
||||
}, function () {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
layer.confirm('确定删除吗?', function (index) {
|
||||
http.delete('delete', { ids: ids.join(','), force: force })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
layer.msg('已删除');
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var dataFromFun = function (data, callback, done) {
|
||||
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
|
||||
},
|
||||
dataRecybin: function (data = {}) {
|
||||
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: data.id ? '编辑数据' : (data.pid ? '添加子数据' : '添加根数据'),
|
||||
title: '资源回收站',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
btnAlign: "l",
|
||||
area: layui.setter.layerArea(),
|
||||
area: ['60%', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
btn: ['确定', '取消'],
|
||||
success: function (layero, index) {
|
||||
callback(layero, index);
|
||||
form.render();
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
|
||||
done(layero, index, elem);
|
||||
layui.off('submit(wxapp-form)', 'from');
|
||||
return false;
|
||||
});
|
||||
layero.contents().find("#wxapp-form-submit").trigger('click');
|
||||
}
|
||||
});
|
||||
};
|
||||
//事件
|
||||
var active = {
|
||||
dataCreate: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.icon = 'layui-icon ' + field.icon;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.post('save', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataEdit: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.icon = 'layui-icon ' + field.icon;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.put('update', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataDelete: function (ids, force = 0) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
layer.prompt({
|
||||
formType: 1
|
||||
, title: '敏感操作,请验证口令'
|
||||
}, function (value, index) {
|
||||
layer.close(index);
|
||||
layer.confirm('确定删除吗?', function (index) {
|
||||
http.delete('delete', { ids: ids.join(','), force: force })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
layer.msg('已删除');
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
});
|
||||
});
|
||||
},
|
||||
dataRecybin: function (data = {}) {
|
||||
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: '资源回收站',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
area: ['60%', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
success: function (layero, index) {
|
||||
layui.table.render({
|
||||
elem: '#dataRecybinTable',
|
||||
url: 'recyclebin',
|
||||
height: 'full-100',
|
||||
defaultToolbar: [{
|
||||
title: '批量删除数据',
|
||||
layEvent: 'dataDelete',
|
||||
icon: 'layui-icon-delete',
|
||||
onClick: function (obj) {
|
||||
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
console.log(ids);
|
||||
}
|
||||
}, 'filter', 'exports', 'print'],
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '标题', width: 180, fixed: 'left' },
|
||||
{
|
||||
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
|
||||
return `<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
|
||||
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div >`;
|
||||
}
|
||||
}
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
layui.table.on('tool(dataRecybinTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'restore':
|
||||
active.dataRestore([data.id]);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id], 1);
|
||||
break;
|
||||
}
|
||||
});
|
||||
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let idss = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(idss, 1);
|
||||
break;
|
||||
case 'dataRestore':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataRestore(ids);
|
||||
break;
|
||||
};
|
||||
});
|
||||
layui.table.render({
|
||||
elem: '#dataRecybinTable',
|
||||
url: 'recyclebin',
|
||||
height: 'full-100',
|
||||
defaultToolbar: [{
|
||||
title: '批量删除数据',
|
||||
layEvent: 'dataDelete',
|
||||
icon: 'layui-icon-delete',
|
||||
onClick: function (obj) {
|
||||
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
console.log(ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
dataRestore: function (ids) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
http.put('restore', { ids: ids.join(',') })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.msg('恢复成功');
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '恢复失败');
|
||||
}, 'filter', 'exports', 'print'],
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: 'title', width: 180, fixed: 'left' },
|
||||
{
|
||||
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
|
||||
return `<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
|
||||
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div >`;
|
||||
}
|
||||
}
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
layui.table.on('tool(dataRecybinTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'restore':
|
||||
active.dataRestore([data.id]);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id], 1);
|
||||
break;
|
||||
}
|
||||
});
|
||||
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let idss = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(idss, 1);
|
||||
break;
|
||||
case 'dataRestore':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataRestore(ids);
|
||||
break;
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
dataRestore: function (ids) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
http.put('restore', { ids: ids.join(',') })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.msg('恢复成功');
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '恢复失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
</html>
|
||||
</script>
|
||||
@@ -62,18 +62,63 @@
|
||||
<div class="section-title">
|
||||
<h2>📋 {$info.title}</h2>
|
||||
</div>
|
||||
<div class="website-list">
|
||||
{volist name="info.links" id="link"}
|
||||
<a href="/haonav/site/{$link.id}.html" class="website-item">
|
||||
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
|
||||
<span class="website-item-info">
|
||||
<h3>{$link.title}</h3>
|
||||
<p>{$link.description|default='暂无描述'}</p>
|
||||
</span>
|
||||
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码">▦</span>
|
||||
</a>
|
||||
{/volist}
|
||||
|
||||
{if $mode == 'group'}
|
||||
<!-- 顶级分类:按二级子分类分组展示 -->
|
||||
{volist name="info.children" id="sub"}
|
||||
<div class="sub-category">
|
||||
<div class="sub-title"><span>{$sub.title}</span></div>
|
||||
<div class="website-list">
|
||||
{volist name="sub.sub_links" id="link"}
|
||||
<a href="/haonav/site/{$link.id}.html" class="website-item">
|
||||
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
|
||||
<span class="website-item-info">
|
||||
<h3>{$link.title}</h3>
|
||||
<p>{$link.description|default='暂无描述'}</p>
|
||||
</span>
|
||||
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码">▦</span>
|
||||
</a>
|
||||
{/volist}
|
||||
</div>
|
||||
</div>
|
||||
{/volist}
|
||||
|
||||
<!-- 未细分二级的常用链接(sub_cid=0) -->
|
||||
{notempty name="others"}
|
||||
<div class="sub-category">
|
||||
<div class="sub-title"><span>常用</span></div>
|
||||
<div class="website-list">
|
||||
{volist name="others" id="link"}
|
||||
<a href="/haonav/site/{$link.id}.html" class="website-item">
|
||||
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
|
||||
<span class="website-item-info">
|
||||
<h3>{$link.title}</h3>
|
||||
<p>{$link.description|default='暂无描述'}</p>
|
||||
</span>
|
||||
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码">▦</span>
|
||||
</a>
|
||||
{/volist}
|
||||
</div>
|
||||
</div>
|
||||
{/notempty}
|
||||
{else /}
|
||||
<!-- 二级/叶子分类:直接列出该分类下的全部链接 -->
|
||||
<div class="sub-category">
|
||||
<div class="sub-title"><span>{$info.title}</span></div>
|
||||
<div class="website-list">
|
||||
{volist name="links" id="link"}
|
||||
<a href="/haonav/site/{$link.id}.html" class="website-item">
|
||||
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
|
||||
<span class="website-item-info">
|
||||
<h3>{$link.title}</h3>
|
||||
<p>{$link.description|default='暂无描述'}</p>
|
||||
</span>
|
||||
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码">▦</span>
|
||||
</a>
|
||||
{/volist}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
|
||||
@@ -143,21 +143,28 @@
|
||||
<!-- 分类区块 -->
|
||||
<div class="category-block" id="cat-{$k}">
|
||||
<div class="section-title">
|
||||
<h2>📋 <a href="/haonav/category/{$vo.id}.html" target="_blank"> {$vo.title} </a></h2>
|
||||
<h2>📋 <a href="/haonav/category/{$vo.id}.html" target="_blank"> {$vo.title} </a></h2> <div class="sub-title"><span>全部</span> <a class="sub-more" href="/haonav/category/{$vo.id}.html">更多 ›</a></div>
|
||||
|
||||
</div>
|
||||
<div class="website-grid">
|
||||
{volist name="vo.links" id="site"}
|
||||
<div class="website-card" data-sid="{$site.id}">
|
||||
<span class="fav-btn js-fav" data-id="{$site.id}" data-title="{$site.title}" data-url="{$site.url}" data-icon="{$site.show_icon}" title="收藏">★</span>
|
||||
<span class="qr-btn js-qr" data-url="{$site.url}" title="二维码">▦</span>
|
||||
<a href="/haonav/site/{$site.id}.html" target="_blank" title="{$site.title}" style="text-decoration:none;color:inherit;display:block;">
|
||||
<div class="website-icon"><img src="{$site.show_icon}" loading="lazy" alt="{$site.title}"></div>
|
||||
<div class="website-name">{$site.title}</div>
|
||||
{if $site.rating_count > 0}<div class="website-rating">⭐ {$site.rating}<span class="rc">({$site.rating_count})</span></div>{/if}
|
||||
</a>
|
||||
|
||||
<!-- 首页只展示一级分类下的全部链接,二级分类在分类页展示 -->
|
||||
{notempty name="vo.links"}
|
||||
<div class="sub-category">
|
||||
<div class="website-grid">
|
||||
{volist name="vo.links" id="site"}
|
||||
<div class="website-card" data-sid="{$site.id}">
|
||||
<span class="fav-btn js-fav" data-id="{$site.id}" data-title="{$site.title}" data-url="{$site.url}" data-icon="{$site.show_icon}" title="收藏">★</span>
|
||||
<span class="qr-btn js-qr" data-url="{$site.url}" title="二维码">▦</span>
|
||||
<a href="/haonav/site/{$site.id}.html" target="_blank" title="{$site.title}" style="text-decoration:none;color:inherit;display:block;">
|
||||
<div class="website-icon"><img src="{$site.show_icon}" loading="lazy" alt="{$site.title}"></div>
|
||||
<div class="website-name">{$site.title}</div>
|
||||
{if $site.rating_count > 0}<div class="website-rating">⭐ {$site.rating}<span class="rc">({$site.rating_count})</span></div>{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/volist}
|
||||
</div>
|
||||
{/volist}
|
||||
</div>
|
||||
{/notempty}
|
||||
</div>
|
||||
{/volist}
|
||||
|
||||
|
||||
+14
-14
@@ -18,7 +18,7 @@ use ywxapp\AddonBase;
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Addon extends addon
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
/**
|
||||
* 安装钩子:表由 install.sql 统一创建,这里无需额外处理
|
||||
@@ -33,19 +33,19 @@ class Addon extends addon
|
||||
*/
|
||||
public function uninstall()
|
||||
{
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_connection`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_message`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_topic`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_auth`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_acl`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_retain`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_offline`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_stats`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_rule`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_outbox`");
|
||||
// 清理框架菜单(后端权限 + 前端/会员规则)
|
||||
Db::execute("DELETE FROM wxapp_user_rule WHERE name LIKE 'mqttbroker:%'");
|
||||
Db::execute("DELETE FROM wxapp_admin_power WHERE addon='mqttbroker'");
|
||||
$prefix = Db::getConfig('prefix');
|
||||
$tables = [
|
||||
'mqttbroker_connection', 'mqttbroker_message', 'mqttbroker_topic',
|
||||
'mqttbroker_auth', 'mqttbroker_acl', 'mqttbroker_retain',
|
||||
'mqttbroker_offline', 'mqttbroker_stats', 'mqttbroker_rule',
|
||||
'mqttbroker_outbox',
|
||||
];
|
||||
foreach ($tables as $t) {
|
||||
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
|
||||
}
|
||||
// 清理框架菜单(后端权限 + 前端/会员规则),前缀同样动态获取
|
||||
Db::execute("DELETE FROM `{$prefix}user_rule` WHERE name LIKE 'mqttbroker:%'");
|
||||
Db::execute("DELETE FROM `{$prefix}admin_power` WHERE addon='mqttbroker'");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestAcl extends Command
|
||||
|
||||
// 插入临时规则(测试后清理)
|
||||
$rows = [
|
||||
['target_type' => 'member', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'],
|
||||
['target_type' => 'user', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'],
|
||||
['target_type' => 'all', 'target' => '', 'topic' => 'secret/#', 'access' => 2, 'allow' => 0, 'sort' => 10, 'remark' => 'tmp'],
|
||||
['target_type' => 'all', 'target' => '', 'topic' => 'public/#', 'access' => 3, 'allow' => 1, 'sort' => 20, 'remark' => 'tmp'],
|
||||
];
|
||||
|
||||
@@ -75,6 +75,22 @@ return [
|
||||
'value' => 'allow',
|
||||
'tip' => '所有规则都未命中时的兜底策略',
|
||||
],
|
||||
[
|
||||
'name' => 'allow_member_login',
|
||||
'title' => '允许框架会员登录',
|
||||
'type' => 'select',
|
||||
'options' => ['0' => '关闭', '1' => '开启'],
|
||||
'value' => '0',
|
||||
'tip' => '开启后,框架 member_user 表的会员可用其账号密码直接连接 Broker(密码算法一致),无需在"认证账号"中重复建号',
|
||||
],
|
||||
[
|
||||
'name' => 'member_is_superuser',
|
||||
'title' => '会员默认超管',
|
||||
'type' => 'select',
|
||||
'options' => ['0' => '否', '1' => '是'],
|
||||
'value' => '0',
|
||||
'tip' => '会员直通连接是否视为超级用户(跳过 ACL);建议保持"否",用 ACL 规则按 %u 隔离',
|
||||
],
|
||||
[
|
||||
'name' => 'max_keepalive',
|
||||
'title' => '最大保活间隔(秒)',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?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 addon\mqttbroker\controller\api;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use addon\mqttbroker\service\Store;
|
||||
|
||||
/**
|
||||
* App 端 MQTT API(需框架会员 JWT 登录)
|
||||
* 路由前缀:/mqttbroker/api/...
|
||||
*/
|
||||
class App extends ApiBase
|
||||
{
|
||||
/**
|
||||
* 当前会员账号(Broker 连接用户名通常为 member.account)
|
||||
*/
|
||||
protected function memberAccount(): string
|
||||
{
|
||||
return (string) ($this->auth->model->account ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的设备 / 连接列表
|
||||
* GET /mqttbroker/api/devices
|
||||
*/
|
||||
public function devices()
|
||||
{
|
||||
$account = $this->memberAccount();
|
||||
if ($account === '') {
|
||||
return $this->result->error('无法获取当前会员账号');
|
||||
}
|
||||
$list = Db::name('mqttbroker_connection')
|
||||
->where('username', $account)
|
||||
->order('update_at', 'desc')
|
||||
->limit(200)
|
||||
->field('client_id,username,ip,status,create_at,update_at')
|
||||
->select()
|
||||
->toArray();
|
||||
return $this->result->success(['list' => $list, 'total' => count($list)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的订阅主题列表
|
||||
* GET /mqttbroker/api/subscriptions
|
||||
*/
|
||||
public function subscriptions()
|
||||
{
|
||||
$account = $this->memberAccount();
|
||||
if ($account === '') {
|
||||
return $this->result->error('无法获取当前会员账号');
|
||||
}
|
||||
$clientIds = Db::name('mqttbroker_connection')
|
||||
->where('username', $account)
|
||||
->column('client_id');
|
||||
$list = [];
|
||||
if ($clientIds) {
|
||||
$list = Db::name('mqttbroker_topic')
|
||||
->whereIn('client_id', $clientIds)
|
||||
->order('id', 'desc')
|
||||
->field('client_id,topic,qos,create_at')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
return $this->result->success(['list' => $list, 'total' => count($list)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息(服务端代发,强制命名空间隔离)
|
||||
* POST /mqttbroker/api/publish
|
||||
* param: topic, payload, qos(0/1/2), retain(0/1)
|
||||
*/
|
||||
public function publish()
|
||||
{
|
||||
$account = $this->memberAccount();
|
||||
if ($account === '') {
|
||||
return $this->result->error('无法获取当前会员账号');
|
||||
}
|
||||
$topic = trim((string) $this->request->post('topic', ''));
|
||||
$payload = (string) $this->request->post('payload', '');
|
||||
$qos = (int) $this->request->post('qos', 0);
|
||||
$retain = (int) $this->request->post('retain', 0);
|
||||
|
||||
if ($topic === '' || $topic[0] === '$') {
|
||||
return $this->result->error('主题不能为空,且禁止发布到 $SYS 等系统主题');
|
||||
}
|
||||
// 命名空间隔离:topic 必须以 {username}/ 开头,避免越权发到他人主题
|
||||
$prefix = $account . '/';
|
||||
if (strncmp($topic, $prefix, strlen($prefix)) !== 0) {
|
||||
return $this->result->error("主题必须以 {$prefix} 开头(命名空间隔离)");
|
||||
}
|
||||
if (!in_array($qos, [0, 1, 2], true)) {
|
||||
$qos = 0;
|
||||
}
|
||||
$ok = Store::enqueueManual($topic, $payload, $qos, $retain, 'app');
|
||||
if (!$ok) {
|
||||
return $this->result->error('发布失败,请稍后重试');
|
||||
}
|
||||
return $this->result->success(['topic' => $topic]);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ declare (strict_types = 1);
|
||||
namespace addon\mqttbroker\controller\api;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use addon\mqttbroker\model\Message;
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,7 @@ use addon\mqttbroker\model\Message;
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Mqtt extends ApiController
|
||||
class Mqtt extends ApiBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
@@ -26,9 +26,8 @@ use addon\mqttbroker\service\Store;
|
||||
*/
|
||||
class MqttBroker extends BackendBase
|
||||
{
|
||||
// 与 blog 后台一致:开发期放宽登录校验(生产请改回需登录)
|
||||
// 插件后台由 BackendBase 强制登录校验,未登录访问一律拦截
|
||||
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 概览
|
||||
@@ -232,6 +231,14 @@ class MqttBroker extends BackendBase
|
||||
* ========================================================== */
|
||||
|
||||
public function stats()
|
||||
{
|
||||
return View::fetch('admin/stats');
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行监控数据接口(供 stats 页面轮询,避免与页面路由冲突)
|
||||
*/
|
||||
public function statsData()
|
||||
{
|
||||
$row = Db::name('mqttbroker_stats')->where('id', 1)->find();
|
||||
if (!$row) {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-07-10 17:47:52
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-17 17:41:29
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\mqttbroker\info.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
|
||||
return [
|
||||
'name' => 'mqttbroker',
|
||||
@@ -6,8 +15,8 @@ return [
|
||||
'intro' => '基于 Workerman 自研的 MQTT 3.1.1/5.0 Broker(类 EMQX 轻量版):认证/ACL、WebSocket/TLS、保留与离线消息持久化、共享订阅、$SYS 指标、Redis 多进程桥接、实时仪表盘、转发规则(桥接EMQX)、手动发布零依赖出站队列,含后台管理',
|
||||
'author' => '',
|
||||
'website' => '',
|
||||
'version' => '2.1.1',
|
||||
'state' => 0,
|
||||
'version' => '2.1.2',
|
||||
'state' => 1,
|
||||
'url' => '/mqttbroker/backend',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
|
||||
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_auth` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(191) NOT NULL DEFAULT '' COMMENT '用户名',
|
||||
`password` varchar(191) NOT NULL DEFAULT '' COMMENT '密码(bcrypt哈希,兼容明文)',
|
||||
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(0=非会员账号;会员直通时回填对应 member_user.id)',
|
||||
`is_superuser` tinyint(1) NOT NULL DEFAULT '0' COMMENT '超级用户跳过ACL',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
|
||||
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
|
||||
|
||||
@@ -28,6 +28,7 @@ Route::group('backend', function () {
|
||||
Route::post('deleteAcl', 'backend/MqttBroker/deleteAcl');
|
||||
// 实时监控
|
||||
Route::get('stats', 'backend/MqttBroker/stats');
|
||||
Route::get('statsData', 'backend/MqttBroker/statsData');
|
||||
// 转发规则(规则引擎 / 桥接到 EMQX)
|
||||
Route::get('rule', 'backend/MqttBroker/rule');
|
||||
Route::post('saveRule', 'backend/MqttBroker/saveRule');
|
||||
@@ -38,4 +39,11 @@ Route::group('backend', function () {
|
||||
Route::group('api', function () {
|
||||
Route::get('status', 'api/Mqtt/status');
|
||||
Route::post('publish', 'api/Mqtt/publish');
|
||||
|
||||
// App 端接口(需会员 JWT 登录):/mqttbroker/api/app/...
|
||||
Route::group('app', function () {
|
||||
Route::get('devices', 'api/App/devices');
|
||||
Route::get('subscriptions', 'api/App/subscriptions');
|
||||
Route::post('publish', 'api/App/publish');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,12 @@ class Auth
|
||||
`create_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`), KEY `idx_sort` (`sort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
// 自愈:老表补 member_id 列(框架会员直通用)
|
||||
$prefix = Db::getConfig('prefix');
|
||||
$has = Db::query("SHOW COLUMNS FROM `{$prefix}mqttbroker_auth` LIKE 'member_id'");
|
||||
if (empty($has)) {
|
||||
Db::execute("ALTER TABLE `{$prefix}mqttbroker_auth` ADD `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(会员直通回填)'");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
@@ -120,7 +126,27 @@ class Auth
|
||||
}
|
||||
|
||||
if (!$acc) {
|
||||
// 无此账号:匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝
|
||||
// 无此账号:尝试框架会员直通(仅当显式开启且禁止匿名,避免被匿名语义绕过)
|
||||
if (!$anonymous && !empty($this->config['allow_member_login'])) {
|
||||
$member = null;
|
||||
try {
|
||||
$member = Db::name('member_user')
|
||||
->where('account', $username)
|
||||
->where('status', 1)
|
||||
->field('id,password')
|
||||
->find();
|
||||
} catch (\Throwable $e) {
|
||||
$member = null;
|
||||
}
|
||||
// 会员密码使用 bcrypt 原生盐(详见 ywxapp\model\MemberUser),
|
||||
// 表无独立 salt 列,直接 verify 即可。
|
||||
if ($member && !empty($member['password'])
|
||||
&& password_verify((string) $password, $member['password'])) {
|
||||
$isSuper = !empty($this->config['member_is_superuser']);
|
||||
return ['ok' => true, 'superuser' => $isSuper, 'reason' => 'member', 'member_id' => $member['id']];
|
||||
}
|
||||
}
|
||||
// 匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝
|
||||
return $anonymous
|
||||
? ['ok' => true, 'superuser' => false, 'reason' => 'anonymous-named']
|
||||
: ['ok' => false, 'superuser' => false, 'reason' => 'account not found'];
|
||||
@@ -170,7 +196,7 @@ class Auth
|
||||
continue;
|
||||
}
|
||||
$type = $r['target_type'];
|
||||
if ($type === 'member' && $r['target'] !== $username) {
|
||||
if ($type === 'user' && $r['target'] !== $username) {
|
||||
continue;
|
||||
}
|
||||
if ($type === 'client' && $r['target'] !== $clientId) {
|
||||
|
||||
@@ -35,3 +35,11 @@ ALTER TABLE `__PREFIX__mqttbroker_rule`
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_outbox`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL;
|
||||
|
||||
-- 会员直通用:补 member_id 列(兼容已装旧库;重复执行不会报错)
|
||||
SET @db = DATABASE();
|
||||
SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = '__PREFIX__mqttbroker_auth' AND COLUMN_NAME = 'member_id');
|
||||
SET @sql = IF(@has = 0, 'ALTER TABLE `__PREFIX__mqttbroker_auth` ADD `member_id` int unsigned NOT NULL DEFAULT 0 COMMENT ''归属框架会员ID(会员直通回填)''', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{extend name="layout"}
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">ACL 权限规则</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
需在"服务设置"中开启 ACL 才生效。按 sort 升序匹配,命中第一条即决定放行/拒绝;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{extend name="layout"}
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">认证账号</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
当"允许匿名连接"关闭时,客户端必须使用此处账号连接;超级用户跳过 ACL 校验。
|
||||
@@ -8,7 +6,7 @@
|
||||
<button class="layui-btn layui-btn-sm" onclick="editAuth()">+ 新增账号</button>
|
||||
<table class="layui-table" style="margin-top:10px;">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>用户名</th><th>超级用户</th><th>状态</th><th>备注</th><th>操作</th></tr>
|
||||
<tr><th>ID</th><th>用户名</th><th>超级用户</th><th>会员</th><th>状态</th><th>备注</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $list as $c}
|
||||
@@ -16,6 +14,7 @@
|
||||
<td>{$c.id}</td>
|
||||
<td>{$c.username}</td>
|
||||
<td>{if $c.is_superuser}<span class="layui-badge layui-bg-orange">是</span>{else}否{/if}</td>
|
||||
<td>{if $c.member_id}<span class="layui-badge layui-bg-blue">M{$c.member_id}</span>{else}<span style="color:#aaa;">—</span>{/if}</td>
|
||||
<td>{if $c.status}<span class="layui-badge layui-bg-green">启用</span>{else}<span class="layui-badge">禁用</span>{/if}</td>
|
||||
<td>{$c.remark}</td>
|
||||
<td>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{extend name="layout"}
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">转发规则(规则引擎 / 桥接到 EMQX)</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
匹配"源主题过滤器"的消息,将被转发到外部 MQTT Broker(如 EMQX)。目标主题支持
|
||||
|
||||
@@ -59,7 +59,7 @@ layui.use(['jquery'], function(){
|
||||
return p(h)+':'+p(m)+':'+p(ss);
|
||||
}
|
||||
function load(){
|
||||
$.getJSON('/mqttbroker/backend/stats', function(res){
|
||||
$.getJSON('/mqttbroker/backend/statsData', function(res){
|
||||
if (res.code !== 0 || !res.data) return;
|
||||
var d = res.data;
|
||||
$('#m_online').text(d.db_online != null ? d.db_online : d.clients_online);
|
||||
|
||||
+6
-10
@@ -10,24 +10,21 @@ declare (strict_types = 1);
|
||||
|
||||
namespace addon\smsbao;
|
||||
|
||||
use ywxapp\AddonBase;
|
||||
use ywxapp\library\Menu;
|
||||
use ywxapp\AddonBase;
|
||||
|
||||
/**
|
||||
* Addon 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Addon extends addon
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
/**
|
||||
* 插件安装方法
|
||||
* @return bool
|
||||
*/
|
||||
public function install()
|
||||
{
|
||||
$menu = [];
|
||||
Menu::create($menu);
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -36,8 +33,7 @@ class Addon extends addon
|
||||
* @return bool
|
||||
*/
|
||||
public function uninstall()
|
||||
{
|
||||
Menu::delete('smsbao');
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -46,7 +42,7 @@ class Addon extends addon
|
||||
*/
|
||||
public function enable()
|
||||
{
|
||||
Menu::enable('smsbao');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +50,7 @@ class Addon extends addon
|
||||
*/
|
||||
public function disable()
|
||||
{
|
||||
Menu::disable('smsbao');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
// 默认值仅为迁移占位,请在生产环境替换为自有账号与签名。
|
||||
return [
|
||||
[
|
||||
'name' => 'u',
|
||||
'name' => 'user',
|
||||
'title' => '短信宝账号',
|
||||
'type' => 'string',
|
||||
'value' => 'cqmqzp',
|
||||
'tip' => '短信宝平台账号,请替换为自己的账号',
|
||||
],
|
||||
[
|
||||
'name' => 'p',
|
||||
'name' => 'pass',
|
||||
'title' => '短信宝密码',
|
||||
'type' => 'string',
|
||||
'value' => 'a645b6f80c0a40b69156ca0fd9d47d7c',
|
||||
|
||||
+8
-12
@@ -6,22 +6,18 @@ return [
|
||||
'intro' => '',
|
||||
'author' => '',
|
||||
'website' => '',
|
||||
'version' => '1.0.1',
|
||||
'state' => 0,
|
||||
'version' => '1.0.2',
|
||||
'state' => 1,
|
||||
'url' => '/smsbao',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
'config' => [
|
||||
],
|
||||
'events' => [
|
||||
'subscribe' =>
|
||||
[
|
||||
0 => 'addon\\smsbao\\subscribe\\Smsbao',
|
||||
'config' => [ ],
|
||||
'events' => [
|
||||
'subscribe' => [
|
||||
'addon\smsbao\subscribe\Smsbao',
|
||||
],
|
||||
],
|
||||
'middleware' => [
|
||||
],
|
||||
'services' => [
|
||||
],
|
||||
'middleware' => [ ],
|
||||
'services' => [ ],
|
||||
'update_time' => 1786365220,
|
||||
];
|
||||
@@ -17,7 +17,7 @@ use ywxapp\AddonBase;
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Addon extends addon
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
|
||||
public function install()
|
||||
|
||||
@@ -12,8 +12,8 @@ return [
|
||||
'intro' => '生产环境真正下发短信:监听 SmsSend 事件,调用可配置网关 HTTP 接口',
|
||||
'author' => 'Ywxapp',
|
||||
'website' => '',
|
||||
'version' => '1.0.1',
|
||||
'state' => 1,
|
||||
'version' => '1.0.2',
|
||||
'state' => 0,
|
||||
'url' => '/smssend',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
|
||||
@@ -53,7 +53,7 @@ class Friend extends FrontendBase
|
||||
->select();
|
||||
$list = [];
|
||||
foreach ($friends as $f) {
|
||||
$u = Db::name('member')->where('uid', $f->fid)
|
||||
$u = Db::name('member_user')->where('uid', $f->fid)
|
||||
->field('uid,nickname,avatar,phone')->find();
|
||||
if ($u) {
|
||||
$u['friend_id'] = $f->id;
|
||||
@@ -76,7 +76,7 @@ class Friend extends FrontendBase
|
||||
->select();
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$u = Db::name('member')->where('uid', $r->uid)
|
||||
$u = Db::name('member_user')->where('uid', $r->uid)
|
||||
->field('uid,nickname,avatar')->find();
|
||||
if ($u) {
|
||||
$u['friend_id'] = $r->id;
|
||||
@@ -151,7 +151,7 @@ class Friend extends FrontendBase
|
||||
if (! $row) {
|
||||
$this->error('好友不存在');
|
||||
}
|
||||
$u = Db::name('member')->where('uid', $row->fid)
|
||||
$u = Db::name('member_user')->where('uid', $row->fid)
|
||||
->field('uid,nickname,avatar,phone')->find();
|
||||
if ($u) {
|
||||
$u['friend_id'] = $row->id;
|
||||
|
||||
@@ -67,7 +67,7 @@ class Gift extends FrontendBase
|
||||
if (! $gift) {
|
||||
$this->error('礼物不存在');
|
||||
}
|
||||
$receiver = Db::name('member')->where('uid', $toUid)->value('uid');
|
||||
$receiver = Db::name('member_user')->where('uid', $toUid)->value('uid');
|
||||
if (! $receiver) {
|
||||
$this->error('接收用户不存在');
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ class Login extends FrontendBase
|
||||
if (! $ret) {
|
||||
$this->error("验证码不正确!");
|
||||
}
|
||||
$user = Db::name('member')->where('mobile', $mobile)->where('status', '>=', 0)->find();
|
||||
$user = Db::name('member_user')->where('mobile', $mobile)->where('status', '>=', 0)->find();
|
||||
if (empty($user)) {
|
||||
$uid = Db::name('member')->insertGetId([
|
||||
$uid = Db::name('member_user')->insertGetId([
|
||||
'account' => $mobile,
|
||||
'mobile' => $mobile,
|
||||
'nickname' => $this->generateRandomNickname(),
|
||||
@@ -78,9 +78,9 @@ class Login extends FrontendBase
|
||||
'update_at' => time(),
|
||||
'update_ip' => Request::ip(),
|
||||
]);
|
||||
$user = Db::name('member')->find($uid);
|
||||
$user = Db::name('member_user')->find($uid);
|
||||
} else {
|
||||
Db::name('member')->where('uid', $user['uid'])->update([
|
||||
Db::name('member_user')->where('uid', $user['uid'])->update([
|
||||
'update_at' => time(),
|
||||
'update_ip' => Request::ip(),
|
||||
]);
|
||||
@@ -114,9 +114,9 @@ class Login extends FrontendBase
|
||||
}
|
||||
Cache::delete('wxchat_oneclick_' . $preToken);
|
||||
|
||||
$user = Db::name('member')->where('mobile', $phone)->where('status', '>=', 0)->find();
|
||||
$user = Db::name('member_user')->where('mobile', $phone)->where('status', '>=', 0)->find();
|
||||
if (empty($user)) {
|
||||
$uid = Db::name('member')->insertGetId([
|
||||
$uid = Db::name('member_user')->insertGetId([
|
||||
'account' => $phone,
|
||||
'mobile' => $phone,
|
||||
'nickname' => $this->generateRandomNickname(),
|
||||
@@ -127,9 +127,9 @@ class Login extends FrontendBase
|
||||
'update_at' => time(),
|
||||
'update_ip' => Request::ip(),
|
||||
]);
|
||||
$user = Db::name('member')->find($uid);
|
||||
$user = Db::name('member_user')->find($uid);
|
||||
} else {
|
||||
Db::name('member')->where('uid', $user['uid'])->update([
|
||||
Db::name('member_user')->where('uid', $user['uid'])->update([
|
||||
'update_at' => time(),
|
||||
'update_ip' => Request::ip(),
|
||||
]);
|
||||
@@ -167,7 +167,7 @@ class Login extends FrontendBase
|
||||
$phone = $tempData['phone'];
|
||||
|
||||
// 检查手机号是否已被注册
|
||||
$exists = Db::name('member')
|
||||
$exists = Db::name('member_user')
|
||||
->where('mobile', $phone)
|
||||
->where('status', 1)
|
||||
->find();
|
||||
@@ -196,7 +196,7 @@ class Login extends FrontendBase
|
||||
'update_ip' => Request::ip(),
|
||||
];
|
||||
|
||||
$userId = Db::name('member')->insertGetId($userData);
|
||||
$userId = Db::name('member_user')->insertGetId($userData);
|
||||
if (! $userId) {
|
||||
throw new \Exception('用户注册失败');
|
||||
}
|
||||
@@ -205,7 +205,7 @@ class Login extends FrontendBase
|
||||
$this->issueToken($userId);
|
||||
$this->logUserRegister($userId, $userData);
|
||||
|
||||
$user = Db::name('member')->find($userId);
|
||||
$user = Db::name('member_user')->find($userId);
|
||||
$this->success([
|
||||
'user_info' => $this->formatUserInfo($user),
|
||||
'is_registered' => true,
|
||||
|
||||
@@ -178,7 +178,7 @@ class Task extends FrontendBase
|
||||
->where('create_at', '>=', $today)
|
||||
->count();
|
||||
case 'profile':
|
||||
$user = Db::name('member')->where('uid', $uid)->field('nickname,avatar')->find();
|
||||
$user = Db::name('member_user')->where('uid', $uid)->field('nickname,avatar')->find();
|
||||
$profile = Db::name('member_profile')->where('uid', $uid)
|
||||
->field('bio,birthday,gender')->find();
|
||||
$score = 0;
|
||||
|
||||
@@ -58,7 +58,7 @@ class Member extends FrontendBase
|
||||
if (! $profile) {
|
||||
$this->error('用户不存在');
|
||||
}
|
||||
$user = Db::name('member')->where('uid', $targetUid)
|
||||
$user = Db::name('member_user')->where('uid', $targetUid)
|
||||
->field('uid,nickname,avatar,account,status')->find();
|
||||
|
||||
$pc = ! empty($profile->privacy_config) ? json_decode($profile->privacy_config, true) : [];
|
||||
@@ -538,7 +538,7 @@ class Member extends FrontendBase
|
||||
$limit = 50;
|
||||
|
||||
if ($type == 'rich') {
|
||||
$list = Db::name('member_wallets')->alias('w')
|
||||
$list = Db::name('member_wallet')->alias('w')
|
||||
->join('user u', 'u.uid = w.uid')
|
||||
->order('w.coins', 'desc')
|
||||
->limit($limit)
|
||||
@@ -578,7 +578,7 @@ class Member extends FrontendBase
|
||||
public function wallet()
|
||||
{
|
||||
$uid = $this->uid;
|
||||
$row = Db::name('member_wallets')->where('uid', $uid)->find();
|
||||
$row = Db::name('member_wallet')->where('uid', $uid)->find();
|
||||
if (empty($row)) {
|
||||
$row = [
|
||||
'balance' => '0.00',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'intro' => '',
|
||||
'author' => '',
|
||||
'website' => '',
|
||||
'version' => '1.0.1',
|
||||
'version' => '1.0.2',
|
||||
'state' => 0,
|
||||
'url' => '/wxchat',
|
||||
'license' => '',
|
||||
|
||||
@@ -11,7 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\Api\Controller\V1;
|
||||
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\service\RemoteService;
|
||||
use think\Request;
|
||||
|
||||
@@ -26,7 +26,7 @@ use think\Request;
|
||||
*
|
||||
* 中心站领域数据(appmarket_* 表)已全部收敛到 addon/appmall,核心不再直接读写。
|
||||
*/
|
||||
class Addon extends ApiController
|
||||
class Addon extends ApiBase
|
||||
{
|
||||
// notify/payResult 由支付平台回调/跳转,无需登录;info 公开
|
||||
protected $noNeedLogin = ['info', 'notify', 'payResult'];
|
||||
|
||||
@@ -11,10 +11,10 @@ declare (strict_types = 1);
|
||||
namespace app\Api\Controller\V1;
|
||||
|
||||
use think\facade\Validate;
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use addon\articles\model\Article as ArticleModel;
|
||||
|
||||
class Article extends ApiController
|
||||
class Article extends ApiBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $needRight = ['*'];
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?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\api\controller\v1;
|
||||
|
||||
use think\facade\Filesystem;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\model\SystemConfig;
|
||||
|
||||
/**
|
||||
* 通用基础接口(App 启动所需,聊天 / 商城等应用共用)
|
||||
*
|
||||
* 路由前缀:/api/v1/common/<action>
|
||||
* 鉴权:config / init 公开;upload 需登录(防滥用)。
|
||||
*
|
||||
* @package app\api\controller\v1
|
||||
*/
|
||||
class Common extends ApiBase
|
||||
{
|
||||
/**
|
||||
* 免登录(公开)接口白名单。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $noNeedLogin = ['config', 'init'];
|
||||
|
||||
/**
|
||||
* App 启动初始化数据(公开)。
|
||||
*
|
||||
* 返回站点基础信息、可用的注册方式开关、接口版本等,供客户端冷启动时读取。
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带 site / register_methods / version
|
||||
*
|
||||
* @route GET /api/v1/common/init
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
$config = $this->siteConfig();
|
||||
$data = [
|
||||
'site' => $config,
|
||||
'register_methods' => [
|
||||
'password' => 1,
|
||||
'sms' => 1,
|
||||
'email' => 1,
|
||||
],
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
return $this->apiSuccess($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站点配置(公开)。
|
||||
*
|
||||
* 返回站点名称、Logo、备案号等基础配置。
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带站点配置数组
|
||||
*
|
||||
* @route GET /api/v1/common/config
|
||||
*/
|
||||
public function config()
|
||||
{
|
||||
return $this->apiSuccess($this->siteConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传(需登录)。
|
||||
*
|
||||
* 接收 multipart/form-data 中的 file 字段,保存到本地存储并返回可访问 URL。
|
||||
*
|
||||
* @param \think\file\UploadedFile $file 上传的文件(form-data: file)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带 url / path
|
||||
*
|
||||
* @throws \Throwable 当文件存储失败时
|
||||
*
|
||||
* @route POST /api/v1/common/upload
|
||||
*/
|
||||
public function upload()
|
||||
{
|
||||
$file = $this->request->file('file');
|
||||
if (!$file) {
|
||||
return $this->apiError('请选择上传文件');
|
||||
}
|
||||
try {
|
||||
$path = Filesystem::disk('local')->putFile('uploads', $file);
|
||||
$url = Filesystem::disk('local')->url($path);
|
||||
return $this->apiSuccess([
|
||||
'url' => $url,
|
||||
'path' => $path,
|
||||
], '上传成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->apiError('上传失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取站点配置。
|
||||
*
|
||||
* 优先从 SystemConfig 表读取,表不存在或字段缺失时回退到默认值。
|
||||
*
|
||||
* @return array<string, mixed> 站点配置数组(name / logo / icp)
|
||||
*/
|
||||
protected function siteConfig(): array
|
||||
{
|
||||
$defaults = [
|
||||
'name' => 'YwxApp',
|
||||
'logo' => '',
|
||||
'icp' => '',
|
||||
];
|
||||
try {
|
||||
$rows = SystemConfig::column('value', 'name');
|
||||
if ($rows) {
|
||||
foreach ($defaults as $k => $v) {
|
||||
if (isset($rows[$k])) {
|
||||
$defaults[$k] = $rows[$k];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 表不存在时用默认值
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
}
|
||||
@@ -10,30 +10,41 @@
|
||||
declare (strict_types = 1);
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use think\facade\Event;
|
||||
use ywxapp\model\MemberUser as UserModel;
|
||||
use ywxapp\library\Result;
|
||||
use ywxapp\library\Email as EmailLib;
|
||||
|
||||
/**
|
||||
* 邮箱验证码接口.
|
||||
* 邮箱验证码接口(公开,无需登录)
|
||||
*
|
||||
* 路由前缀:/api/v1/ems/<action>
|
||||
* 业务事件(event)由调用方约定,如 register / resetpwd / changepwd / changeemail 等。
|
||||
*
|
||||
* @package app\api\controller\v1
|
||||
*/
|
||||
class Ems extends ApiController
|
||||
class Ems extends ApiBase
|
||||
{
|
||||
protected $noNeedLogin = '*';
|
||||
protected $needRight = '*';
|
||||
|
||||
|
||||
public function initialize()
|
||||
{
|
||||
}
|
||||
/**
|
||||
* 免登录(公开)接口白名单。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* 发送邮箱验证码(公开)。
|
||||
*
|
||||
* @param string $email 邮箱
|
||||
* @param string $event 事件名称
|
||||
* 触发 email_send 事件并调用 Email 服务下发验证码;
|
||||
* 按 event 校验账号是否已注册 / 占用 / 未注册。
|
||||
*
|
||||
* @param string $email 邮箱(必填)
|
||||
* @param string $event 事件名称(可选,默认 register)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/ems/send
|
||||
*/
|
||||
public function send()
|
||||
{
|
||||
@@ -43,24 +54,31 @@ class Ems extends ApiController
|
||||
Event::trigger('email_send', ['email'=>$email, 'event'=>'register'], true);
|
||||
$userinfo = UserModel::getByEmail($email);
|
||||
if ($event == 'register' && $userinfo)
|
||||
Result::instance()->error(('已被注册'));
|
||||
$this->apiError('已被注册');
|
||||
elseif (in_array($event, ['changeemail']) && $userinfo)
|
||||
Result::instance()->error(('已被占用'));
|
||||
$this->apiError('已被占用');
|
||||
elseif (in_array($event, ['changepwd', 'resetpwd']) && !$userinfo)
|
||||
Result::instance()->error(('未注册'));
|
||||
$this->apiError('未注册');
|
||||
$ret = \ywxapp\library\Email::instance()->sendEmail($email, null, $event);
|
||||
if (!$ret)
|
||||
Result::instance()->error(('发送失败'));
|
||||
$this->apiError('发送失败');
|
||||
|
||||
Result::instance()->success(('发送成功'));
|
||||
$this->apiSuccess([], '发送成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测验证码
|
||||
* 校验邮箱验证码(公开)。
|
||||
*
|
||||
* @param string $email 邮箱
|
||||
* @param string $event 事件名称
|
||||
* @param string $captcha 验证码
|
||||
* 校验邮箱格式、事件名称与验证码格式,并依 event 校验账号注册状态,
|
||||
* 最后调用 EmailLib::check 比对验证码。
|
||||
*
|
||||
* @param string $email 邮箱(必填)
|
||||
* @param string $event 事件名称(可选,默认 register)
|
||||
* @param string $captcha 验证码(必填)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/ems/check
|
||||
*/
|
||||
public function check()
|
||||
{
|
||||
@@ -74,20 +92,20 @@ class Ems extends ApiController
|
||||
'code' => 'Num',
|
||||
]);
|
||||
if (!$validate->check(['email' => $email, 'event' => $event, 'code' => $captcha]))
|
||||
Result::instance()->error($validate->getError());
|
||||
$this->apiError($validate->getError());
|
||||
|
||||
$userinfo = UserModel::where('email', $email)->find();
|
||||
if ($event == 'register' && $userinfo)
|
||||
$this->result->error(('已被注册'));
|
||||
$this->apiError('已被注册');
|
||||
elseif (in_array($event, ['changeemail']) && $userinfo)
|
||||
Result::instance()->error(('已被占用'));
|
||||
$this->apiError('已被占用');
|
||||
elseif (in_array($event, ['changepwd', 'resetpwd']) && !$userinfo)
|
||||
Result::instance()->error(('未注册'));
|
||||
$this->apiError('未注册');
|
||||
|
||||
$ret = EmailLib::instance()->check($email, $captcha, $event);
|
||||
if (!$ret)
|
||||
Result::instance()->error(('验证码不正确'));
|
||||
$this->apiError('验证码不正确');
|
||||
|
||||
Result::instance()->success(data: ('验证码正确'));
|
||||
$this->apiSuccess([], '验证码正确');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
namespace app\api\controller\v1;
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
|
||||
use think\Response;
|
||||
|
||||
@@ -17,7 +17,7 @@ use think\Response;
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Example extends ApiController
|
||||
class Example extends ApiBase
|
||||
{
|
||||
|
||||
public function index(): Response
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types = 1);
|
||||
namespace app\Api\Controller\V1;
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\library\Result;
|
||||
/**
|
||||
* Index 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Index extends ApiController
|
||||
class Index extends ApiBase
|
||||
{
|
||||
|
||||
|
||||
|
||||
@@ -8,42 +8,70 @@
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace app\Api\Controller\V1;
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use think\facade\Event;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\library\Sms as Smslib;
|
||||
use ywxapp\model\Sms as SmsModel;
|
||||
use ywxapp\model\CommonSms as SmsModel;
|
||||
use ywxapp\model\MemberUser as UserModel;
|
||||
use ywxapp\utils\Random;
|
||||
|
||||
/**
|
||||
* Sms 类
|
||||
* 短信验证码接口(公开,无需登录)
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
* 路由前缀:/api/v1/sms/<action>
|
||||
* 业务事件(event)由调用方约定,如 register / resetpwd / changepwd / mobilelogin 等。
|
||||
*
|
||||
* @package app\api\controller\v1
|
||||
*/
|
||||
class Sms extends ApiController
|
||||
class Sms extends ApiBase
|
||||
{
|
||||
|
||||
/**
|
||||
* 免登录(公开)接口白名单。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $noNeedLogin = ['*'];
|
||||
|
||||
|
||||
/**
|
||||
* 接口存活探测。
|
||||
*
|
||||
* @return \think\Response JSON 响应,演示用
|
||||
*
|
||||
* @route GET /api/v1/sms/index
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return json(['message' => 'SMS API']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* 发送短信验证码(公开)。
|
||||
*
|
||||
* @ApiMethod (POST)
|
||||
* @ApiParams (name="mobile", type="string", required=true, description="手机号")
|
||||
* @ApiParams (name="event", type="string", required=true, description="事件名称")
|
||||
* @ApiParams (name="type", type="string", required=false, description="验证类型,auto为自动验证,system为系统验证码")
|
||||
* @ApiParams (name="source_id", type="string", required=false, description="来源ID")
|
||||
* 校验手机号与事件后,受发送频率(同号 60 秒、同 IP 每小时 5 条)限制;
|
||||
* 按 event 校验账号是否已注册 / 占用 / 未注册,最后触发 SmsSend 事件下发短信。
|
||||
*
|
||||
* @param string $mobile 手机号(必填)
|
||||
* @param string $event 事件名称(必填,小写字母,默认 register)
|
||||
* @param string $type 验证类型(可选,auto 自动 / system 系统验证码)
|
||||
* @param string $source_id 来源 ID(可选)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @throws ValidateException 当参数校验失败时
|
||||
*
|
||||
* @route POST /api/v1/sms/send
|
||||
*/
|
||||
public function send()
|
||||
{
|
||||
{
|
||||
$cfg = config('smsbao');
|
||||
|
||||
dump($cfg);die;
|
||||
$mobile = $this->request->post("mobile");
|
||||
$event = $this->request->post("event", 'register');
|
||||
$event = $this->request->param("event", 'register');
|
||||
$type = $this->request->post("type", 'auto');
|
||||
$source_id = $this->request->post("source_id", '');
|
||||
try {
|
||||
@@ -62,46 +90,52 @@ class Sms extends ApiController
|
||||
]);
|
||||
$last = Smslib::get($mobile, $event);
|
||||
if ($last && time() - (int) $last['create_at'] < 60) {
|
||||
$this->result->error('发送频繁');
|
||||
$this->apiError('发送频繁');
|
||||
}
|
||||
$ipSendTotal = SmsModel::where(['ip' => $this->request->ip()])->whereTime('create_at', '-1 hours')->count();
|
||||
if ($ipSendTotal >= 5) {
|
||||
$this->result->error('发送频繁');
|
||||
$this->apiError('发送频繁');
|
||||
}
|
||||
if ($event) {
|
||||
$userinfo = UserModel::getByMobile($mobile);
|
||||
if ($event == 'register' && $userinfo) {
|
||||
//已被注册
|
||||
$this->result->error('已被注册');
|
||||
$this->apiError('已被注册');
|
||||
} elseif (in_array($event, ['changemobile']) && $userinfo) {
|
||||
//被占用
|
||||
$this->result->error('已被占用');
|
||||
$this->apiError('已被占用');
|
||||
} elseif (in_array($event, ['changepwd', 'resetpwd']) && ! $userinfo) {
|
||||
//未注册
|
||||
$this->result->error('未注册');
|
||||
$this->apiError('未注册');
|
||||
}
|
||||
}
|
||||
if (! Event::hasListener('SmsSend')) {
|
||||
$this->result->error('请在后台插件管理安装短信验证插件');
|
||||
$this->apiError('请在后台插件管理安装短信验证插件');
|
||||
}
|
||||
$ret = Smslib::send($mobile, null, $event);
|
||||
if ($ret) {
|
||||
$this->result->success('发送成功');
|
||||
$this->apiSuccess([], '发送成功');
|
||||
} else {
|
||||
$this->result->error('发送失败,请检查短信配置是否正确');
|
||||
$this->apiError('发送失败,请检查短信配置是否正确');
|
||||
}
|
||||
} catch (ValidateException $e) {
|
||||
$this->result->error($e->getError());
|
||||
$this->apiError($e->getError());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测验证码
|
||||
* 校验短信验证码(公开)。
|
||||
*
|
||||
* @ApiMethod (POST)
|
||||
* @ApiParams (name="mobile", type="string", required=true, description="手机号")
|
||||
* @ApiParams (name="event", type="string", required=true, description="事件名称")
|
||||
* @ApiParams (name="captcha", type="string", required=true, description="验证码")
|
||||
* 校验手机号、事件名称、验证码格式,并依 event 校验账号注册状态,
|
||||
* 最后调用 SmsLib::check 比对验证码(默认有效期 5 分钟)。
|
||||
*
|
||||
* @param string $mobile 手机号(必填)
|
||||
* @param string $event 事件名称(必填,默认 register)
|
||||
* @param string $captcha 验证码(必填)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/sms/check
|
||||
*/
|
||||
public function check()
|
||||
{
|
||||
@@ -109,33 +143,33 @@ class Sms extends ApiController
|
||||
$event = $this->request->post("event", 'register');
|
||||
$captcha = $this->request->post("captcha");
|
||||
if (! $mobile || ! \think\Validate::regex($mobile, "^1\d{10}$")) {
|
||||
$this->result->error('手机号不正确');
|
||||
$this->apiError('手机号不正确');
|
||||
}
|
||||
if (! preg_match("/^[a-z0-9_\-]{3,30}\$/i", $event)) {
|
||||
$this->result->error('事件名称错误');
|
||||
$this->apiError('事件名称错误');
|
||||
}
|
||||
if (! preg_match("/^[a-z0-9]{4,6}\$/i", $captcha)) {
|
||||
$this->result->error('验证码格式错误');
|
||||
$this->apiError('验证码格式错误');
|
||||
}
|
||||
|
||||
if ($event) {
|
||||
$userinfo = UserModel::getByMobile($mobile);
|
||||
if ($event == 'register' && $userinfo) {
|
||||
//已被注册
|
||||
$this->result->error('已被注册');
|
||||
$this->apiError('已被注册');
|
||||
} elseif (in_array($event, ['changemobile']) && $userinfo) {
|
||||
//被占用
|
||||
$this->result->error('已被占用');
|
||||
$this->apiError('已被占用');
|
||||
} elseif (in_array($event, ['changepwd', 'resetpwd']) && ! $userinfo) {
|
||||
//未注册
|
||||
$this->result->error('未注册');
|
||||
$this->apiError('未注册');
|
||||
}
|
||||
}
|
||||
$ret = Smslib::check($mobile, $captcha, $event);
|
||||
if ($ret) {
|
||||
$this->result->success('成功');
|
||||
$this->apiSuccess([], '成功');
|
||||
} else {
|
||||
$this->result->error('验证码不正确');
|
||||
$this->apiError('验证码不正确');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+354
-162
@@ -6,201 +6,393 @@
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace app\Api\Controller\V1;
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\api\validate\Login as LoginValidate;
|
||||
use think\facade\Event;
|
||||
use think\facade\Validate;
|
||||
use ywxapp\controller\ApiController;
|
||||
use ywxapp\model\MemberUser as UserModel;
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\service\JwtService;
|
||||
use ywxapp\library\Sms as SmsLib;
|
||||
use ywxapp\library\Email as EmailLib;
|
||||
use ywxapp\model\MemberUser as UserModel;
|
||||
|
||||
|
||||
class User extends ApiController
|
||||
/**
|
||||
* 会员账户接口(标准基础能力,为聊天 / 商城等应用铺路)
|
||||
*
|
||||
* 路由前缀:/api/v1/user/<action>
|
||||
* 鉴权:除 $noNeedLogin 声明的公开接口外,其余接口均需 Bearer Authorization 登录态(由父类统一拦截)。
|
||||
*
|
||||
* @package app\api\controller\v1
|
||||
*/
|
||||
class User extends ApiBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
|
||||
public function initialize()
|
||||
{
|
||||
$this->model = new \ywxapp\model\Members();
|
||||
}
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
return json(['message' => 'This is version 1 of the API']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册会员.
|
||||
* @route put /register, method:put
|
||||
* @param string $username 用户名
|
||||
* @param string $password 密码
|
||||
* @param string $email 邮箱
|
||||
* @param string $mobile 手机号
|
||||
* @param string $code 验证码
|
||||
* @return \think\Response
|
||||
*/
|
||||
|
||||
public function register(\think\Request $request)
|
||||
{
|
||||
$data = $request->param();
|
||||
$validate = validate([
|
||||
'account|账户或手机号' => 'require',
|
||||
'password' => 'alphaDash',
|
||||
'code' => 'number|length:4',
|
||||
'captcha' => 'alphaNum',
|
||||
]);
|
||||
if (! $validate->check($data)) {
|
||||
$this->result->error($validate->getError());
|
||||
}
|
||||
|
||||
$account = $request->param('account');
|
||||
$password = $request->has('password') ? $request->param('password') : md5("xixingwl");
|
||||
$code = $request->param('code');
|
||||
if (Validate::is($account, 'email') && $request->has('code')) {
|
||||
$ret = \ywxapp\library\Sms::instence()->check($account, $code, 'register');
|
||||
if (! $ret) {
|
||||
$this->result->error('Code is incorrect');
|
||||
}
|
||||
|
||||
}
|
||||
if (Validate::is($account, 'mobile') && $request->has('code')) {
|
||||
$ret = \ywxapp\library\Sms::instence()->check($account, $code, 'register');
|
||||
if (! $ret) {
|
||||
$this->result->error('Code is incorrect');
|
||||
}
|
||||
|
||||
}
|
||||
$extend = [];
|
||||
if ($request->param('avatar')) {
|
||||
$extend['avatar'] = $request->param('avatar');
|
||||
}
|
||||
|
||||
if ($request->param('nickname')) {
|
||||
$extend['nickname'] = $request->param('nickname');
|
||||
}
|
||||
|
||||
$this->user->create($account, $password, $extend);
|
||||
$this->result->success(['userinfo' => $this->Member->info]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Member Login.
|
||||
* 免登录(公开)接口白名单。
|
||||
*
|
||||
* @param string $account 账号
|
||||
* @param string $password 密码
|
||||
* @return \think\Response
|
||||
* @var array<int, string>
|
||||
*/
|
||||
|
||||
public function login(\think\Request $request)
|
||||
{
|
||||
protected $noNeedLogin = [
|
||||
'register',
|
||||
'login',
|
||||
'loginBySms',
|
||||
'refresh',
|
||||
'resetPwdBySms',
|
||||
'resetPwdByEmail',
|
||||
];
|
||||
|
||||
$data = $this->request->param();
|
||||
/**
|
||||
* 账号密码注册。
|
||||
*
|
||||
* 普通注册仅需 account + password;若传入 mobile 则需先通过短信验证码校验。
|
||||
* 成功后自动签发 JWT(access_token / refresh_token 由响应自动携带)。
|
||||
*
|
||||
* @param string $account 登录账号(必填,唯一)
|
||||
* @param string $password 登录密码(必填,至少 6 位)
|
||||
* @param string $mobile 手机号(可选,注册时必需短信验证码)
|
||||
* @param string $captcha 短信验证码(mobile 传入时必填)
|
||||
* @param string $nickname 昵称(可选,默认同 account)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带会员信息与 token
|
||||
*
|
||||
* @route POST /api/v1/user/register
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$account = trim((string) $this->request->post('account', ''));
|
||||
$password = (string) $this->request->post('password', '');
|
||||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||||
$nickname = trim((string) $this->request->post('nickname', ''));
|
||||
|
||||
if ($account === '' || $password === '') {
|
||||
return $this->apiError('账号和密码不能为空');
|
||||
}
|
||||
if (strlen($password) < 6) {
|
||||
return $this->apiError('密码至少 6 位');
|
||||
}
|
||||
if (UserModel::getByAccount($account)) {
|
||||
return $this->apiError('该账号已被注册');
|
||||
}
|
||||
|
||||
// 手机号注册需校验短信验证码
|
||||
if ($mobile !== '') {
|
||||
if ($captcha === '' || ! SmsLib::check($mobile, $captcha, 'register')) {
|
||||
return $this->apiError('短信验证码不正确');
|
||||
}
|
||||
if (UserModel::getByMobile($mobile)) {
|
||||
return $this->apiError('该手机号已被注册');
|
||||
}
|
||||
}
|
||||
|
||||
$user = new UserModel();
|
||||
$user->account = $account;
|
||||
$user->password = $password; // 触发 setPasswordAttr 自动 bcrypt
|
||||
$user->nickname = $nickname ?: $account;
|
||||
if ($mobile !== '') {
|
||||
$user->mobile = $mobile;
|
||||
}
|
||||
$user->status = 1;
|
||||
$user->save();
|
||||
|
||||
return $this->issueToken($user, '注册成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码登录。
|
||||
*
|
||||
* 校验账号存在、状态正常、未被锁定,并通过 checkPassword 验证密码;
|
||||
* 成功后记录登录信息并签发 JWT(access_token / refresh_token 由响应自动携带)。
|
||||
*
|
||||
* @param string $account 登录账号(必填)
|
||||
* @param string $password 登录密码(必填)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带会员信息与 token
|
||||
*
|
||||
* @route POST /api/v1/user/login
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$account = trim((string) $this->request->post('account', ''));
|
||||
$password = (string) $this->request->post('password', '');
|
||||
|
||||
$user = UserModel::getByAccount($account);
|
||||
if (!$user) {
|
||||
return $this->apiError('账号不存在');
|
||||
}
|
||||
if ($user->status != 1) {
|
||||
return $this->apiError('账号已被禁用');
|
||||
}
|
||||
if ($user->isLocked()) {
|
||||
return $this->apiError('账号已锁定,请稍后再试');
|
||||
}
|
||||
if (!$user->checkPassword($password)) {
|
||||
$user->recordLoginFail($this->request->ip());
|
||||
return $this->apiError('密码错误');
|
||||
}
|
||||
$user->recordLogin($this->request->ip());
|
||||
|
||||
return $this->issueToken($user, '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码登录 / 一键注册。
|
||||
*
|
||||
* 校验 mobile + captcha 通过后,若该手机号已注册则直接登录,
|
||||
* 否则自动创建账号并登录。成功后签发 JWT。
|
||||
*
|
||||
* @param string $mobile 手机号(必填)
|
||||
* @param string $captcha 短信验证码(必填)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带会员信息与 token
|
||||
*
|
||||
* @route POST /api/v1/user/loginBySms
|
||||
*/
|
||||
public function loginBySms()
|
||||
{
|
||||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||||
|
||||
if ($mobile === '' || ! SmsLib::check($mobile, $captcha, 'mobilelogin')) {
|
||||
return $this->apiError('短信验证码不正确');
|
||||
}
|
||||
|
||||
$user = UserModel::getByMobile($mobile);
|
||||
if (!$user) {
|
||||
// 该手机号未注册 → 自动注册
|
||||
$user = new UserModel();
|
||||
$user->account = 'u' . $mobile;
|
||||
$user->password = mt_rand(100000, 999999); // 随机初始密码(仅短信登录,密码登录不可用)
|
||||
$user->mobile = $mobile;
|
||||
$user->nickname = '用户' . substr($mobile, -4);
|
||||
$user->status = 1;
|
||||
$user->save();
|
||||
} elseif ($user->status != 1) {
|
||||
return $this->apiError('账号已被禁用');
|
||||
}
|
||||
$user->recordLogin($this->request->ip());
|
||||
|
||||
return $this->issueToken($user, '登录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 refresh_token 刷新 access_token。
|
||||
*
|
||||
* 优先读取请求体中的 refresh_token,缺失时回退到 Header / Cookie。
|
||||
* 调用 JwtService::refreshAccessToken 校验并签发新的 access_token。
|
||||
*
|
||||
* @param string $refresh_token 刷新令牌(请求体 / Header / Cookie)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带新的 token 对;失败返回 401
|
||||
*
|
||||
* @throws \Throwable 当 refresh_token 非法或过期时
|
||||
*
|
||||
* @route POST /api/v1/user/refresh
|
||||
*/
|
||||
public function refresh()
|
||||
{
|
||||
$refreshToken = trim((string) $this->request->post('refresh_token', ''));
|
||||
if ($refreshToken === '') {
|
||||
// 兼容从 Cookie / Header 读取
|
||||
$refreshToken = $this->request->header('refresh_token', '') ?: $this->request->cookie('refresh_token', '');
|
||||
}
|
||||
if ($refreshToken === '') {
|
||||
return $this->apiError('缺少 refresh_token', 401, null, 401);
|
||||
}
|
||||
try {
|
||||
validate(LoginValidate::class)->check($data);
|
||||
$info = UserModel::where('account', $data['username'])
|
||||
->whereOr('mobile', $data['username'])
|
||||
->whereOr('email', $data['username'])
|
||||
->findOrEmpty();
|
||||
if ($info->isEmpty()) {
|
||||
$this->result->error('用户不存在', 4010);
|
||||
}
|
||||
// 检查账户状态
|
||||
if ($info->status == 0) {
|
||||
$this->result->error('账号已被禁用', 403);
|
||||
}
|
||||
// 检查是否被锁定
|
||||
if ($info->isLocked()) {
|
||||
$lockTime = strtotime($info->lock_time) + 1800 - time();
|
||||
$minutes = ceil($lockTime / 60);
|
||||
$this->result->error("账号被锁定,请 {$minutes} 分钟后重试", 403);
|
||||
}
|
||||
$info->resetPassword($data['password']);
|
||||
// 验证密码
|
||||
if (! $info->checkPassword($data['password'])) {
|
||||
$info->recordLoginFail($this->request->ip()); // 记录失败
|
||||
$this->result->error('密码错误', 4011);
|
||||
}
|
||||
Event::trigger('MemberLog', [
|
||||
'uid' => $info->uid,
|
||||
'action' => 'login',
|
||||
'ip' => $this->request->ip(),
|
||||
'remark' => '用户注册',
|
||||
]);
|
||||
$newClaims = [
|
||||
'uid' => $info->uid,
|
||||
'account' => $info->account,
|
||||
];
|
||||
JwtService::instance()->createToken($newClaims);
|
||||
$this->result->success($info);
|
||||
} catch (ValidateException $e) {
|
||||
$this->result->error($e->getMessage(), 1);
|
||||
$data = JwtService::instance()->refreshAccessToken($refreshToken);
|
||||
return $this->apiSuccess($data, '刷新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->apiError($e->getMessage(), 401, null, 401);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user detail
|
||||
* @route get /detail, method:get
|
||||
* @param int $uid
|
||||
* @return \think\Response
|
||||
* 退出登录。
|
||||
*
|
||||
* 系统采用无状态 JWT,服务端不维护会话;客户端收到成功响应后自行丢弃本地 token 即可。
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/user/logout
|
||||
*/
|
||||
|
||||
public function detail(\think\Request $request, int $uid = 0)
|
||||
public function logout()
|
||||
{
|
||||
if (! $uid) {
|
||||
$this->result->error('Invalid parameters', 404);
|
||||
}
|
||||
|
||||
$this->result->success($this->user->info);
|
||||
return $this->apiSuccess([], '已登出');
|
||||
}
|
||||
|
||||
/**
|
||||
* update user detail
|
||||
* @route put /update, method:get
|
||||
* @param int $uid
|
||||
* @return \think\Response
|
||||
* 获取当前登录会员资料(需登录)。
|
||||
*
|
||||
* 返回会员模型数组(password 等敏感字段已由模型 hidden 自动隐藏)。
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带会员信息数组
|
||||
*
|
||||
* @route GET /api/v1/user/profile
|
||||
*/
|
||||
|
||||
public function update(\think\Request $request, int $uid = 0)
|
||||
public function profile()
|
||||
{
|
||||
if (! $uid) {
|
||||
$this->result->error('Invalid parameters', 404);
|
||||
}
|
||||
|
||||
$this->result->success($this->user->info);
|
||||
$user = $this->auth->model;
|
||||
return $this->apiSuccess($user->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE user detail
|
||||
* @route DELETE /update, method:DELETE
|
||||
* @param int $uid
|
||||
* @return \think\Response
|
||||
* 修改当前会员资料(需登录)。
|
||||
*
|
||||
* 仅更新传入的非空字段(nickname / avatar / email),
|
||||
* 邮箱需通过格式校验且未被其他会员占用。
|
||||
*
|
||||
* @param string $nickname 昵称(可选)
|
||||
* @param string $avatar 头像地址(可选)
|
||||
* @param string $email 邮箱(可选,唯一)
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带更新后的会员信息
|
||||
*
|
||||
* @route POST /api/v1/user/updateProfile
|
||||
*/
|
||||
|
||||
public function delete(\think\Request $request, int $uid = 0)
|
||||
public function updateProfile()
|
||||
{
|
||||
if (! $uid) {
|
||||
$this->result->error('Invalid parameters', 404);
|
||||
$user = $this->auth->model;
|
||||
$nickname = trim((string) $this->request->post('nickname', ''));
|
||||
$avatar = trim((string) $this->request->post('avatar', ''));
|
||||
$email = trim((string) $this->request->post('email', ''));
|
||||
|
||||
if ($nickname !== '') {
|
||||
$user->nickname = $nickname;
|
||||
}
|
||||
if ($avatar !== '') {
|
||||
$user->avatar = $avatar;
|
||||
}
|
||||
if ($email !== '') {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->apiError('邮箱格式不正确');
|
||||
}
|
||||
$exists = UserModel::getByEmail($email);
|
||||
if ($exists && $exists->uid != $user->uid) {
|
||||
return $this->apiError('该邮箱已被占用');
|
||||
}
|
||||
$user->email = $email;
|
||||
}
|
||||
$user->save();
|
||||
|
||||
return $this->apiSuccess($user->toArray(), '保存成功');
|
||||
}
|
||||
|
||||
|
||||
private function getEncryptPassword($password, $salt = '')
|
||||
/**
|
||||
* 修改密码(需登录)。
|
||||
*
|
||||
* 先校验原密码正确,再校验新密码长度(至少 6 位),通过后通过 resetPassword 更新。
|
||||
*
|
||||
* @param string $oldpassword 原密码(必填)
|
||||
* @param string $newpassword 新密码(必填,至少 6 位)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/user/changePwd
|
||||
*/
|
||||
public function changePwd()
|
||||
{
|
||||
return md5(md5($password) . $salt);
|
||||
$old = (string) $this->request->post('oldpassword', '');
|
||||
$new = (string) $this->request->post('newpassword', '');
|
||||
|
||||
$user = $this->auth->model;
|
||||
if (!$user->checkPassword($old)) {
|
||||
return $this->apiError('原密码错误');
|
||||
}
|
||||
if (strlen($new) < 6) {
|
||||
return $this->apiError('新密码至少 6 位');
|
||||
}
|
||||
$user->resetPassword($new);
|
||||
|
||||
return $this->apiSuccess([], '密码修改成功');
|
||||
}
|
||||
|
||||
|
||||
public function init()
|
||||
/**
|
||||
* 短信验证码找回密码(公开)。
|
||||
*
|
||||
* 校验 mobile + captcha(事件 resetpwd)通过后,若该手机号已注册则重置其密码。
|
||||
*
|
||||
* @param string $mobile 手机号(必填)
|
||||
* @param string $captcha 短信验证码(必填)
|
||||
* @param string $newpassword 新密码(必填,至少 6 位)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/user/resetPwdBySms
|
||||
*/
|
||||
public function resetPwdBySms()
|
||||
{
|
||||
return json(['message' => 'This is version 1 of the API']);
|
||||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||||
$new = (string) $this->request->post('newpassword', '');
|
||||
|
||||
if ($new === '' || strlen($new) < 6) {
|
||||
return $this->apiError('新密码至少 6 位');
|
||||
}
|
||||
if (! SmsLib::check($mobile, $captcha, 'resetpwd')) {
|
||||
return $this->apiError('短信验证码不正确');
|
||||
}
|
||||
$user = UserModel::getByMobile($mobile);
|
||||
if (!$user) {
|
||||
return $this->apiError('该手机号未注册');
|
||||
}
|
||||
$user->resetPassword($new);
|
||||
|
||||
return $this->apiSuccess([], '密码重置成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱验证码找回密码(公开)。
|
||||
*
|
||||
* 校验 email + captcha(事件 resetpwd)通过后,若该邮箱已注册则重置其密码。
|
||||
*
|
||||
* @param string $email 邮箱(必填)
|
||||
* @param string $captcha 邮箱验证码(必填)
|
||||
* @param string $newpassword 新密码(必填,至少 6 位)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功提示
|
||||
*
|
||||
* @route POST /api/v1/user/resetPwdByEmail
|
||||
*/
|
||||
public function resetPwdByEmail()
|
||||
{
|
||||
$email = trim((string) $this->request->post('email', ''));
|
||||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||||
$new = (string) $this->request->post('newpassword', '');
|
||||
|
||||
if ($new === '' || strlen($new) < 6) {
|
||||
return $this->apiError('新密码至少 6 位');
|
||||
}
|
||||
if (! EmailLib::instance()->check($email, $captcha, 'resetpwd')) {
|
||||
return $this->apiError('邮箱验证码不正确');
|
||||
}
|
||||
$user = UserModel::getByEmail($email);
|
||||
if (!$user) {
|
||||
return $this->apiError('该邮箱未注册');
|
||||
}
|
||||
$user->resetPassword($new);
|
||||
|
||||
return $this->apiSuccess([], '密码重置成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发 JWT 并通过标准响应返回会员信息。
|
||||
*
|
||||
* 调用 JwtService::createToken 生成 access_token / refresh_token(由 Result 自动随响应输出)。
|
||||
*
|
||||
* @param UserModel $user 会员模型
|
||||
* @param string $msg 成功提示语
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带会员信息与 token
|
||||
*/
|
||||
protected function issueToken(UserModel $user, string $msg = 'success')
|
||||
{
|
||||
JwtService::instance()->createToken([
|
||||
'uid' => $user->uid,
|
||||
'account' => $user->account,
|
||||
]);
|
||||
$data = $user->toArray();
|
||||
return $this->apiSuccess($data, $msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Ad as AdModel;
|
||||
use ywxapp\model\CommonAd as AdModel;
|
||||
use app\backend\validate\Ad as AdValidate;
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,7 +48,7 @@ class Card extends BackendBase
|
||||
$uids = array_filter(array_column($items, 'uid'));
|
||||
$users = [];
|
||||
if (!empty($uids)) {
|
||||
$users = Db::name('member')->whereIn('uid', array_unique($uids))
|
||||
$users = Db::name('member_user')->whereIn('uid', array_unique($uids))
|
||||
->column('nickname,username', 'uid');
|
||||
}
|
||||
foreach ($items as &$row) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-04-29 02:32:33
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-03 13:32:15
|
||||
* @LastEditTime: 2026-08-19 22:05:03
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\app\backend\controller\Configure.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
@@ -14,7 +14,7 @@ namespace app\backend\controller;
|
||||
|
||||
use think\facade\View;
|
||||
use think\Request;
|
||||
use ywxapp\model\Configure as ConfigureModel;
|
||||
use ywxapp\model\CommonConfigure as ConfigureModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Cache;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
@@ -64,10 +64,10 @@ class Console extends BackendBase
|
||||
public function hotsearch()
|
||||
{
|
||||
$admin = Db::name('backend_admin')->count();
|
||||
$user = Db::name('member')->count();
|
||||
$user = Db::name('member_user')->count();
|
||||
$article = Db::name('articles_article')->count();
|
||||
$links = Db::name('links')->count();
|
||||
$addon = Db::name('addon')->count();
|
||||
$links = Db::name('common_links')->count();
|
||||
$addon = Db::name('common_addon')->count();
|
||||
$data = [
|
||||
['keywords' => '管理员', 'frequency' => $admin, 'userNums' => $admin],
|
||||
['keywords' => '会员', 'frequency' => $user, 'userNums' => $user],
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-07 09:44:40
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-19 22:06:38
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\app\backend\controller\Help.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
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\BaseModel;
|
||||
use ywxapp\model\Help as HelpModel;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\CommonHelp as HelpModel;
|
||||
use app\backend\validate\Help as HelpValidate;
|
||||
|
||||
/**
|
||||
@@ -22,7 +29,7 @@ class Help extends BackendBase
|
||||
protected function initialize()
|
||||
{
|
||||
// 运行时自愈 help 表结构(category / view_count 等扩展列)
|
||||
\ywxapp\model\Help::ensureSchema();
|
||||
\ywxapp\model\CommonHelp::ensureSchema();
|
||||
$this->model = new HelpModel();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Links as LinksModel;
|
||||
use ywxapp\model\CommonLinks as LinksModel;
|
||||
use app\backend\validate\Links as LinksValidate;
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Medal as MedalModel;
|
||||
use ywxapp\model\CommonMedal as MedalModel;
|
||||
use app\backend\validate\Medal as MedalValidate;
|
||||
|
||||
/**
|
||||
@@ -173,7 +173,7 @@ class Medal extends BackendBase
|
||||
$this->result->error('请选择勋章');
|
||||
}
|
||||
// 校验用户存在
|
||||
$user = Db::name('member')->where('uid', $uid)->find();
|
||||
$user = Db::name('member_user')->where('uid', $uid)->find();
|
||||
if (! $user) {
|
||||
$this->result->error('用户不存在');
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ declare (strict_types = 1);
|
||||
|
||||
namespace app\backend\controller;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Request;
|
||||
use think\facade\View;
|
||||
use app\backend\model\NavMenu;
|
||||
use ywxapp\model\CommonNavMenu as NavMenu;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
/**
|
||||
@@ -30,12 +31,56 @@ class Navbar extends BackendBase
|
||||
public function index()
|
||||
{
|
||||
if (Request::isAjax()) {
|
||||
$list = NavMenu::getAdminTree();
|
||||
return json(['code' => 0, 'msg' => 'ok', 'data' => $list, 'count' => count($list)]);
|
||||
$title = trim((string) Request::param('title', ''));
|
||||
$rows = NavMenu::getAdminTree($title);
|
||||
// 拍平成 treeTable isSimpleData 所需的扁平 pid 列表(treeTable 用 pid 自动建树)
|
||||
$flat = [];
|
||||
foreach ($rows as $p) {
|
||||
$p['pid'] = (int) $p['parent_id'];
|
||||
$flat[] = $p;
|
||||
foreach (($p['child'] ?? []) as $c) {
|
||||
$c['pid'] = (int) $c['parent_id'];
|
||||
$flat[] = $c;
|
||||
}
|
||||
}
|
||||
foreach ($flat as &$row) {
|
||||
unset($row['child']);
|
||||
}
|
||||
return json(['code' => 0, 'msg' => 'ok', 'data' => $flat, 'count' => count($flat)]);
|
||||
}
|
||||
// 已启用插件列表:导航「所属应用」可选插件(插件页面同样支持绑定域名)
|
||||
View::assign('plugins', $this->enabledPlugins());
|
||||
return View::fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已启用插件名列表.
|
||||
*/
|
||||
private function enabledPlugins(): array
|
||||
{
|
||||
$raw = Cache::get('addon_loaded_config');
|
||||
if (! is_array($raw) || empty($raw)) {
|
||||
$addonDir = root_path() . 'addon' . DIRECTORY_SEPARATOR;
|
||||
$raw = [];
|
||||
if (is_dir($addonDir)) {
|
||||
foreach (array_diff(scandir($addonDir), ['.', '..']) as $dir) {
|
||||
$info = @include $addonDir . $dir . DIRECTORY_SEPARATOR . 'info.php';
|
||||
if (is_array($info)) {
|
||||
$raw[$dir] = $info;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$plugins = [];
|
||||
foreach ($raw as $info) {
|
||||
if (! empty($info['state']) && ! empty($info['name'])) {
|
||||
$plugins[] = (string) $info['name'];
|
||||
}
|
||||
}
|
||||
sort($plugins);
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加/编辑页(页面式,复用 edit.html).
|
||||
*/
|
||||
@@ -47,6 +92,7 @@ class Navbar extends BackendBase
|
||||
'parent_id' => (int) Request::post('parent_id', 0),
|
||||
'title' => trim((string) Request::post('title', '')),
|
||||
'url' => trim((string) Request::post('url', '')),
|
||||
'app' => trim((string) Request::post('app', '')),
|
||||
'icon' => trim((string) Request::post('icon', '')),
|
||||
'sort' => (int) Request::post('sort', 0),
|
||||
'status' => (int) Request::post('status', 1),
|
||||
@@ -86,6 +132,7 @@ class Navbar extends BackendBase
|
||||
'parent_id' => (int) Request::post('parent_id', 0),
|
||||
'title' => trim((string) Request::post('title', '')),
|
||||
'url' => trim((string) Request::post('url', '')),
|
||||
'app' => trim((string) Request::post('app', '')),
|
||||
'icon' => trim((string) Request::post('icon', '')),
|
||||
'sort' => (int) Request::post('sort', 0),
|
||||
'status' => (int) Request::post('status', 1),
|
||||
@@ -114,19 +161,25 @@ class Navbar extends BackendBase
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除(支持单 id;有子项时拒绝,避免孤儿).
|
||||
* 删除(支持单 id 或批量 ids 逗号分隔;有子项时拒绝,避免孤儿).
|
||||
*/
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$id = $id ?: (int) Request::post('id', 0);
|
||||
if (! $id) {
|
||||
$ids = Request::post('ids', '');
|
||||
if ($ids !== '' && $ids !== null) {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string) $ids)))));
|
||||
} else {
|
||||
$id = $id ?: (int) Request::post('id', 0);
|
||||
$ids = $id ? [$id] : [];
|
||||
}
|
||||
if (empty($ids)) {
|
||||
return json(['code' => 1, 'msg' => '请选择要删除的项']);
|
||||
}
|
||||
$hasChild = NavMenu::where('parent_id', $id)->where('delete_at', 0)->count();
|
||||
$hasChild = NavMenu::where('parent_id', 'in', $ids)->where('delete_at', 0)->count();
|
||||
if ($hasChild) {
|
||||
return json(['code' => 1, 'msg' => '请先删除该菜单下的子项']);
|
||||
return json(['code' => 1, 'msg' => '请先删除所选菜单下的子项']);
|
||||
}
|
||||
NavMenu::destroy($id);
|
||||
NavMenu::destroy($ids);
|
||||
return json(['code' => 0, 'msg' => '已删除']);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Notice as NoticeModel;
|
||||
use app\backend\validate\Notice as NoticeValidate;
|
||||
use app\backend\validate\CommonNotice as NoticeValidate;
|
||||
|
||||
/**
|
||||
* 站点公告管理
|
||||
|
||||
@@ -7,7 +7,7 @@ use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Prop as PropModel;
|
||||
use ywxapp\model\CommonProp as PropModel;
|
||||
use app\backend\validate\Prop as PropValidate;
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ use think\facade\Db;
|
||||
use think\db\exception\DbException;
|
||||
use think\exception\ValidateException;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\model\Score as ScoreModel;
|
||||
use ywxapp\model\MebmberScore as ScoreModel;
|
||||
use app\backend\validate\Score as ScoreValidate;
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user