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
+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>