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:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\AddonBase;
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 下载站插件
|
||||
*
|
||||
* 安装由框架自动导入 install.sql 建表(wxapp_download_category / resource);
|
||||
* 卸载按统一前缀清理全部表;升级时确保表存在(install.sql 幂等建表)。
|
||||
*/
|
||||
class Addon extends AddonBase
|
||||
{
|
||||
public function install(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载钩子:清理本插件全部表(与 install.sql 表名前缀严格对齐)。
|
||||
*/
|
||||
public function uninstall(): bool
|
||||
{
|
||||
$prefix = 'wxapp_download_';
|
||||
$tables = [
|
||||
'category',
|
||||
'resource',
|
||||
];
|
||||
foreach ($tables as $t) {
|
||||
try {
|
||||
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
|
||||
} catch (\Exception $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级时确保全部表存在(读 install.sql,CREATE TABLE IF NOT EXISTS 幂等建表)。
|
||||
* 单一事实源 = install.sql,避免复制 DDL 导致漂移。
|
||||
*/
|
||||
private function ensureTablesFromInstallSql(): void
|
||||
{
|
||||
$sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql';
|
||||
if (!is_file($sqlFile)) {
|
||||
return;
|
||||
}
|
||||
$content = (string) file_get_contents($sqlFile);
|
||||
$content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content);
|
||||
$stmts = array_filter(
|
||||
array_map('trim', explode(';', $content)),
|
||||
function ($s) {
|
||||
return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s);
|
||||
}
|
||||
);
|
||||
foreach ($stmts as $sql) {
|
||||
if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) {
|
||||
BaseModel::ensureTable($m[1], $sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function upgrade($currentVersion = ''): bool
|
||||
{
|
||||
$this->ensureTablesFromInstallSql();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
// 下载站插件默认配置(与 install.sql 无关,仅作为 readConfig 第一层默认值)。
|
||||
// 后台「插件配置」保存会写入 addon_config 表并覆盖此处。
|
||||
return [
|
||||
'title' => '下载',
|
||||
'page_size' => 20,
|
||||
'local_path' => '/static/download/files/',
|
||||
'need_audit' => 1,
|
||||
'show_hot' => 1,
|
||||
'show_new' => 1,
|
||||
'beian' => '',
|
||||
'copyright' => '© 下载站',
|
||||
];
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download\controller;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Lang;
|
||||
use think\facade\Cookie;
|
||||
use think\Response;
|
||||
use ywxapp\controller\FrontendBase;
|
||||
use addon\download\model\Category;
|
||||
use addon\download\model\Resource;
|
||||
use addon\download\service\DownloadService;
|
||||
|
||||
class Index extends FrontendBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function initialize()
|
||||
{
|
||||
// 多语言:支持 ?lang=zh-cn|en-us 切换并写 cookie;插件级 lang 显式加载兜底
|
||||
$lang = $this->request->param('lang', '');
|
||||
if (in_array($lang, ['zh-cn', 'en-us'], true)) {
|
||||
Lang::setLangSet($lang);
|
||||
Cookie::set('download_lang', $lang);
|
||||
} elseif ($cookie = Cookie::get('download_lang')) {
|
||||
Lang::setLangSet($cookie);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 固定页面文案注入模板
|
||||
$this->view->assign([
|
||||
'site_name' => lang('site_name'),
|
||||
'all_category_text' => lang('all_category'),
|
||||
'hot_text' => lang('hot'),
|
||||
'latest_text' => lang('latest'),
|
||||
'list_text' => lang('list'),
|
||||
'detail_text' => lang('detail'),
|
||||
'search_text' => lang('search'),
|
||||
'search_placeholder' => lang('search_placeholder'),
|
||||
'download_btn_text' => lang('download_btn'),
|
||||
]);
|
||||
|
||||
// 插件前台接入「全站动态布局」:视图只写正文,由核心 frontend 的
|
||||
// common/layout.html 包裹全站 header / footer(与开发文档约定一致)。
|
||||
// 注意:layout_name 必须带 .html 扩展名,否则 think-template 的
|
||||
// parseTemplateFile() 会把无扩展名的绝对路径里的盘符冒号(D:)当成
|
||||
// 模板分隔符替换成当前 viewPath,导致「模板文件不存在」。
|
||||
$this->view->config([
|
||||
'layout_on' => true,
|
||||
'layout_name' => $this->app->getRootPath() . 'app' . DIRECTORY_SEPARATOR
|
||||
. 'frontend' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
|
||||
. 'common' . DIRECTORY_SEPARATOR . 'layout.html',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本插件配置(运行时由 AppInit 注入 config('download'))
|
||||
*/
|
||||
protected function pluginConfig(): array
|
||||
{
|
||||
return Config::get('download', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页:分类导航 + 热门/最新(按 config 开关)
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$cfg = $this->pluginConfig();
|
||||
|
||||
$categories = Category::with(['resources' => function ($q) {
|
||||
$q->order('downloads', 'desc')->limit(8);
|
||||
}])
|
||||
->where('pid', 0)
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
|
||||
$hot = [];
|
||||
$news = [];
|
||||
if (!empty($cfg['show_hot'])) {
|
||||
$hot = Resource::scope('visible')->order('downloads', 'desc')->limit(10)->select();
|
||||
}
|
||||
if (!empty($cfg['show_new'])) {
|
||||
$news = Resource::scope('visible')->order('id', 'desc')->limit(10)->select();
|
||||
}
|
||||
|
||||
$this->view->assign('categories', $categories);
|
||||
$this->view->assign('hot', $hot);
|
||||
$this->view->assign('news', $news);
|
||||
$this->view->assign('config', $cfg);
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类列表页
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$cid = $this->request->param('cid/d', 0);
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$cfg = $this->pluginConfig();
|
||||
$size = (int)($cfg['page_size'] ?? 20);
|
||||
|
||||
$cat = $cid ? Category::find($cid) : null;
|
||||
if ($cid && !$cat) {
|
||||
$this->error('分类不存在');
|
||||
}
|
||||
|
||||
$list = Resource::scope('visible');
|
||||
if ($cid) {
|
||||
$list = $list->where('cid', $cid);
|
||||
}
|
||||
$list = $list->order('downloads', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $size]);
|
||||
|
||||
$this->view->assign('cat', $cat);
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('pager', $list->render());
|
||||
$this->view->assign('config', $cfg);
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$id = $this->request->param('id/d', 0);
|
||||
$resource = Resource::scope('visible')->find($id);
|
||||
if (!$resource) {
|
||||
$this->error('资源不存在或已下架');
|
||||
}
|
||||
|
||||
// 查看计数(防刷交给 service 内的简单锁,此处仅点击+1)
|
||||
DownloadService::incClicks($id);
|
||||
|
||||
$cat = $resource['cid'] ? Category::find($resource['cid']) : null;
|
||||
|
||||
$this->view->assign('resource', $resource);
|
||||
$this->view->assign('cat', $cat);
|
||||
$this->view->assign('config', $this->pluginConfig());
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索
|
||||
*/
|
||||
public function search()
|
||||
{
|
||||
$q = $this->request->param('q', $this->request->param('kw', ''));
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$cfg = $this->pluginConfig();
|
||||
$size = (int)($cfg['page_size'] ?? 20);
|
||||
|
||||
$list = Resource::scope('visible');
|
||||
if ($q) {
|
||||
$list = $list->whereLike('title', "%{$q}%");
|
||||
}
|
||||
$list = $list->order('downloads', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $size]);
|
||||
|
||||
$this->view->assign('keyword', $q);
|
||||
$this->view->assign('list', $list);
|
||||
$this->view->assign('pager', $list->render());
|
||||
$this->view->assign('config', $cfg);
|
||||
return $this->view->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发下载:外链 302 跳转 / 本地文件流式输出
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
$id = $this->request->param('id/d', 0);
|
||||
$ip = $this->request->ip();
|
||||
try {
|
||||
$ret = DownloadService::dispatch($id, $ip);
|
||||
} catch (\think\Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if ($ret['type'] === 'redirect') {
|
||||
return redirect($ret['url']);
|
||||
}
|
||||
|
||||
// 本地文件流式输出(避免大文件占用内存)
|
||||
$path = $ret['path'];
|
||||
$name = ($ret['name'] ?? 'download') . '.' . pathinfo($path, PATHINFO_EXTENSION);
|
||||
return Response::create()->data(file_get_contents($path))->header([
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . rawurlencode($name) . '"',
|
||||
'Content-Length' => filesize($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download\controller\backend;
|
||||
|
||||
use think\exception\ValidateException;
|
||||
use think\facade\Db;
|
||||
use addon\download\model\Category as CategoryModel;
|
||||
use addon\download\model\Resource;
|
||||
|
||||
use ywxapp\controller\BackendBase;
|
||||
class Category extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->model = new CategoryModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$title = $this->request->param('title', '');
|
||||
$all = $this->model->order('sort', 'desc')->order('id', 'asc')->select()->toArray();
|
||||
//$tree = CategoryModel::toNestedTree($all);
|
||||
|
||||
// 关键字过滤:命中节点保留,并保留其祖先链
|
||||
// if ($title) {
|
||||
// $tree = $this->filterTree($tree, $title);
|
||||
// }
|
||||
$this->result->success($all);
|
||||
}
|
||||
return $this->view->fetch('category/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按标题关键字过滤树,保留命中节点及其祖先链
|
||||
*/
|
||||
protected function filterTree(array $tree, string $keyword): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($tree as $node) {
|
||||
$children = !empty($node['children']) ? $this->filterTree($node['children'], $keyword) : [];
|
||||
$hit = stripos($node['title'], $keyword) !== false;
|
||||
if ($hit || !empty($children)) {
|
||||
$node['children'] = $children;
|
||||
$result[] = $node;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$data = CategoryModel::cateTree($this->model->select()->toArray());
|
||||
$this->result->success(['data' => $data]);
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$params = $this->request->post();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$this->model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($this->model, '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('保存失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
$this->result->error('数据不存在');
|
||||
}
|
||||
if ($this->request->isAjax()) {
|
||||
$tree = CategoryModel::cateTree($this->model->select()->toArray());
|
||||
$this->result->success(['power' => $tree, 'info' => $model]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
if ($this->request->isAjax() && $this->request->isPut()) {
|
||||
$params = $this->request->param();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
throw new ValidateException('数据不存在');
|
||||
}
|
||||
$model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($model, '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('更新失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if ($this->request->isAjax() && $this->request->isDelete()) {
|
||||
$ids = $this->request->param('ids', '');
|
||||
$force = $this->request->param('force/d', 0);
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的数据');
|
||||
}
|
||||
$idArr = array_filter(array_map('intval', explode(',', $ids)));
|
||||
try {
|
||||
Db::transaction(function () use ($idArr, $force) {
|
||||
foreach ($idArr as $id) {
|
||||
$this->deleteCascade($id, (bool) $force);
|
||||
}
|
||||
});
|
||||
$this->result->success();
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $th) {
|
||||
$this->result->error('删除失败: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归级联删除:force=true 时先删子分类下的资源与子分类,再删自身
|
||||
*/
|
||||
protected function deleteCascade($id, bool $force): void
|
||||
{
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
if ($force) {
|
||||
Resource::where('cid', $id)->delete();
|
||||
$children = $this->model->where('pid', $id)->select();
|
||||
foreach ($children as $child) {
|
||||
$this->deleteCascade($child['id'], true);
|
||||
}
|
||||
}
|
||||
// 非 force 时若仍有子分类/资源,onBeforeDelete 会抛出异常阻止删除
|
||||
$model->delete();
|
||||
}
|
||||
|
||||
protected function validateSave(array $params): void
|
||||
{
|
||||
if (empty($params['title'])) {
|
||||
throw new ValidateException('分类名称不能为空');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\download\controller\backend;
|
||||
|
||||
use think\exception\ValidateException;
|
||||
use think\facade\Db;
|
||||
use addon\download\model\Resource as ResourceModel;
|
||||
use addon\download\model\Category as CategoryModel;
|
||||
use ywxapp\controller\BackendBase;
|
||||
class Resource extends BackendBase
|
||||
{
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
protected function initialize()
|
||||
{
|
||||
$this->model = new ResourceModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$title = $this->request->param('title', '');
|
||||
$cid = $this->request->param('cid/d', 0);
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
$data = $this->model
|
||||
->with(['category'])
|
||||
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
|
||||
->when($cid, fn($q, $c) => $q->where('cid', $c))
|
||||
->order('id', 'desc')
|
||||
->paginate(['page' => $page, 'list_rows' => $limit]);
|
||||
$this->result->setCount($data->total());
|
||||
$this->result->success($data->items());
|
||||
}
|
||||
$categories = (new CategoryModel())
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
$this->view->assign('categories', $categories);
|
||||
return $this->view->fetch('resource/index');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
if ($this->request->isAjax()) {
|
||||
$categories = (new CategoryModel())
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
$this->result->success(['categories' => $categories]);
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$params = $this->request->post();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$this->model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($this->model, '保存成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('保存失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function edit($id = null)
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
$this->result->error('数据不存在');
|
||||
}
|
||||
if ($this->request->isAjax()) {
|
||||
$categories = (new CategoryModel())
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc')
|
||||
->select();
|
||||
$this->result->success(['categories' => $categories, 'info' => $model]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update()
|
||||
{
|
||||
$id = $this->request->param('id');
|
||||
if ($this->request->isAjax() && $this->request->isPut()) {
|
||||
$params = $this->request->param();
|
||||
$this->validateSave($params);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$model = $this->model->find($id);
|
||||
if (!$model) {
|
||||
throw new ValidateException('数据不存在');
|
||||
}
|
||||
$model->save($params);
|
||||
Db::commit();
|
||||
$this->result->success($model, '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
$this->result->error('更新失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
if ($this->request->isAjax() && $this->request->isDelete()) {
|
||||
$ids = $this->request->param('ids', '');
|
||||
if (empty($ids)) {
|
||||
$this->result->error('请选择要删除的数据');
|
||||
}
|
||||
try {
|
||||
Db::transaction(function () use ($ids) {
|
||||
$this->model->destroy($ids);
|
||||
});
|
||||
$this->result->success();
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $th) {
|
||||
$this->result->error('删除失败: ' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateSave(array $params): void
|
||||
{
|
||||
if (empty($params['title'])) {
|
||||
throw new ValidateException('资源标题不能为空');
|
||||
}
|
||||
if (empty($params['cid'])) {
|
||||
throw new ValidateException('请选择分类');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'download',
|
||||
'title' => '下载站',
|
||||
'intro' => '资源下载站,支持分类管理、资源管理与下载统计。',
|
||||
'author' => 'ywxapp',
|
||||
'website' => 'https://github.com',
|
||||
'version' => '1.0.0',
|
||||
'state' => 1,
|
||||
'url' => '/download',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
'config' => [
|
||||
['name' => 'page_size', 'title' => '列表每页数量', 'type' => 'number', 'value' => 20],
|
||||
['name' => 'local_path', 'title' => '本地存储目录', 'type' => 'text', 'value' => '/static/download/files/'],
|
||||
['name' => 'need_audit', 'title' => '用户投稿需审核', 'type' => 'switch', 'value' => 1],
|
||||
['name' => 'show_hot', 'title' => '首页显示热门下载', 'type' => 'switch', 'value' => 1],
|
||||
['name' => 'show_new', 'title' => '首页显示最新上传', 'type' => 'switch', 'value' => 1],
|
||||
],
|
||||
'events' => [
|
||||
'bind' => [],
|
||||
'listen' => [],
|
||||
'subscribe' => [],
|
||||
],
|
||||
'middleware' => [
|
||||
'alias' => [],
|
||||
'priority' => [],
|
||||
],
|
||||
'services' => [],
|
||||
'install_time' => 1787000000,
|
||||
'update_time' => 1787000000,
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 下载站插件安装脚本(前缀硬写 wxapp_download_,与 Addon.php 卸载清单一致)
|
||||
-- 注意:种子文案禁 -- 注释整段;字符串内禁 \' ,用 '' 转义。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wxapp_download_category` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`pid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '父级ID,0=一级',
|
||||
`title` varchar(50) NOT NULL DEFAULT '' COMMENT '分类名',
|
||||
`cover` varchar(255) NOT NULL DEFAULT '' COMMENT '图标/封面',
|
||||
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序,越大越靠前',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏',
|
||||
`create_at` int(11) NOT NULL DEFAULT 0,
|
||||
`update_at` int(11) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `pid` (`pid`),
|
||||
KEY `status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载分类';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `wxapp_download_resource` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`cid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '分类ID',
|
||||
`title` varchar(150) NOT NULL DEFAULT '' COMMENT '资源标题',
|
||||
`cover` varchar(255) NOT NULL DEFAULT '' COMMENT '封面图',
|
||||
`intro` text COMMENT '简介',
|
||||
`author` varchar(50) NOT NULL DEFAULT '' COMMENT '作者/出品方',
|
||||
`version` varchar(30) NOT NULL DEFAULT '' COMMENT '版本号',
|
||||
`file_size` bigint(20) NOT NULL DEFAULT 0 COMMENT '文件字节数',
|
||||
`file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '外链地址或本地相对路径',
|
||||
`is_local` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1=本地存储 0=外链',
|
||||
`is_free` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=免费 0=收费',
|
||||
`price` int(11) NOT NULL DEFAULT 0 COMMENT '所需积分/价格',
|
||||
`clicks` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '查看次数',
|
||||
`downloads` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '下载次数',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=正常 0=下架',
|
||||
`is_audit` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=已审 0=待审',
|
||||
`source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=后台 1=用户投稿',
|
||||
`create_at` int(11) NOT NULL DEFAULT 0,
|
||||
`update_at` int(11) NOT NULL DEFAULT 0,
|
||||
`delete_at` int(11) NOT NULL DEFAULT 0 COMMENT '软删除,0=未删',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `cid` (`cid`),
|
||||
KEY `status` (`status`),
|
||||
KEY `is_audit` (`is_audit`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载资源';
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/*
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-18 16:46:29
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-18 17:25:53
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\addon\download\lang\en-us.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
*/
|
||||
|
||||
// 下载站插件 - 英文语言包
|
||||
// 键与 zh-cn.php 一一对应;未在中文包中列出的固定文案(如自由录入的资源名)
|
||||
// 由 lang() 透传原文(中文),在此无需声明。
|
||||
|
||||
return [
|
||||
// 站点 / 页面
|
||||
'site_name' => 'Downloads',
|
||||
'all_category' => 'All Categories',
|
||||
'hot' => 'Popular',
|
||||
'latest' => 'Latest',
|
||||
'search' => 'Search',
|
||||
'search_placeholder' => 'Search resources',
|
||||
'detail' => 'Detail',
|
||||
'list' => 'List',
|
||||
'download_btn' => 'Download',
|
||||
'no_cover' => 'No cover',
|
||||
|
||||
// 示例:固定分类名
|
||||
'category.dev' => 'Dev Tools',
|
||||
'category.design' => 'Design Assets',
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
// 下载站插件 - 简体中文语言包
|
||||
// 约定:title 字段在数据库存默认语言(中文),lang() 对未定义键原样透传,
|
||||
// 因此自由录入的资源/分类名无需在此列出;此处仅维护「固定页面文案」与
|
||||
// 「需要英文翻译的固定分类名」的映射。
|
||||
|
||||
return [
|
||||
// 站点 / 页面
|
||||
'site_name' => '下载站',
|
||||
'all_category' => '分类',
|
||||
'hot' => '热门',
|
||||
'latest' => '最新',
|
||||
'search' => '搜索',
|
||||
'search_placeholder' => '搜索资源名称',
|
||||
'detail' => '详情',
|
||||
'list' => '列表',
|
||||
'download_btn' => '立即下载',
|
||||
'no_cover' => '暂无封面',
|
||||
|
||||
// 示例:固定分类名(若想让英文站点显示英文分类名,在此给出映射;
|
||||
// 自由录入的分类名 lang() 会原样透传中文)
|
||||
'category.dev' => '开发工具',
|
||||
'category.design' => '设计素材',
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"backend": [
|
||||
{
|
||||
"name": "download",
|
||||
"title": "下载站",
|
||||
"icon": "fa fa-download",
|
||||
"type": 1,
|
||||
"sort": 60,
|
||||
"status": 1,
|
||||
"child": [
|
||||
{ "name": "download/category", "title": "分类管理", "icon": "fa fa-list", "type": 2, "sort": 0, "route": "/download/backend/category/index" },
|
||||
{ "name": "download/resource", "title": "资源管理", "icon": "fa fa-file-archive-o", "type": 2, "sort": 1, "route": "/download/backend/resource/index" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"member": [],
|
||||
"frontend": []
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\download\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
class Category extends BaseModel
|
||||
{
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'download_category',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 子分类(pid 指向自身)
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'pid', 'id')
|
||||
->where('status', 1)
|
||||
->order('sort', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类下的资源
|
||||
*/
|
||||
public function resources()
|
||||
{
|
||||
return $this->hasMany(Resource::class, 'cid', 'id')
|
||||
->where('status', 1)
|
||||
->where('is_audit', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平铺分类列表转带前缀的下拉选项(供新增/编辑表单的上级分类 select 使用)
|
||||
* 直接复用父类 BaseModel::cateTree($cate, $name='title', $lefthtml='|— ', $pid=0, $level=0),
|
||||
* 返回的 title 自带层级前缀,无需在子类重复声明(会与父类签名冲突 fatal error)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将平铺分类列表转换为嵌套树(供 layui.treeTable 使用)
|
||||
* 返回形如 [['id'=>..,'children'=>[...]], ...]
|
||||
*/
|
||||
public static function toNestedTree(array $list): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($list as &$item) {
|
||||
$item['children'] = [];
|
||||
$map[$item['id']] = &$item;
|
||||
}
|
||||
unset($item);
|
||||
$tree = [];
|
||||
foreach ($list as &$item) {
|
||||
if (!empty($item['pid']) && isset($map[$item['pid']])) {
|
||||
$map[$item['pid']]['children'][] = &$item;
|
||||
} else {
|
||||
$tree[] = &$item;
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
// 清理空的 children,保持返回结构干净
|
||||
return self::cleanEmptyChildren($tree);
|
||||
}
|
||||
|
||||
protected static function cleanEmptyChildren(array $tree): array
|
||||
{
|
||||
foreach ($tree as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$node['children'] = self::cleanEmptyChildren($node['children']);
|
||||
} else {
|
||||
unset($node['children']);
|
||||
}
|
||||
}
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前保护:存在子分类或资源时禁止删除
|
||||
*/
|
||||
public static function onBeforeDelete($model)
|
||||
{
|
||||
$childCount = self::where('pid', $model->id)->count();
|
||||
if ($childCount > 0) {
|
||||
throw new \think\Exception('该分类下还存在 ' . $childCount . ' 个子分类,请先处理子分类');
|
||||
}
|
||||
$resCount = \addon\download\model\Resource::where('cid', $model->id)->count();
|
||||
if ($resCount > 0) {
|
||||
throw new \think\Exception('该分类下还存在 ' . $resCount . ' 个资源,请先移走或删除这些资源');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\download\model;
|
||||
|
||||
use ywxapp\model\BaseModel;
|
||||
|
||||
class Resource extends BaseModel
|
||||
{
|
||||
protected $defaultSoftDelete = 0;
|
||||
protected $deleteTime = 'delete_at';
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'strict' => false,
|
||||
'name' => 'download_resource',
|
||||
'autoRelation' => [],
|
||||
'createTime' => 'create_at',
|
||||
'updateTime' => 'update_at',
|
||||
'dateFormat' => 'Y-m-d H:i:s',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 所属分类
|
||||
*/
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'cid', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小友好显示(字节 -> KB/MB/GB)
|
||||
*/
|
||||
public function getSizeTextAttr($value, $data)
|
||||
{
|
||||
$size = (int)($data['file_size'] ?? 0);
|
||||
if ($size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = 0;
|
||||
while ($size >= 1024 && $i < count($units) - 1) {
|
||||
$size /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return round($size, 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表查询作用域:仅正常且已审
|
||||
*/
|
||||
public function scopeVisible($query)
|
||||
{
|
||||
return $query->where('status', 1)->where('is_audit', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类名称(依赖预载入的 category 关联)
|
||||
*/
|
||||
public function getCategoryTitleAttr($value, $data)
|
||||
{
|
||||
if (isset($this->category) && $this->category) {
|
||||
return $this->category->title;
|
||||
}
|
||||
$cid = (int)($data['cid'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
return (new Category())->where('id', $cid)->value('title') ?: '-';
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | 本文件由 ywxapp/service/AppService::loadAddonRoutes() 在 boot 阶段 include,
|
||||
// | 并统一被外层 Route::group('download', ...) 包住,下方均写【相对规则】,
|
||||
// | 最终自动加 /download 前缀:前台 -> /download/* ,后台 -> /download/backend/* 。
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
use think\facade\Route;
|
||||
|
||||
// 前台页面 / 接口
|
||||
Route::rule('index', 'Index/index');
|
||||
Route::rule('list/:cid', 'Index/list');
|
||||
Route::rule('detail/:id', 'Index/detail');
|
||||
Route::rule('search', 'Index/search');
|
||||
Route::rule('down/:id', 'Index/down'); // 触发下载(外链跳转 / 本地落盘)
|
||||
|
||||
// 后台管理路由(对应 controller/backend/ 下的控制器)
|
||||
Route::group('backend', function () {
|
||||
Route::rule('category', 'Category/index');
|
||||
Route::rule('category/:action', 'Category/:action');
|
||||
Route::rule('resource', 'Resource/index');
|
||||
Route::rule('resource/:action', 'Resource/:action');
|
||||
})->prefix('backend/');
|
||||
@@ -0,0 +1,61 @@
|
||||
-- 下载站测试数据(可直接前台展示:status=1 / is_audit=1)
|
||||
-- 前缀 wxapp_ 与 install.sql 保持一致。执行前如已存在测试数据先清空:
|
||||
-- TRUNCATE `wxapp_download_category`; TRUNCATE `wxapp_download_resource`;
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ===================== 一级分类 =====================
|
||||
INSERT INTO `wxapp_download_category` (`id`, `pid`, `title`, `cover`, `sort`, `status`, `create_at`, `update_at`) VALUES
|
||||
(1, 0, '办公软件', '📄', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(2, 0, '安全杀毒', '🛡️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(3, 0, '图形图像', '🎨', 80, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(4, 0, '影音播放', '🎬', 70, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(5, 0, '开发工具', '💻', 60, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(6, 0, '游戏娱乐', '🎮', 50, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
-- ===================== 二级分类 =====================
|
||||
INSERT INTO `wxapp_download_category` (`id`, `pid`, `title`, `cover`, `sort`, `status`, `create_at`, `update_at`) VALUES
|
||||
(11, 1, '文档处理', '📝', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(12, 1, '表格计算', '📊', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(13, 2, '杀毒软件', '🦠', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(14, 2, '防火墙', '🔥', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(15, 3, '图像处理', '🖼️', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(16, 3, '矢量绘图', '✏️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(17, 4, '视频播放', '📺', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(18, 4, '音乐播放', '🎵', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(19, 5, '编辑器', '⌨️', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(20, 5, '数据库', '🗄️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
-- ===================== 资源(全部可见 status=1 / is_audit=1) =====================
|
||||
INSERT INTO `wxapp_download_resource`
|
||||
(`cid`, `title`, `cover`, `intro`, `author`, `version`, `file_size`, `file_url`, `is_local`, `is_free`, `price`, `clicks`, `downloads`, `status`, `is_audit`, `source`, `create_at`, `update_at`, `delete_at`) VALUES
|
||||
-- 办公软件 / 文档处理
|
||||
(11, '极速文档 2026 专业版', '📄', '轻量级文档处理工具,支持 Word/PDF 互转,启动快、占用低。', '极速软件', '2026.1.0', 88450390, 'https://pc.qq.com/', 0, 1, 0, 1320, 980, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(11, '云笔记 Markdown 编辑器', '📝', '支持双向链接与大纲视图的本地优先笔记软件。', '云栈科技', '3.4.2', 45208700, 'https://pc.qq.com/', 0, 1, 0, 880, 612, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(12, '表格大师 计算版', '📊', '海量数据秒级计算,自带数据透视与图表模板。', '数擎信息', '11.0', 120560000, 'https://pc.qq.com/', 0, 1, 0, 540, 330, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(12, '财务报表一键生成器', '💡', '内置 200+ 财务模板,快速生成合规报表。', '财通软件', '2.8.1', 30990000, 'https://pc.qq.com/', 0, 0, 50, 410, 188, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 安全杀毒
|
||||
(13, '护盾杀毒 免费版', '🦠', '云查杀引擎,体积仅 30MB,低内存占用。', '护盾实验室', '2026.0.3', 31457280, 'https://pc.qq.com/', 0, 1, 0, 2200, 1900, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(13, '木马专杀工具箱', '🧰', '针对顽固木马与流氓插件的专杀合集。', '净网团队', '5.2', 15800300, 'https://pc.qq.com/', 0, 1, 0, 960, 720, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(14, '个人防火墙 极简版', '🔥', '仅允许白名单程序联网,杜绝后台偷跑流量。', '安域科技', '1.9.7', 9870000, 'https://pc.qq.com/', 0, 1, 0, 320, 150, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 图形图像
|
||||
(15, '美图秀秀 电脑版', '🖼️', '一键美颜、抠图、拼图,海量素材免费下载。', '美图公司', '2026.2', 156000000, 'https://pc.qq.com/', 0, 1, 0, 5300, 4700, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(15, '光影魔术手', '🌅', '照片后期调色利器,批量处理更高效。', '光影工作室', '4.5.1', 68000000, 'https://pc.qq.com/', 0, 1, 0, 1200, 880, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(16, '矢量绘图画板', '✏️', '类似 Illustrator 的开源矢量绘图工具。', '开源社区', '1.2.0', 42000000, 'https://pc.qq.com/', 0, 1, 0, 700, 430, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 影音播放
|
||||
(17, '全能影音播放器', '📺', '支持 4K/HDR,几乎通吃所有视频格式。', '全能影音', '9.3.0', 88000000, 'https://pc.qq.com/', 0, 1, 0, 6100, 5400, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(17, '本地视频剪辑', '🎞️', '轻量剪辑,导出无水印,适合短视频创作。', '剪映轻量版', '3.0.1', 210000000, 'https://pc.qq.com/', 0, 1, 0, 3400, 2600, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(18, '高保真音乐播放器', '🎵', '支持无损 FLAC/APE,歌词自动匹配。', '声海科技', '7.1.4', 39000000, 'https://pc.qq.com/', 0, 1, 0, 1900, 1500, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 开发工具
|
||||
(19, '代码编辑器 Pro', '⌨️', '智能补全、远程开发、多光标编辑,插件丰富。', '码云开源', '4.12.0', 95000000, 'https://pc.qq.com/', 0, 1, 0, 2800, 2100, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(19, '终端利器 Tabby', '🖥️', '跨平台现代终端,支持 SSH/SFTP 与主题美化。', 'Tabby 社区', '1.0.205', 125000000, 'https://pc.qq.com/', 0, 1, 0, 1100, 760, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(20, '数据库管理工具', '🗄️', '同时连接 MySQL/PostgreSQL/SQLite,可视化建模。', '数据方舟', '6.4.2', 73000000, 'https://pc.qq.com/', 0, 0, 30, 640, 290, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
-- 游戏娱乐
|
||||
(6, '休闲益智合集', '🎮', '100 款单机小游戏打包,离线即玩。', '乐玩工作室', '2026.春节版', 540000000, 'https://pc.qq.com/', 0, 1, 0, 4200, 3800, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(6, '模拟器大厅', '🕹️', '集成多平台复古游戏模拟器,手柄即插即用。', '怀旧游戏社', '2.1.0', 88000000, 'https://pc.qq.com/', 0, 1, 0, 1500, 970, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0);
|
||||
|
||||
-- ===================== 少量隐藏/待审数据(用于验证筛选) =====================
|
||||
INSERT INTO `wxapp_download_resource`
|
||||
(`cid`, `title`, `cover`, `intro`, `author`, `version`, `file_size`, `file_url`, `is_local`, `is_free`, `price`, `clicks`, `downloads`, `status`, `is_audit`, `source`, `create_at`, `update_at`, `delete_at`) VALUES
|
||||
(11, '(下架)旧版文档工具', '📄', '仅用于测试下架不展示。', '极速软件', '2020.0', 40000000, 'https://pc.qq.com/', 0, 1, 0, 100, 50, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
|
||||
(13, '(待审)杀毒内测版', '🦠', '仅用于测试待审不展示。', '护盾实验室', '2027.beta', 33000000, 'https://pc.qq.com/', 0, 1, 0, 20, 5, 1, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0);
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\download\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
use addon\download\model\Resource;
|
||||
|
||||
/**
|
||||
* 下载核心服务:计数 + 防盗链 + 本地/外链分发。
|
||||
*/
|
||||
class DownloadService
|
||||
{
|
||||
/**
|
||||
* 触发下载:返回 ['type'=>'redirect','url'=>...] 或 ['type'=>'file','path'=>...]
|
||||
* @param int $id 资源ID
|
||||
* @param string $clientIp 客户端IP(用于防刷)
|
||||
* @return array
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public static function dispatch(int $id, string $clientIp): array
|
||||
{
|
||||
$resource = Resource::find($id);
|
||||
if (!$resource || $resource->status != 1 || $resource->is_audit != 1) {
|
||||
throw new \think\Exception('资源不存在或已下架');
|
||||
}
|
||||
|
||||
// 防刷:同 IP + 同资源 5 秒内不重复计数
|
||||
$lockKey = 'dl_lock_' . md5($clientIp . '_' . $id);
|
||||
if (!Cache::get($lockKey)) {
|
||||
Db::name('download_resource')
|
||||
->where('id', $id)
|
||||
->inc('downloads', 1)
|
||||
->update();
|
||||
Cache::set($lockKey, 1, 5);
|
||||
}
|
||||
|
||||
if ((int)$resource->is_local === 1) {
|
||||
// 本地文件:返回服务器绝对路径,由控制器做流式输出
|
||||
$root = app()->getRootPath() . 'public';
|
||||
$path = $root . $resource->file_url;
|
||||
if (!is_file($path)) {
|
||||
throw new \think\Exception('文件不存在');
|
||||
}
|
||||
return ['type' => 'file', 'path' => $path, 'name' => $resource->title];
|
||||
}
|
||||
|
||||
if (empty($resource->file_url)) {
|
||||
throw new \think\Exception('下载地址为空');
|
||||
}
|
||||
return ['type' => 'redirect', 'url' => $resource->file_url];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看计数(详情页调用)
|
||||
*/
|
||||
public static function incClicks(int $id): void
|
||||
{
|
||||
Db::name('download_resource')
|
||||
->where('id', $id)
|
||||
->inc('clicks', 1)
|
||||
->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"> </div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/html" id="tableBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="新建分类" lay-event="dataCreate" data-perm="download:add"> <i class="layui-icon layui-icon-add-1"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="dataDelete" data-perm="download:delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="分类回收站" lay-event="dataRecybin" data-perm="download:recyclebin"><i class="layui-icon layui-icon-home"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑分类" lay-event="update" data-perm="download:edit"><i class="layui-icon layui-icon-edit"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="添加子分类" lay-event="create" data-perm="download:add"><i class="layui-icon layui-icon-add-1"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="delete" data-perm="download:delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<!-- 添加/编辑分类表单模板 -->
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
|
||||
<input type="hidden" name="id" value="{{= d.id || '' }}">
|
||||
<input type="hidden" name="pid" value="{{= d.pid || 0 }}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分类名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="请输入分类名称" value="{{ d.title || '' }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="cover" id="iconPicker" placeholder="支持 emoji 或图片URL" value="{{ d.cover || '' }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{{ d.sort || 50 }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用" {{ d.status != 0 ? 'checked' : (!d.id ? 'checked' : '') }}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-hide">
|
||||
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
|
||||
</div>
|
||||
</form>
|
||||
</script>
|
||||
<!-- 状态开关 -->
|
||||
<script type="text/html" id="statusTpl">
|
||||
<input type="checkbox" name="status" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
|
||||
</script>
|
||||
<script type="text/html" id="dataRecybinTpl">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"> </div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataRecybinTable" lay-filter="dataRecybinTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type="text/html" id="dataRecybinBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="恢复数据" lay-event="restore"><i class="layui-icon layui-icon-edit"></i> </a>
|
||||
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div>
|
||||
</script>
|
||||
<script>
|
||||
layui.use(['layer', 'http', 'auth'], function () {
|
||||
var $ = layui.$;
|
||||
var treeTable = layui.treeTable;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var laytpl = layui.laytpl;
|
||||
var http = layui.http;
|
||||
var auth = layui.auth;
|
||||
var _savedPerms = (layui.data('backend').permission) || [];
|
||||
auth.init(_savedPerms);
|
||||
auth.setController('download');
|
||||
treeTable.render({
|
||||
elem: '#dataTable',
|
||||
url: 'index',
|
||||
parseData: function (res) {
|
||||
return {
|
||||
code: res.code === 0 ? 0 : 1,
|
||||
data: res.data || [],
|
||||
msg: res.message || ''
|
||||
};
|
||||
},
|
||||
tree: {
|
||||
customName: {
|
||||
children: "children",
|
||||
isParent: "is_parent",
|
||||
name: "title",
|
||||
id: "id",
|
||||
pid: "pid",
|
||||
icon: "cover"
|
||||
},
|
||||
data: { isSimpleData: true, rootPid: 0 },
|
||||
view: {},
|
||||
async: {},
|
||||
callback: {}
|
||||
},
|
||||
height: 'full-100',
|
||||
toolbar: '#tableBarTpl',
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '分类名称', width: 220, fixed: 'left' },
|
||||
{ field: 'cover', title: '图标', width: 100, templet: function (d) {
|
||||
return d.cover ? '<span style="font-size:18px;">' + d.cover + '</span>' : '<span class="layui-badge-rim">无</span>';
|
||||
} },
|
||||
{ field: 'sort', title: '排序', width: 80, sort: true },
|
||||
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
|
||||
{ fixed: "right", title: "操作", width: 181, align: "center", toolbar: "#dataBarTpl" }
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
var view = $('#dataTable').next('.layui-table-view');
|
||||
view.find('[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
$('.layui-btn[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
treeTable.on('toolbar(dataTable)', function (obj) {
|
||||
var options = obj.config;
|
||||
switch (obj.event) {
|
||||
case 'dataCreate':
|
||||
active.dataCreate({ pid: 0 });
|
||||
break;
|
||||
case 'dataDelete':
|
||||
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(ids);
|
||||
break;
|
||||
case 'dataRecybin':
|
||||
active.dataRecybin();
|
||||
break;
|
||||
};
|
||||
});
|
||||
treeTable.on('tool(dataTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'update':
|
||||
active.dataEdit(data);
|
||||
break;
|
||||
case 'create':
|
||||
active.dataCreate({ pid: data.id });
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
// 状态开关
|
||||
form.on('switch(statusSwitch)', function (obj) {
|
||||
var id = this.value;
|
||||
var status = obj.elem.checked ? 1 : 0;
|
||||
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该分类吗?', function (index) {
|
||||
layui.request.post('update', { id: id, status: status }).then(function (res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.msg, { icon: 1 });
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg, { icon: 2 });
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
}
|
||||
});
|
||||
layer.close(index);
|
||||
}, function () {
|
||||
obj.elem.checked = !obj.elem.checked;
|
||||
form.render('checkbox');
|
||||
});
|
||||
});
|
||||
|
||||
var dataFromFun = function (data, callback, done) {
|
||||
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: data.id ? '编辑分类' : (data.pid ? '添加子分类' : '添加根分类'),
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
btnAlign: "l",
|
||||
area: ['520px', '98%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
btn: ['确定', '取消'],
|
||||
success: function (layero, index) {
|
||||
callback(layero, index);
|
||||
form.render();
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
|
||||
done(layero, index, elem);
|
||||
layui.off('submit(wxapp-form)', 'from');
|
||||
return false;
|
||||
});
|
||||
layero.contents().find("#wxapp-form-submit").trigger('click');
|
||||
}
|
||||
});
|
||||
};
|
||||
//事件
|
||||
var active = {
|
||||
dataCreate: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) {
|
||||
},
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.post('save', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataEdit: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) {
|
||||
},
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
field.status = field.status ? 1 : 0;
|
||||
http.put('update', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataDelete: function (ids, force = 0) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
layer.prompt({
|
||||
formType: 1
|
||||
, title: '敏感操作,请验证口令'
|
||||
}, function (value, index) {
|
||||
layer.close(index);
|
||||
layer.confirm('确定删除吗?其下级分类与关联资源将一并删除,且不可恢复。', function (index) {
|
||||
http.delete('delete', { ids: ids.join(','), force: force })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
layer.msg('已删除');
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
});
|
||||
});
|
||||
},
|
||||
dataRecybin: function (data = {}) {
|
||||
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: '分类回收站',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
area: ['60%', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
success: function (layero, index) {
|
||||
layui.table.render({
|
||||
elem: '#dataRecybinTable',
|
||||
url: 'recyclebin',
|
||||
height: 'full-100',
|
||||
toolbar: '#dataRecybinBarTpl',
|
||||
defaultToolbar: [{
|
||||
title: '批量删除数据',
|
||||
layEvent: 'dataDelete',
|
||||
icon: 'layui-icon-delete',
|
||||
onClick: function (obj) {
|
||||
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
console.log(ids);
|
||||
}
|
||||
}, 'filter', 'exports', 'print'],
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '分类名称', width: 200, fixed: 'left' },
|
||||
{
|
||||
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
|
||||
return `<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
|
||||
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div >`;
|
||||
}
|
||||
}
|
||||
]],
|
||||
page: true,
|
||||
done: function (res, curr, count, origin) {
|
||||
layui.table.on('tool(dataRecybinTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'restore':
|
||||
active.dataRestore([data.id]);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id], 1);
|
||||
break;
|
||||
}
|
||||
});
|
||||
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let idss = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(idss, 1);
|
||||
break;
|
||||
case 'dataRestore':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataRestore(ids);
|
||||
break;
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
dataRestore: function (ids) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
http.put('restore', { ids: ids.join(',') })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.msg('恢复成功');
|
||||
treeTable.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '恢复失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,397 @@
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"></div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 表格顶部工具条 -->
|
||||
<script type="text/html" id="tableBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="新建资源"
|
||||
lay-event="dataCreate"
|
||||
data-perm="download:add">
|
||||
<i class="layui-icon layui-icon-add-1"></i>
|
||||
</a>
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="删除资源"
|
||||
lay-event="dataDelete"
|
||||
data-perm="download:delete"
|
||||
><i class="layui-icon layui-icon-delete"></i>
|
||||
</a>
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="资源回收站"
|
||||
lay-event="dataRecybin"
|
||||
data-perm="download:recyclebin"
|
||||
><i class="layui-icon layui-icon-home"></i>
|
||||
</a>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 表格数据工具条 -->
|
||||
<script type="text/html" id="dataBarTpl">
|
||||
<div class="layui-btn-group">
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="编辑资源"
|
||||
lay-event="update"
|
||||
data-perm="download:edit"
|
||||
><i class="layui-icon layui-icon-edit"></i>
|
||||
</a>
|
||||
<a
|
||||
class="layui-btn layui-btn-sm layui-btn-primary"
|
||||
title="删除资源"
|
||||
lay-event="delete"
|
||||
data-perm="download:delete"
|
||||
><i class="layui-icon layui-icon-delete"></i>
|
||||
</a>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 表格回收站 -->
|
||||
<script type="text/html" id="dataRecybinTpl">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"></div>
|
||||
<div class="layui-card-body">
|
||||
<table
|
||||
class="layui-hide"
|
||||
id="dataRecybinTable"
|
||||
lay-filter="dataRecybinTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<!-- 资源状态 -->
|
||||
<script type="text/html" id="statusTpl">
|
||||
{{# if(d.status == 1) { }}
|
||||
<span class="layui-badge layui-bg-green">正常</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-orange">下架</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<!-- 免费/收费 -->
|
||||
<script type="text/html" id="freeTpl">
|
||||
{{# if(d.is_free == 1) { }}
|
||||
<span class="layui-badge layui-bg-green">免费</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-orange">收费</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<!-- 添加/编辑资源表单 -->
|
||||
<script type="text/html" id="dataFormTpl">
|
||||
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
|
||||
<input type="hidden" name="id" value="{{d.id||''}}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">资源标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="请输入资源标题" autocomplete="off" class="layui-input" value="{{d.title||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">所属分类</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="cid" lay-verify="required">
|
||||
{volist name="categories" id="c"}
|
||||
<option value="{$c.id}" {{ d.cid==$c.id ? 'selected' : '' }}>{$c.title}</option>
|
||||
{/volist}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">作者</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="author" placeholder="请输入作者" autocomplete="off" class="layui-input" value="{{d.author||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">版本</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="version" placeholder="如 1.0.0" autocomplete="off" class="layui-input" value="{{d.version||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">下载地址</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="file_url" placeholder="外链下载地址" autocomplete="off" class="layui-input" value="{{d.file_url||''}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">本地存储</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="is_local" lay-skin="switch" lay-text="本地|外链" {{ d.is_local==1 ? 'checked' : '' }} value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">是否免费</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="is_free" lay-skin="switch" lay-text="免费|收费" {{ d.is_free!=0 ? 'checked' : '' }} value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="checkbox" name="status" lay-skin="switch" lay-text="正常|下架" {{ d.status!=0 ? 'checked' : '' }} value="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="intro" placeholder="资源简介" class="layui-textarea">{{d.intro||''}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item layui-hide">
|
||||
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
|
||||
</div>
|
||||
</form>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
layui.use(['http', 'auth'], function () {
|
||||
var $ = layui.$, table = layui.table, form = layui.form, layer = layui.layer
|
||||
, laytpl = layui.laytpl, http = layui.http
|
||||
, auth = layui.auth;
|
||||
var _savedPerms = (layui.data('backend').permission) || [];
|
||||
auth.init(_savedPerms);
|
||||
auth.setController('download');
|
||||
var dataTable = table.render({
|
||||
elem: '#dataTable'
|
||||
, url: 'index' // 后端数据接口
|
||||
, page: true
|
||||
, limit: 15
|
||||
, toolbar: '#tableBarTpl'
|
||||
, cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' }
|
||||
, { field: 'id', title: 'ID', width: 60, align: 'center', sort: true, fixed: 'left' }
|
||||
, { field: 'title', title: '标题', minWidth: 200, align: 'left', fixed: 'left' }
|
||||
, { field: 'category_title', title: '分类', width: 120, align: 'center' }
|
||||
, { field: 'author', title: '作者', width: 120, align: 'center' }
|
||||
, { field: 'version', title: '版本', width: 90, align: 'center' }
|
||||
, { field: 'downloads', title: '下载数', width: 90, align: 'center', sort: true }
|
||||
, { field: 'is_free', title: '类型', width: 80, align: 'center', templet: '#freeTpl' }
|
||||
, { field: 'status', title: '状态', width: 90, align: 'center', templet: '#statusTpl' }
|
||||
, { title: '操作', width: 120, align: 'left', toolbar: '#dataBarTpl', fixed: 'right', unresize: true }
|
||||
]]
|
||||
, done: function (res, curr, count) {
|
||||
var view = $('#dataTable').next('.layui-table-view');
|
||||
view.find('[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
$('.layui-btn[data-perm]').each(function () {
|
||||
var perm = $(this).attr('data-perm');
|
||||
if (perm && !auth.has(perm)) { $(this).remove(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
table.on('toolbar(dataTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataCreate':
|
||||
active.dataCreate({ });
|
||||
break;
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(ids);
|
||||
break;
|
||||
case 'dataRecybin':
|
||||
active.dataRecybin();
|
||||
break;
|
||||
};
|
||||
});
|
||||
|
||||
table.on('tool(dataTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'update':
|
||||
active.dataEdit(data);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id]);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
var dataFromFun = function (data, callback, done) {
|
||||
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: data.id ? '编辑资源' : '添加资源',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
btnAlign: "l",
|
||||
area: ['50% ', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
btn: ['确定', '取消'],
|
||||
success: function (layero, index) {
|
||||
callback(layero, index);
|
||||
form.render();
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
|
||||
done(layero, index, elem);
|
||||
layui.off('submit(wxapp-form)', 'from');
|
||||
return false;
|
||||
});
|
||||
layero.contents().find("#wxapp-form-submit").trigger('click');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//事件
|
||||
var active = {
|
||||
dataDelete: function (ids, force = 0) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
layer.prompt({
|
||||
formType: 1
|
||||
, title: '敏感操作,请验证口令'
|
||||
}, function (value, index) {
|
||||
layer.close(index);
|
||||
layer.confirm('确定删除吗?', function (index) {
|
||||
http.delete("delete", { ids: ids.join(','), force: force })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
dataCreate: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
http.post('save', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataEdit: function (data = {}) {
|
||||
dataFromFun(data,
|
||||
function (layero, index) { },
|
||||
function (layero, index, elem) {
|
||||
var field = elem.field;
|
||||
http.put('update', field)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.close(index);
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
dataRecybin: function (data = {}) {
|
||||
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
|
||||
layer.open({
|
||||
title: '资源回收站',
|
||||
content: formHtml,
|
||||
anim: "slideLeft",
|
||||
offset: "r",
|
||||
area: ['60%', '99%'],
|
||||
shade: 0.1,
|
||||
shadeClose: true,
|
||||
success: function (layero, index) {
|
||||
table.render({
|
||||
elem: '#dataRecybinTable',
|
||||
url: "recyclebin",
|
||||
toolbar: '#tableBarTpl',
|
||||
defaultToolbar: [{
|
||||
title: '批量删除数据',
|
||||
layEvent: 'dataDelete',
|
||||
icon: 'layui-icon-delete',
|
||||
onClick: function (obj) {
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
console.log(ids);
|
||||
}
|
||||
}, 'filter', 'exports', 'print'],
|
||||
cols: [[
|
||||
{ type: 'checkbox', fixed: 'left' },
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
|
||||
{ field: 'title', title: '标题', width: 200, fixed: 'left' },
|
||||
{ field: 'category_title', title: '分类', width: 120, align: 'center' },
|
||||
{ field: 'author', title: '作者', width: 120, align: 'center' },
|
||||
{
|
||||
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
|
||||
return `<div class="layui-btn-group">
|
||||
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
|
||||
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
|
||||
</div >`;
|
||||
}
|
||||
}
|
||||
]],
|
||||
page: true,
|
||||
done: function () {
|
||||
table.on('tool(dataRecybinTable)', function (elem) {
|
||||
var data = elem.data;
|
||||
switch (elem.event) {
|
||||
case 'restore':
|
||||
active.dataRestore([data.id]);
|
||||
break;
|
||||
case 'delete':
|
||||
active.dataDelete([data.id], 1);
|
||||
break;
|
||||
}
|
||||
});
|
||||
table.on('toolbar(dataRecybinTable)', function (elem) {
|
||||
switch (elem.event) {
|
||||
case 'dataDelete':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let idss = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataDelete(idss, 1);
|
||||
break;
|
||||
case 'dataRestore':
|
||||
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
|
||||
let ids = checkData.map((item, index, array) => { return item.id; });
|
||||
active.dataRestore(ids);
|
||||
break;
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
dataRestore: function (ids) {
|
||||
if (ids.length === 0) {
|
||||
return layer.msg('请选择数据');
|
||||
}
|
||||
http.put("restore", { ids: ids.join(',') })
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
layer.msg('恢复成功');
|
||||
table.reload('dataTable', {}, true);
|
||||
} else {
|
||||
layer.msg(res.msg || '恢复失败');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<div class="dl-crumb">
|
||||
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
|
||||
{present name="cat.title"}
|
||||
<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a><span>/</span>
|
||||
{/present}
|
||||
<strong>{$resource.title|lang}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dl-detail">
|
||||
<img class="dl-detail-cover" src="{$resource.cover|default='/static/download/img/default.svg'}" alt="{$resource.title|lang}">
|
||||
<div class="dl-detail-info">
|
||||
<h1 class="dl-detail-title">{$resource.title|lang}</h1>
|
||||
<div class="dl-detail-row">大小:{$resource.size_text|default='-'}</div>
|
||||
<div class="dl-detail-row">分类:{present name="cat.title"}<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a>{else/}—{/present}</div>
|
||||
<div class="dl-detail-row">更新:{$resource.update_at|default='-'}</div>
|
||||
<div class="dl-detail-row">下载:{$resource.downloads|default=0}</div>
|
||||
<div class="dl-detail-row">
|
||||
{if $resource.is_free == 1}<span class="dl-tag-free">免费</span>{else/}<span class="dl-tag-pay">付费</span>{/if}
|
||||
</div>
|
||||
<a class="dl-btn-down" href="{:url('index/down', ['id'=>$resource.id])}">{$download_btn_text}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-detail-intro">
|
||||
<h3>资源简介</h3>
|
||||
<p>{$resource.intro|default='暂无简介'}</p>
|
||||
</div>
|
||||
|
||||
<a class="dl-back" href="{:url('index/list', ['cid'=>$cat.id|default=0])}">← {$list_text}</a>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<section class="dl-hero">
|
||||
<div>
|
||||
<h2>{$site_name} · 海量资源 高速免费下载</h2>
|
||||
<p>精选软件、源码与工具,安全无捆绑,下载更省心。</p>
|
||||
<div class="slogan">一键下载 · 更新无插件 · 卸载无残留</div>
|
||||
</div>
|
||||
<a class="dl-hero-btn" href="{:url('index/list', ['cid'=>0])}">{$all_category_text}</a>
|
||||
</section>
|
||||
|
||||
{notempty name="hot"}
|
||||
<section class="dl-block">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$hot_text}</h2>
|
||||
<span class="dl-refresh" onclick="location.href='{:url(\'index/index\')}'">换一换</span>
|
||||
</div>
|
||||
<ul class="dl-grid">
|
||||
{volist name="hot" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</section>
|
||||
{/notempty}
|
||||
|
||||
{notempty name="news"}
|
||||
<section class="dl-block">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$latest_text}</h2>
|
||||
<span class="dl-refresh" onclick="location.href='{:url(\'index/index\')}'">换一换</span>
|
||||
</div>
|
||||
<ul class="dl-grid">
|
||||
{volist name="news" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
</section>
|
||||
{/notempty}
|
||||
|
||||
<section class="dl-block">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$all_category_text}</h2>
|
||||
</div>
|
||||
<div class="dl-cat-grid">
|
||||
{volist name="categories" id="cat"}
|
||||
<div class="dl-cat">
|
||||
<h3><a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a></h3>
|
||||
<div class="dl-cat-list">
|
||||
{volist name="cat.resources" id="r"}
|
||||
<a href="{:url('index/detail', ['id'=>$r.id])}">{$r.title|lang}</a>
|
||||
{/volist}
|
||||
</div>
|
||||
<a class="dl-cat-more" href="{:url('index/list', ['cid'=>$cat.id])}">{$all_category_text} ›</a>
|
||||
</div>
|
||||
{/volist}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<div class="dl-crumb">
|
||||
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
|
||||
{present name="cat.title"}
|
||||
<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a>
|
||||
{else/}
|
||||
<strong>{$all_category_text}</strong>
|
||||
{/present}
|
||||
</div>
|
||||
|
||||
<form class="dl-searchbar" action="{:url('index/search')}" method="get">
|
||||
<input type="text" name="kw" placeholder="{$search_placeholder}" value="{$keyword|default=''}">
|
||||
<button type="submit">{$search_text}</button>
|
||||
</form>
|
||||
|
||||
{notempty name="list"}
|
||||
<ul class="dl-grid">
|
||||
{volist name="list" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
|
||||
{notempty name="pager"}
|
||||
<div class="dl-pager">{$pager|raw}</div>
|
||||
{/notempty}
|
||||
{else/}
|
||||
<div class="dl-empty">{$empty_text|default='暂无资源'}</div>
|
||||
{/notempty}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
|
||||
|
||||
<div class="dl-wrap">
|
||||
|
||||
<div class="dl-crumb">
|
||||
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
|
||||
<strong>{$search_text}</strong>
|
||||
</div>
|
||||
|
||||
<form class="dl-searchbar" action="{:url('index/search')}" method="get">
|
||||
<input type="text" name="kw" placeholder="{$search_placeholder}" value="{$keyword|default=''}">
|
||||
<button type="submit">{$search_text}</button>
|
||||
</form>
|
||||
|
||||
{notempty name="list"}
|
||||
<div class="dl-block" style="border:0;padding:0;background:transparent;">
|
||||
<div class="dl-block-head">
|
||||
<h2 class="dl-block-title">{$search_text}:{$keyword|default=''}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="dl-grid">
|
||||
{volist name="list" id="item"}
|
||||
<li class="dl-card">
|
||||
<a href="{:url('index/detail', ['id'=>$item.id])}">
|
||||
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
|
||||
<span class="dl-name">{$item.title|lang}</span>
|
||||
</a>
|
||||
<p class="dl-desc">{$item.intro|default=''}</p>
|
||||
<span class="dl-meta">{$item.size_text|default='-'}</span>
|
||||
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
|
||||
</li>
|
||||
{/volist}
|
||||
</ul>
|
||||
|
||||
{notempty name="pager"}
|
||||
<div class="dl-pager">{$pager|raw}</div>
|
||||
{/notempty}
|
||||
{else/}
|
||||
<div class="dl-empty">{$empty_text|default='未找到相关资源'}</div>
|
||||
{/notempty}
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user