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:
ywxapp
2026-08-20 20:19:15 +08:00
parent 303f548664
commit 1d49e6f5ee
395 changed files with 13342 additions and 2012 deletions
View File
+1 -1
View File
@@ -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' => [
+7 -5
View File
@@ -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"
}
],
+1 -1
View File
@@ -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');
}
/**
+1 -1
View File
@@ -15,7 +15,7 @@ use ywxapp\AddonBase;
/**
* 文章CMS插件启动类
*/
class Addon extends addon
class Addon extends AddonBase
{
// 插件基本信息
public $info = [
+1 -1
View File
@@ -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,
+4 -8
View File
@@ -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"
}
]
}
]
}
+1 -1
View File
@@ -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
View File
@@ -6,7 +6,7 @@ return [
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.2',
'version' => '1.0.3',
'state' => 1,
'url' => '/blog',
'license' => '',
+73
View File
@@ -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(){}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
// 这是系统自动生成的公共文件
+3
View File
@@ -0,0 +1,3 @@
<?php
// 插件配置项(后台「配置」表单数据源)
return [];
+107
View File
@@ -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)
{
//
}
}
+104
View File
@@ -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)
{
//
}
}
+104
View File
@@ -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)
{
//
}
}
+104
View File
@@ -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)
{
//
}
}
+30
View File
@@ -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' => [],
];
+5
View File
@@ -0,0 +1,5 @@
{
"frontend": [],
"member": [],
"backend": []
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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' => '',
+74
View File
@@ -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.sqlCREATE 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;
}
}
+14
View File
@@ -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' => '© 下载站',
];
+201
View File
@@ -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('请选择分类');
}
}
}
+33
View File
@@ -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,
];
+43
View File
@@ -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 '父级ID0=一级',
`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='下载资源';
+32
View File
@@ -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',
];
+25
View File
@@ -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' => '设计素材',
];
+18
View File
@@ -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": []
}
+100
View File
@@ -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 . ' 个资源,请先移走或删除这些资源');
}
}
}
+74
View File
@@ -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 '-';
}
}
+25
View File
@@ -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/');
+61
View File
@@ -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>
+4 -4
View File
@@ -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');
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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 后调用)。
+44 -9
View File
@@ -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();
}
+5 -4
View File
@@ -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');
}
+2 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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
+2 -1
View File
@@ -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');
+80
View File
@@ -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 '所属二级分类ID0表示未细分' 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%');
+282 -329
View File
@@ -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>
+56 -11
View File
@@ -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">
+19 -12
View File
@@ -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
View File
@@ -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;
}
}
+1 -1
View File
@@ -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'],
];
+16
View File
@@ -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' => '最大保活间隔(秒)',
+110
View File
@@ -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]);
}
}
+2 -2
View File
@@ -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) {
+11 -2
View File
@@ -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,
+1
View File
@@ -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 '备注',
+8
View File
@@ -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');
});
});
+28 -2
View File
@@ -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) {
+8
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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;
}
/**
+2 -2
View File
@@ -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
View File
@@ -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,
];
+1 -1
View File
@@ -17,7 +17,7 @@ use ywxapp\AddonBase;
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon extends addon
class Addon extends AddonBase
{
public function install()
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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('接收用户不存在');
}
+11 -11
View File
@@ -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,
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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',
+1 -1
View File
@@ -23,7 +23,7 @@ return [
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.1',
'version' => '1.0.2',
'state' => 0,
'url' => '/wxchat',
'license' => '',
+2 -2
View File
@@ -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'];
+2 -2
View File
@@ -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 = ['*'];
+132
View File
@@ -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;
}
}
+46 -28
View File
@@ -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([], '验证码正确');
}
}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
{
+71 -37
View File
@@ -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
View File
@@ -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 则需先通过短信验证码校验。
* 成功后自动签发 JWTaccess_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 验证密码;
* 成功后记录登录信息并签发 JWTaccess_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);
}
}
+1 -1
View File
@@ -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;
/**
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -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],
+13 -6
View File
@@ -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();
}
+1 -1
View File
@@ -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;
/**
+2 -2
View File
@@ -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('用户不存在');
}
+62 -9
View File
@@ -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' => '已删除']);
}
+1 -1
View File
@@ -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;
/**
* 站点公告管理
+1 -1
View File
@@ -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;
/**
+1 -1
View File
@@ -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